From d1e512054028df27f2f6f34c70ff2cda39a5ac15 Mon Sep 17 00:00:00 2001 From: new_debugger Date: Wed, 5 Aug 2026 10:12:34 +0800 Subject: [PATCH] Add DeepSmart --- .agents/skills/dev-workflow/SKILL.md | 264 ++ .agents/skills/linting-and-style/SKILL.md | 96 + .agents/skills/testing-edge-drivers/SKILL.md | 349 ++ .../understanding-lua-libraries/SKILL.md | 295 ++ .../skills/understanding-profiles/SKILL.md | 369 ++ .github/PULL_REQUEST_TEMPLATE.md | 27 + .github/copilot-instructions.md | 52 + .github/scripts/check_duplicates.py | 94 +- .github/scripts/check_profile_categories.py | 82 + .../check-profile-categories-comment.yml | 34 + .../workflows/check-profile-categories.yml | 41 + .github/workflows/create-channel.yml | 5 +- .github/workflows/delete-channel.yml | 1 + .../workflows/duplicate-profiles-comment.yml | 2 +- .github/workflows/duplicate-profiles.yml | 21 +- .github/workflows/jenkins-driver-tests.yml | 46 + .github/workflows/publish-test-results.yml | 63 +- .github/workflows/run-tests.yml | 81 +- .github/workflows/upload-driver-packages.yml | 6 +- .gitignore | 3 + AGENTS.md | 130 + Jenkinsfile | 60 +- README.md | 2 +- drivers/ABB/insite-scu200/config.yml | 7 + .../profiles/auxiliary-contact.yml | 34 + drivers/ABB/insite-scu200/profiles/bridge.yml | 8 + .../profiles/current-sensor-consumption.yml | 16 + .../profiles/current-sensor-production.yml | 16 + .../insite-scu200/profiles/energy-meter.yml | 44 + .../ABB/insite-scu200/profiles/gas-meter.yml | 10 + .../insite-scu200/profiles/output-module.yml | 10 + .../profiles/usb-energy-meter.yml | 14 + .../insite-scu200/profiles/water-meter.yml | 10 + .../ABB/insite-scu200/search-parameters.yaml | 2 + drivers/ABB/insite-scu200/src/abb/api.lua | 145 + .../insite-scu200/src/abb/device_manager.lua | 115 + .../src/abb/device_refresher.lua | 382 ++ drivers/ABB/insite-scu200/src/commands.lua | 111 + drivers/ABB/insite-scu200/src/config.lua | 72 + .../insite-scu200/src/connection_monitor.lua | 71 + drivers/ABB/insite-scu200/src/discovery.lua | 315 ++ .../insite-scu200/src/eventsource_handler.lua | 128 + drivers/ABB/insite-scu200/src/fields.lua | 16 + drivers/ABB/insite-scu200/src/init.lua | 45 + drivers/ABB/insite-scu200/src/lifecycles.lua | 87 + .../ABB/insite-scu200/src/lunchbox/init.lua | 4 + .../ABB/insite-scu200/src/lunchbox/rest.lua | 427 +++ .../src/lunchbox/sse/eventsource.lua | 523 +++ .../ABB/insite-scu200/src/lunchbox/util.lua | 46 + drivers/ABB/insite-scu200/src/utils.lua | 219 ++ drivers/Aqara/aqara-bath-heater/config.yml | 6 + .../Aqara/aqara-bath-heater/fingerprints.yml | 6 + .../profiles/aqara-bath-heater.yml | 36 + .../aqara-bath-heater/src/aqara_cluster.lua | 14 + drivers/Aqara/aqara-bath-heater/src/init.lua | 538 +++ .../src/test/test_aqara_bath_heater.lua | 854 +++++ drivers/Aqara/aqara-cube/src/init.lua | 3 +- drivers/Aqara/aqara-feeder/src/init.lua | 1 + .../capabilities/antiLockStatus.yaml | 22 + .../aqara-lock/capabilities/lockAlarm.yaml | 27 + drivers/Aqara/aqara-lock/fingerprints.yml | 10 + .../profiles/aqara-lock-battery.yml | 6 +- .../Aqara/aqara-lock/src/credential_utils.lua | 30 + drivers/Aqara/aqara-lock/src/init.lua | 220 +- .../aqara-lock/src/test/test_aqara_lock.lua | 26 +- .../src/test/test_aqara_lock_L100.lua | 77 + .../Aqara/aqara-presence-sensor/config.yml | 5 + .../profiles/aqara-fp2-fallDetection.yml | 20 + .../profiles/aqara-fp2-sleepMonitoring.yml | 16 + .../profiles/aqara-fp2-zoneDetection.yml | 22 + .../search-parameters.yml | 3 + .../aqara-presence-sensor/src/discovery.lua | 133 + .../src/discovery_mdns.lua | 143 + .../aqara-presence-sensor/src/fields.lua | 17 + .../aqara-presence-sensor/src/fp2/api.lua | 91 + .../src/fp2/device_manager.lua | 279 ++ .../src/fp2/discovery_helper.lua | 88 + .../Aqara/aqara-presence-sensor/src/init.lua | 280 ++ .../src/lunchbox/init.lua | 4 + .../src/lunchbox/rest.lua | 398 ++ .../src/lunchbox/sse/eventsource.lua | 552 +++ .../src/lunchbox/util.lua | 45 + .../src/multipleZonePresence.lua | 154 + .../src/selfSignedRootByAqaraLife.crt | 20 + .../aqara-presence-sensor}/src/utils.lua | 87 +- drivers/DeepSmart/deepsmart/CLAUDE.md | 93 + drivers/DeepSmart/deepsmart/config.yml | 5 + drivers/DeepSmart/deepsmart/profiles/Ac.yml | 22 + .../DeepSmart/deepsmart/profiles/Curtain.yml | 12 + .../deepsmart/profiles/Deepsmart-bridge.yml | 8 + .../DeepSmart/deepsmart/profiles/Heater.yml | 20 + drivers/DeepSmart/deepsmart/profiles/Hue.yml | 18 + .../DeepSmart/deepsmart/profiles/Light.yml | 14 + .../DeepSmart/deepsmart/profiles/Newfan.yml | 12 + .../DeepSmart/deepsmart/profiles/Slider.yml | 16 + .../DeepSmart/deepsmart/search-parameters.yml | 2 + drivers/DeepSmart/deepsmart/src/commands.lua | 227 ++ drivers/DeepSmart/deepsmart/src/config.lua | 78 + .../DeepSmart/deepsmart/src/deepsmart/api.lua | 227 ++ .../deepsmart/src/deepsmart/devices.lua | 270 ++ .../deepsmart/src/deepsmart/dp2knx.lua | 164 + .../deepsmart/src/deepsmart/dpenum.lua | 99 + .../deepsmart/src/deepsmart/wisers.lua | 942 +++++ drivers/DeepSmart/deepsmart/src/discovery.lua | 45 + drivers/DeepSmart/deepsmart/src/init.lua | 195 + .../DeepSmart/deepsmart/src/lifecycles.lua | 91 + .../DeepSmart/deepsmart/src/lunchbox/init.lua | 4 + .../DeepSmart/deepsmart/src/lunchbox/rest.lua | 369 ++ .../src/lunchbox/sse/eventsource.lua | 511 +++ .../DeepSmart/deepsmart/src/lunchbox/util.lua | 46 + .../deepsmart/src/selfSignedRoot.crt | 22 + drivers/DeepSmart/deepsmart/src/ssdp.lua | 89 + drivers/DeepSmart/deepsmart/src/utils.lua | 235 ++ drivers/SinuxSoft/britzyhub/README.md | 9 + drivers/SinuxSoft/britzyhub/config.yml | 6 + drivers/SinuxSoft/britzyhub/fingerprints.yml | 16 + .../SinuxSoft/britzyhub/profiles/elevator.yml | 10 + .../britzyhub/profiles/gas-valve.yml | 10 + drivers/SinuxSoft/britzyhub/profiles/vent.yml | 19 + .../SinuxSoft/britzyhub/src/elevator/init.lua | 91 + .../britzyhub/src/gas-valve/init.lua | 91 + drivers/SinuxSoft/britzyhub/src/init.lua | 16 + drivers/SinuxSoft/britzyhub/src/vent/init.lua | 164 + .../harman-luxury/profiles/harman-luxury.yaml | 2 - .../harman-luxury/profiles/maX10.yaml | 4 +- .../harman-luxury/src/api/apis.lua | 266 +- .../harman-luxury/src/api/nsdk.lua | 9 +- .../harman-luxury/src/constants.lua | 19 +- .../SmartThings/harman-luxury/src/disco.lua | 134 +- .../harman-luxury/src/handlers.lua | 239 -- .../harman-luxury/src/hl_websocket.lua | 298 ++ .../SmartThings/harman-luxury/src/init.lua | 414 +- .../harman-luxury/src/lustre/ws.lua | 655 ++++ drivers/SmartThings/hub/config.yml | 4 + drivers/SmartThings/hub/fingerprints.yml | 29 + .../SmartThings/hub/profiles/cooktop-hub.yml | 33 + .../SmartThings/hub/profiles/hub-thing.yml | 29 + .../hub/profiles/refrigerator-hub.yml | 33 + .../SmartThings/hub/profiles/soundbar-hub.yml | 33 + drivers/SmartThings/hub/profiles/tv-hub.yml | 33 + drivers/SmartThings/hub/profiles/v4-hub.yml | 43 + .../hub/profiles/washer-dryer-hub.yml | 33 + drivers/SmartThings/hub/src/init.lua | 0 drivers/SmartThings/jbl/profiles/jbl.yml | 2 - drivers/SmartThings/jbl/src/init.lua | 16 +- drivers/SmartThings/jbl/src/jbl/api.lua | 5 +- .../jbl/src/jbl/capability_handler.lua | 5 - .../jbl/src/jbl/device_manager.lua | 1 - .../SmartThings/matter-appliance/config.yml | 4 + .../matter-appliance/fingerprints.yml | 72 + .../cook-surface-one-cook-surface-two-tl.yml | 24 + .../cook-surface-one-cook-surface-two.yml | 22 + ...ook-surface-one-tl-cook-surface-two-tl.yml | 26 + .../cook-surface-one-tl-cook-surface-two.yml | 24 + .../profiles/cook-surface-one-tl.yml | 19 + .../profiles/cook-surface-one.yml | 17 + .../matter-appliance/profiles/cook-top.yml | 13 + .../profiles/dishwasher-tl.yml | 24 + .../profiles/dishwasher-tn-tl.yml | 29 + .../profiles/dishwasher-tn.yml | 27 + .../matter-appliance/profiles/dishwasher.yml | 25 + .../profiles/extractor-hood-ac-light.yml | 30 + .../profiles/extractor-hood-ac-wind-light.yml | 32 + .../profiles/extractor-hood-ac-wind.yml | 26 + .../profiles/extractor-hood-ac.yml | 24 + .../profiles/extractor-hood-hepa-ac-light.yml | 39 + .../extractor-hood-hepa-ac-wind-light.yml | 41 + .../profiles/extractor-hood-hepa-ac-wind.yml | 35 + .../profiles/extractor-hood-hepa-ac.yml | 33 + .../profiles/extractor-hood-hepa-light.yml | 30 + .../extractor-hood-hepa-wind-light.yml | 32 + .../profiles/extractor-hood-hepa-wind.yml | 26 + .../profiles/extractor-hood-hepa.yml | 24 + .../profiles/extractor-hood-light.yml | 20 + .../profiles/extractor-hood-wind-light.yml | 22 + .../profiles/extractor-hood-wind.yml | 16 + .../profiles/extractor-hood.yml | 14 + .../profiles/laundry-dryer-tl.yml | 21 + .../profiles/laundry-dryer-tn-tl.yml | 23 + .../profiles/laundry-dryer-tn.yml | 21 + .../profiles/laundry-dryer.yml | 19 + .../profiles/laundry-washer-tl.yml | 25 + .../profiles/laundry-washer-tn-tl.yml | 27 + .../profiles/laundry-washer-tn.yml | 25 + .../profiles/laundry-washer.yml | 23 + .../profiles/microwave-oven.yml | 19 + ...ook-surface-one-tl-cook-surface-two-tl.yml | 50 + .../profiles/refrigerator-freezer-tl.yml | 40 + .../profiles/refrigerator-freezer-tn-tl.yml | 40 + .../profiles/refrigerator-freezer-tn.yml | 40 + .../profiles/refrigerator-freezer.yml | 44 + .../ActivatedCarbonFilterMonitoring/init.lua | 83 + .../server/attributes/AttributeList.lua | 115 + .../server/attributes/ChangeIndication.lua | 105 + .../server/attributes/Condition.lua | 68 + .../server/attributes/init.lua | 24 + .../types/ChangeIndicationEnum.lua | 33 + .../types/Feature.lua | 98 + .../types/init.lua | 18 + .../src/DishwasherAlarm/init.lua | 94 + .../server/attributes/AcceptedCommandList.lua | 75 + .../server/attributes/Mask.lua | 68 + .../server/attributes/State.lua | 68 + .../server/attributes/Supported.lua | 68 + .../server/attributes/init.lua | 23 + .../server/commands/ModifyEnabledAlarms.lua | 98 + .../src/DishwasherAlarm/types/AlarmMap.lua | 149 + .../src/DishwasherAlarm/types/Feature.lua | 54 + .../src/DishwasherAlarm/types/init.lua | 18 + .../src/DishwasherMode/init.lua | 96 + .../server/attributes/AcceptedCommandList.lua | 75 + .../server/attributes/CurrentMode.lua | 69 + .../server/attributes/SupportedModes.lua | 75 + .../DishwasherMode/server/attributes/init.lua | 24 + .../server/commands/ChangeToMode.lua | 87 + .../DishwasherMode/server/commands/init.lua | 23 + .../src/DishwasherMode/types/Feature.lua | 54 + .../DishwasherMode/types/ModeOptionStruct.lua | 85 + .../src/DishwasherMode/types/ModeTag.lua | 31 + .../DishwasherMode/types/ModeTagStruct.lua | 78 + .../src/DishwasherMode/types/init.lua | 18 + .../src/HepaFilterMonitoring/init.lua | 83 + .../server/attributes/AttributeList.lua | 115 + .../server/attributes/ChangeIndication.lua | 105 + .../server/attributes/Condition.lua | 68 + .../server/attributes/init.lua | 24 + .../types/ChangeIndicationEnum.lua | 33 + .../HepaFilterMonitoring/types/Feature.lua | 98 + .../src/HepaFilterMonitoring/types/init.lua | 18 + .../src/LaundryWasherControls/init.lua | 76 + .../server/attributes/NumberOfRinses.lua | 80 + .../server/attributes/SpinSpeedCurrent.lua | 87 + .../server/attributes/SpinSpeeds.lua | 74 + .../server/attributes/SupportedRinses.lua | 74 + .../server/attributes/init.lua | 23 + .../LaundryWasherControls/types/Feature.lua | 75 + .../types/NumberOfRinsesEnum.lua | 35 + .../src/LaundryWasherControls/types/init.lua | 17 + .../src/LaundryWasherMode/init.lua | 96 + .../server/attributes/CurrentMode.lua | 67 + .../server/attributes/OnMode.lua | 87 + .../server/attributes/StartUpMode.lua | 80 + .../server/attributes/SupportedModes.lua | 74 + .../server/attributes/init.lua | 23 + .../server/commands/ChangeToMode.lua | 86 + .../server/commands/init.lua | 22 + .../src/LaundryWasherMode/types/Feature.lua | 53 + .../types/ModeOptionStruct.lua | 85 + .../src/LaundryWasherMode/types/ModeTag.lua | 33 + .../LaundryWasherMode/types/ModeTagStruct.lua | 77 + .../src/LaundryWasherMode/types/init.lua | 17 + .../src/MicrowaveOvenControl/init.lua | 88 + .../server/attributes/AcceptedCommandList.lua | 74 + .../server/attributes/CookTime.lua | 68 + .../server/attributes/MaxCookTime.lua | 68 + .../server/attributes/init.lua | 23 + .../server/commands/AddMoreTime.lua | 97 + .../server/commands/SetCookingParameters.lua | 125 + .../server/commands/init.lua | 22 + .../MicrowaveOvenControl/types/Feature.lua | 97 + .../src/MicrowaveOvenControl/types/init.lua | 18 + .../src/MicrowaveOvenMode/init.lua | 72 + .../server/attributes/AcceptedCommandList.lua | 74 + .../server/attributes/CurrentMode.lua | 68 + .../server/attributes/SupportedModes.lua | 75 + .../server/attributes/init.lua | 23 + .../src/MicrowaveOvenMode/types/Feature.lua | 53 + .../types/ModeOptionStruct.lua | 85 + .../src/MicrowaveOvenMode/types/ModeTag.lua | 27 + .../MicrowaveOvenMode/types/ModeTagStruct.lua | 78 + .../src/MicrowaveOvenMode/types/init.lua | 20 + .../src/OperationalState/init.lua | 126 + .../server/attributes/AcceptedCommandList.lua | 74 + .../server/attributes/CountdownTime.lua | 67 + .../server/attributes/CurrentPhase.lua | 67 + .../server/attributes/OperationalError.lua | 67 + .../server/attributes/OperationalState.lua | 67 + .../attributes/OperationalStateList.lua | 74 + .../server/attributes/PhaseList.lua | 74 + .../server/attributes/init.lua | 23 + .../server/commands/Pause.lua | 79 + .../server/commands/Resume.lua | 79 + .../server/commands/Start.lua | 79 + .../OperationalState/server/commands/Stop.lua | 79 + .../OperationalState/server/commands/init.lua | 22 + .../server/events/OperationalError.lua | 100 + .../OperationalState/server/events/init.lua | 23 + .../OperationalState/types/ErrorStateEnum.lua | 33 + .../types/ErrorStateStruct.lua | 84 + .../types/OperationalStateEnum.lua | 35 + .../types/OperationalStateStruct.lua | 77 + .../src/OperationalState/types/init.lua | 17 + .../matter-appliance/src/OvenMode/init.lua | 84 + .../server/attributes/CurrentMode.lua | 67 + .../server/attributes/SupportedModes.lua | 74 + .../src/OvenMode/server/attributes/init.lua | 23 + .../OvenMode/server/commands/ChangeToMode.lua | 86 + .../src/OvenMode/server/commands/init.lua | 22 + .../src/OvenMode/types/Feature.lua | 54 + .../src/OvenMode/types/ModeOptionStruct.lua | 84 + .../src/OvenMode/types/ModeTag.lua | 48 + .../src/OvenMode/types/ModeTagStruct.lua | 77 + .../src/OvenMode/types/init.lua | 18 + .../src/RefrigeratorAlarm/init.lua | 60 + .../server/attributes/Mask.lua | 68 + .../server/attributes/State.lua | 68 + .../server/attributes/Supported.lua | 68 + .../server/attributes/init.lua | 24 + .../src/RefrigeratorAlarm/types/AlarmMap.lua | 47 + .../src/RefrigeratorAlarm/types/init.lua | 18 + .../init.lua | 105 + .../server/attributes/CurrentMode.lua | 67 + .../server/attributes/SupportedModes.lua | 74 + .../server/attributes/init.lua | 23 + .../server/commands/ChangeToMode.lua | 86 + .../server/commands/init.lua | 22 + .../types/Feature.lua | 80 + .../types/ModeOptionStruct.lua | 106 + .../types/ModeTag.lua | 50 + .../types/ModeTagStruct.lua | 97 + .../types/init.lua | 17 + .../src/TemperatureControl/init.lua | 99 + .../server/attributes/MaxTemperature.lua | 67 + .../server/attributes/MinTemperature.lua | 67 + .../attributes/SelectedTemperatureLevel.lua | 67 + .../server/attributes/Step.lua | 67 + .../attributes/SupportedTemperatureLevels.lua | 74 + .../server/attributes/TemperatureSetpoint.lua | 67 + .../server/attributes/init.lua | 23 + .../server/commands/SetTemperature.lua | 104 + .../server/commands/init.lua | 22 + .../src/TemperatureControl/types/Feature.lua | 97 + .../src/TemperatureControl/types/init.lua | 17 + .../matter-appliance/src/common-utils.lua | 169 + .../src/embedded-cluster-utils.lua | 91 + .../SmartThings/matter-appliance/src/init.lua | 305 ++ .../src/lazy_load_subdriver.lua | 14 + .../src/matter-cook-top/can_handle.lua | 21 + .../src/matter-cook-top/init.lua | 73 + .../src/matter-dishwasher/can_handle.lua | 17 + .../src/matter-dishwasher/init.lua | 250 ++ .../src/matter-extractor-hood/can_handle.lua | 16 + .../src/matter-extractor-hood/init.lua | 294 ++ .../src/matter-laundry/can_handle.lua | 19 + .../src/matter-laundry/init.lua | 301 ++ .../src/matter-microwave-oven/can_handle.lua | 16 + .../src/matter-microwave-oven/init.lua | 256 ++ .../src/matter-oven/can_handle.lua | 14 + .../matter-appliance/src/matter-oven/init.lua | 129 + .../src/matter-refrigerator/can_handle.lua | 16 + .../src/matter-refrigerator/init.lua | 166 + .../matter-appliance/src/sub_drivers.lua | 14 + .../src/test/test_cook_top.lua | 199 + .../src/test/test_dishwasher.lua | 556 +++ .../src/test/test_extractor_hood.lua | 723 ++++ .../src/test/test_laundry_dryer.lua | 598 +++ .../src/test/test_laundry_washer.lua | 185 + .../src/test/test_matter_appliance_rpc_5.lua | 927 +++++ .../src/test/test_microwave_oven.lua | 579 +++ .../matter-appliance/src/test/test_oven.lua | 564 +++ .../src/test/test_refrigerator.lua | 358 ++ .../matter-button/fingerprints.yml | 6 - .../profiles/5-button-battery.yml | 38 + .../matter-button/profiles/5-button.yml | 36 + .../SmartThings/matter-button/src/init.lua | 9 +- .../src/test/skip_test_latching_switch.lua | 18 +- .../src/test/test_matter_button.lua | 33 + .../test/test_matter_button_parent_child.lua | 113 +- .../src/test/test_matter_multi_button.lua | 98 +- drivers/SmartThings/matter-energy/config.yml | 6 + .../matter-energy/fingerprints.yml | 17 + .../profiles/battery-storage.yml | 30 + .../evse-energy-meas-energy-mgmt-mode.yml | 27 + ...nergy-meas-power-meas-energy-mgmt-mode.yml | 32 + .../profiles/evse-energy-meas.yml | 22 + .../evse-power-meas-energy-mgmt-mode.yml | 30 + .../profiles/evse-power-meas.yml | 25 + .../matter-energy/profiles/evse.yml | 19 + .../matter-energy/profiles/solar-power.yml | 18 + .../src/DeviceEnergyManagementMode/init.lua | 86 + .../server/attributes/AcceptedCommandList.lua | 80 + .../server/attributes/AttributeList.lua | 81 + .../server/attributes/CurrentMode.lua | 67 + .../server/attributes/SupportedModes.lua | 74 + .../server/attributes/init.lua | 23 + .../server/commands/ChangeToMode.lua | 79 + .../server/commands/init.lua | 22 + .../types/Feature.lua | 54 + .../types/ModeOptionStruct.lua | 85 + .../types/ModeTag.lua | 33 + .../types/ModeTagStruct.lua | 77 + .../DeviceEnergyManagementMode/types/init.lua | 16 + .../src/ElectricalEnergyMeasurement/init.lua | 66 + .../attributes/CumulativeEnergyExported.lua | 67 + .../attributes/CumulativeEnergyImported.lua | 67 + .../attributes/PeriodicEnergyExported.lua | 67 + .../attributes/PeriodicEnergyImported.lua | 67 + .../server/attributes/init.lua | 23 + .../types/EnergyMeasurementStruct.lua | 97 + .../types/Feature.lua | 115 + .../types/init.lua | 14 + .../src/ElectricalPowerMeasurement/init.lua | 53 + .../server/attributes/ActivePower.lua | 67 + .../server/attributes/PowerMode.lua | 68 + .../server/attributes/init.lua | 23 + .../types/Feature.lua | 137 + .../types/PowerModeEnum.lua | 30 + .../ElectricalPowerMeasurement/types/init.lua | 14 + .../matter-energy/src/EnergyEvse/init.lua | 162 + .../server/attributes/AcceptedCommandList.lua | 79 + .../attributes/ApproximateEVEfficiency.lua | 76 + .../server/attributes/AttributeList.lua | 80 + .../server/attributes/BatteryCapacity.lua | 79 + .../attributes/ChargingEnabledUntil.lua | 64 + .../server/attributes/CircuitCapacity.lua | 79 + .../attributes/DischargingEnabledUntil.lua | 64 + .../server/attributes/EventList.lua | 80 + .../server/attributes/FaultState.lua | 113 + .../attributes/MaximumChargeCurrent.lua | 79 + .../attributes/MaximumDischargeCurrent.lua | 79 + .../attributes/MinimumChargeCurrent.lua | 79 + .../attributes/NextChargeRequiredEnergy.lua | 78 + .../server/attributes/NextChargeStartTime.lua | 64 + .../server/attributes/NextChargeTargetSoC.lua | 64 + .../attributes/NextChargeTargetTime.lua | 64 + .../attributes/RandomizationDelayWindow.lua | 91 + .../server/attributes/SessionDuration.lua | 78 + .../attributes/SessionEnergyCharged.lua | 78 + .../attributes/SessionEnergyDischarged.lua | 78 + .../server/attributes/SessionID.lua | 64 + .../EnergyEvse/server/attributes/State.lua | 93 + .../server/attributes/StateOfCharge.lua | 64 + .../server/attributes/SupplyState.lua | 89 + .../attributes/UserMaximumChargeCurrent.lua | 92 + .../server/attributes/VehicleID.lua | 64 + .../src/EnergyEvse/server/attributes/init.lua | 23 + .../server/commands/ClearTargets.lua | 84 + .../EnergyEvse/server/commands/Disable.lua | 84 + .../server/commands/EnableCharging.lua | 106 + .../server/commands/EnableDischarging.lua | 99 + .../EnergyEvse/server/commands/GetTargets.lua | 72 + .../EnergyEvse/server/commands/SetTargets.lua | 93 + .../server/commands/StartDiagnostics.lua | 84 + .../src/EnergyEvse/server/commands/init.lua | 22 + .../EnergyEvse/server/events/EVConnected.lua | 97 + .../server/events/EVNotDetected.lua | 129 + .../server/events/EnergyTransferStarted.lua | 114 + .../server/events/EnergyTransferStopped.lua | 123 + .../src/EnergyEvse/server/events/Fault.lua | 123 + .../src/EnergyEvse/server/events/Rfid.lua | 97 + .../src/EnergyEvse/server/events/init.lua | 23 + .../types/ChargingTargetScheduleStruct.lua | 71 + .../EnergyEvse/types/ChargingTargetStruct.lua | 78 + .../types/EnergyTransferStoppedReasonEnum.lua | 26 + .../src/EnergyEvse/types/FaultStateEnum.lua | 54 + .../src/EnergyEvse/types/Feature.lua | 137 + .../src/EnergyEvse/types/StateEnum.lua | 34 + .../src/EnergyEvse/types/SupplyStateEnum.lua | 30 + .../types/TargetDayOfWeekBitmap.lua | 170 + .../src/EnergyEvse/types/init.lua | 16 + .../matter-energy/src/EnergyEvseMode/init.lua | 96 + .../server/attributes/AcceptedCommandList.lua | 80 + .../server/attributes/AttributeList.lua | 80 + .../server/attributes/CurrentMode.lua | 64 + .../server/attributes/SupportedModes.lua | 74 + .../EnergyEvseMode/server/attributes/init.lua | 23 + .../server/commands/ChangeToMode.lua | 79 + .../EnergyEvseMode/server/commands/init.lua | 22 + .../src/EnergyEvseMode/types/Feature.lua | 55 + .../EnergyEvseMode/types/ModeOptionStruct.lua | 85 + .../src/EnergyEvseMode/types/ModeTag.lua | 30 + .../EnergyEvseMode/types/ModeTagStruct.lua | 77 + .../src/EnergyEvseMode/types/init.lua | 16 + .../src/embedded_cluster_utils.lua | 60 + .../SmartThings/matter-energy/src/init.lua | 758 ++++ .../src/test/test_battery_storage.lua | 308 ++ .../matter-energy/src/test/test_evse.lua | 548 +++ .../src/test/test_evse_energy_meas.lua | 159 + .../src/test/test_solar_power.lua | 204 + drivers/SmartThings/matter-hrap/config.yml | 6 + .../SmartThings/matter-hrap/fingerprints.yml | 11 + .../network-infrastructure-manager.yml | 16 + .../profiles/thread-border-router.yml | 14 + .../client/commands/DatasetResponse.lua | 103 + .../client/commands/init.lua | 23 + .../src/ThreadBorderRouterManagement/init.lua | 148 + .../attributes/ActiveDatasetTimestamp.lua | 68 + .../server/attributes/BorderRouterName.lua | 69 + .../server/attributes/InterfaceEnabled.lua | 69 + .../server/attributes/ThreadVersion.lua | 68 + .../server/attributes/init.lua | 24 + .../commands/GetActiveDatasetRequest.lua | 79 + .../server/commands/init.lua | 28 + .../types/Feature.lua | 54 + .../types/init.lua | 15 + .../src/WiFiNetworkManagement/init.lua | 58 + .../server/attributes/Ssid.lua | 68 + .../server/attributes/init.lua | 24 + .../src/embedded-cluster-utils.lua | 62 + drivers/SmartThings/matter-hrap/src/init.lua | 217 ++ .../test_thread_border_router_network.lua | 363 ++ .../SmartThings/matter-lock/fingerprints.yml | 209 +- .../profiles/base-lock-batteryLevel.yml | 26 + .../profiles/base-lock-nobattery.yml | 24 + .../matter-lock/profiles/base-lock.yml | 3 +- .../matter-lock/profiles/lock-battery.yml | 25 + .../profiles/lock-batteryLevel.yml | 25 + .../profiles/lock-lockalarm-batteryLevel.yml | 24 + .../profiles/lock-lockalarm-nobattery.yml | 22 + .../matter-lock/profiles/lock-lockalarm.yml | 24 + .../lock-modular-embedded-unlatch.yml | 134 + .../matter-lock/profiles/lock-modular.yml | 34 + .../lock-nocodes-notamper-batteryLevel.yml | 22 + .../profiles/lock-nocodes-notamper.yml | 3 +- .../profiles/lock-unlatch-battery.yml | 120 + .../profiles/lock-unlatch-batteryLevel.yml | 120 + .../matter-lock/profiles/lock-unlatch.yml | 112 + .../profiles/lock-user-pin-battery.yml | 29 + .../profiles/lock-user-pin-batteryLevel.yml | 29 + .../lock-user-pin-schedule-battery.yml | 31 + .../lock-user-pin-schedule-batteryLevel.yml | 31 + ...lock-user-pin-schedule-unlatch-battery.yml | 126 + ...user-pin-schedule-unlatch-batteryLevel.yml | 126 + .../lock-user-pin-schedule-unlatch.yml | 118 + .../profiles/lock-user-pin-schedule.yml | 29 + .../profiles/lock-user-pin-unlatch.yml | 116 + .../matter-lock/profiles/lock-user-pin.yml | 27 + .../profiles/lock-user-schedule-unlatch.yml | 116 + .../profiles/lock-user-schedule.yml | 27 + .../profiles/lock-user-unlatch.yml | 114 + .../matter-lock/profiles/lock-user.yml | 25 + .../lock-without-codes-batteryLevel.yml | 24 + .../profiles/lock-without-codes-nobattery.yml | 22 + .../profiles/lock-without-codes.yml | 3 +- .../SmartThings/matter-lock/profiles/lock.yml | 23 + .../nonfunctional-lock-batteryLevel.yml | 17 + .../profiles/nonfunctional-lock.yml | 1 + .../commands/GetCredentialStatusResponse.lua | 129 + .../client/commands/GetUserResponse.lua | 165 + .../commands/GetWeekDayScheduleResponse.lua | 150 + .../commands/GetYearDayScheduleResponse.lua | 129 + .../client/commands/SetCredentialResponse.lua | 115 + .../src/DoorLock/client/commands/init.lua | 23 + .../matter-lock/src/DoorLock/init.lua | 262 ++ .../server/attributes/AcceptedCommandList.lua | 74 + .../server/attributes/ActuatorEnabled.lua | 67 + .../attributes/AliroBLEAdvertisingVersion.lua | 67 + ...edTransactionSupportedProtocolVersions.lua | 74 + .../attributes/AliroGroupResolvingKey.lua | 67 + .../attributes/AliroReaderGroupIdentifier.lua | 67 + .../AliroReaderGroupSubIdentifier.lua | 67 + .../attributes/AliroReaderVerificationKey.lua | 67 + .../server/attributes/AttributeList.lua | 74 + .../DoorLock/server/attributes/DoorState.lua | 67 + .../DoorLock/server/attributes/EventList.lua | 74 + .../DoorLock/server/attributes/LockState.lua | 67 + .../server/attributes/MaxPINCodeLength.lua | 67 + .../server/attributes/MinPINCodeLength.lua | 67 + ...erOfAliroCredentialIssuerKeysSupported.lua | 67 + .../NumberOfAliroEndpointKeysSupported.lua | 67 + .../NumberOfCredentialsSupportedPerUser.lua | 67 + .../attributes/NumberOfPINUsersSupported.lua | 67 + .../NumberOfTotalUsersSupported.lua | 67 + ...mberOfWeekDaySchedulesSupportedPerUser.lua | 67 + ...mberOfYearDaySchedulesSupportedPerUser.lua | 67 + .../server/attributes/OperatingMode.lua | 80 + .../RequirePINforRemoteOperation.lua | 80 + .../server/attributes/SendPINOverTheAir.lua | 80 + .../attributes/SupportedOperatingModes.lua | 67 + .../UserCodeTemporaryDisableTime.lua | 80 + .../server/attributes/WrongCodeEntryLimit.lua | 80 + .../src/DoorLock/server/attributes/init.lua | 23 + .../commands/ClearAliroReaderConfig.lua | 91 + .../server/commands/ClearCredential.lua | 98 + .../DoorLock/server/commands/ClearUser.lua | 98 + .../server/commands/ClearWeekDaySchedule.lua | 104 + .../server/commands/ClearYearDaySchedule.lua | 104 + .../server/commands/GetCredentialStatus.lua | 86 + .../src/DoorLock/server/commands/GetUser.lua | 86 + .../server/commands/GetWeekDaySchedule.lua | 93 + .../server/commands/GetYearDaySchedule.lua | 93 + .../src/DoorLock/server/commands/LockDoor.lua | 98 + .../server/commands/SetAliroReaderConfig.lua | 119 + .../server/commands/SetCredential.lua | 122 + .../src/DoorLock/server/commands/SetUser.lua | 140 + .../server/commands/SetWeekDaySchedule.lua | 139 + .../server/commands/SetYearDaySchedule.lua | 118 + .../DoorLock/server/commands/UnlockDoor.lua | 98 + .../src/DoorLock/server/commands/init.lua | 22 + .../DoorLock/server/events/DoorLockAlarm.lua | 100 + .../server/events/DoorStateChange.lua | 100 + .../DoorLock/server/events/LockOperation.lua | 136 + .../server/events/LockOperationError.lua | 143 + .../DoorLock/server/events/LockUserChange.lua | 142 + .../src/DoorLock/server/events/init.lua | 23 + .../src/DoorLock/types/AlarmCodeEnum.lua | 45 + .../src/DoorLock/types/CredentialRuleEnum.lua | 31 + .../src/DoorLock/types/CredentialStruct.lua | 77 + .../src/DoorLock/types/CredentialTypeEnum.lua | 48 + .../DoorLock/types/DataOperationTypeEnum.lua | 30 + .../src/DoorLock/types/DaysMaskMap.lua | 169 + .../types/DlKeypadOperationEventMask.lua | 190 + .../types/DlKeypadProgrammingEventMask.lua | 127 + .../types/DlLocalProgrammingFeatures.lua | 106 + .../src/DoorLock/types/DlLockState.lua | 33 + .../types/DlManualOperationEventMask.lua | 253 ++ .../types/DlRFIDOperationEventMask.lua | 169 + .../types/DlRFIDProgrammingEventMask.lua | 85 + .../types/DlRemoteOperationEventMask.lua | 169 + .../types/DlRemoteProgrammingEventMask.lua | 169 + .../src/DoorLock/types/DlStatus.lua | 42 + .../types/DlSupportedOperatingModes.lua | 127 + .../src/DoorLock/types/DoorLockDayOfWeek.lua | 169 + .../types/DoorLockOperationEventCode.lua | 66 + .../types/DoorLockProgrammingEventCode.lua | 42 + .../types/DoorLockSetPinOrIdStatus.lua | 33 + .../src/DoorLock/types/DoorLockUserStatus.lua | 33 + .../src/DoorLock/types/DoorLockUserType.lua | 39 + .../src/DoorLock/types/DoorStateEnum.lua | 39 + .../src/DoorLock/types/Feature.lua | 361 ++ .../src/DoorLock/types/LockDataTypeEnum.lua | 63 + .../DoorLock/types/LockOperationTypeEnum.lua | 36 + .../src/DoorLock/types/OperatingModeEnum.lua | 36 + .../src/DoorLock/types/OperationErrorEnum.lua | 36 + .../DoorLock/types/OperationSourceEnum.lua | 54 + .../src/DoorLock/types/UserStatusEnum.lua | 30 + .../src/DoorLock/types/UserTypeEnum.lua | 51 + .../matter-lock/src/DoorLock/types/init.lua | 17 + drivers/SmartThings/matter-lock/src/init.lua | 3175 +++++++++++++--- .../matter-lock/src/lazy_load_subdriver.lua | 14 + .../matter-lock/src/lock_utils.lua | 250 +- .../matter-lock/src/sub_drivers.lua | 8 + .../src/test/test_aqara_matter_lock.lua | 334 ++ .../src/test/test_bridged_matter_lock.lua | 109 + .../matter-lock/src/test/test_matter_lock.lua | 150 +- .../src/test/test_matter_lock_battery.lua | 161 + .../test/test_matter_lock_batteryLevel.lua | 102 + .../src/test/test_matter_lock_codes.lua | 241 +- .../src/test/test_matter_lock_cota.lua | 136 +- .../src/test/test_matter_lock_migration.lua | 143 + .../src/test/test_matter_lock_modular.lua | 699 ++++ .../src/test/test_matter_lock_unlatch.lua | 387 ++ .../src/test/test_new_matter_lock.lua | 2616 +++++++++++++ .../src/test/test_new_matter_lock_aliro.lua | 1263 ++++++ .../src/test/test_new_matter_lock_battery.lua | 849 +++++ .../src/unmigrated-matter-lock/can_handle.lua | 17 + .../src/unmigrated-matter-lock/init.lua | 758 ++++ .../src/test/test_matter_media_speaker.lua | 570 +-- .../test/test_matter_media_video_player.lua | 166 +- drivers/SmartThings/matter-pump/config.yml | 4 + .../SmartThings/matter-pump/fingerprints.yml | 6 + .../profiles/pump-level.yml} | 45 +- .../matter-pump/profiles/pump-only.yml | 19 + .../src/PumpConfigurationAndControl/init.lua | 119 + .../server/attributes/ControlMode.lua | 80 + .../attributes/EffectiveControlMode.lua | 67 + .../attributes/EffectiveOperationMode.lua | 67 + .../server/attributes/OperationMode.lua | 80 + .../server/attributes/PumpStatus.lua | 67 + .../server/attributes/Speed.lua | 67 + .../server/attributes/init.lua | 23 + .../types/ControlModeEnum.lua | 46 + .../types/Feature.lua | 185 + .../types/OperationModeEnum.lua | 35 + .../types/PumpStatusBitmap.lua | 211 ++ .../types/init.lua | 14 + .../src/embedded-cluster-utils.lua | 49 + drivers/SmartThings/matter-pump/src/init.lua | 329 ++ .../robotCleanerOperatingState.yml | 108 + drivers/SmartThings/matter-rvc/config.yml | 4 + .../SmartThings/matter-rvc/fingerprints.yml | 13 + .../profiles/rvc-clean-mode-service-area.yml | 20 + .../matter-rvc/profiles/rvc-clean-mode.yml | 18 + .../matter-rvc/profiles/rvc-service-area.yml | 15 + .../SmartThings/matter-rvc/profiles/rvc.yml | 13 + .../matter-rvc/src/Global/init.lua | 26 + .../src/Global/types/AreaTypeTag.lua | 423 +++ .../src/Global/types/LandmarkTag.lua | 247 ++ .../Global/types/LocationDescriptorStruct.lua | 106 + .../src/Global/types/RelativePositionTag.lua | 71 + .../matter-rvc/src/Global/types/init.lua | 36 + .../matter-rvc/src/OperationalState/init.lua | 11 + .../OperationalState/types/ErrorStateEnum.lua | 33 + .../types/ErrorStateStruct.lua | 84 + .../types/OperationalStateEnum.lua | 33 + .../types/OperationalStateStruct.lua | 77 + .../src/OperationalState/types/init.lua | 14 + .../client/commands/ChangeToModeResponse.lua | 108 + .../src/RvcCleanMode/client/commands/init.lua | 22 + .../matter-rvc/src/RvcCleanMode/init.lua | 103 + .../server/attributes/CurrentMode.lua | 68 + .../server/attributes/SupportedModes.lua | 74 + .../RvcCleanMode/server/attributes/init.lua | 23 + .../server/commands/ChangeToMode.lua | 86 + .../src/RvcCleanMode/server/commands/init.lua | 22 + .../src/RvcCleanMode/types/Feature.lua | 53 + .../RvcCleanMode/types/ModeOptionStruct.lua | 85 + .../src/RvcCleanMode/types/ModeTag.lua | 30 + .../src/RvcCleanMode/types/ModeTagStruct.lua | 77 + .../src/RvcCleanMode/types/StatusCode.lua | 24 + .../src/RvcCleanMode/types/init.lua | 14 + .../src/RvcOperationalState/init.lua | 113 + .../server/attributes/AcceptedCommandList.lua | 75 + .../server/attributes/OperationalError.lua | 67 + .../server/attributes/OperationalState.lua | 68 + .../attributes/OperationalStateList.lua | 74 + .../server/attributes/init.lua | 23 + .../server/commands/GoHome.lua | 79 + .../server/commands/Pause.lua | 79 + .../server/commands/Resume.lua | 79 + .../server/commands/Start.lua | 79 + .../server/commands/Stop.lua | 79 + .../server/commands/init.lua | 22 + .../server/events/OperationalError.lua | 100 + .../server/events/init.lua | 23 + .../types/ErrorStateEnum.lua | 45 + .../types/ErrorStateStruct.lua | 84 + .../types/OperationalStateEnum.lua | 30 + .../types/OperationalStateStruct.lua | 77 + .../src/RvcOperationalState/types/init.lua | 14 + .../client/commands/ChangeToModeResponse.lua | 108 + .../src/RvcRunMode/client/commands/init.lua | 22 + .../matter-rvc/src/RvcRunMode/init.lua | 103 + .../server/attributes/CurrentMode.lua | 67 + .../RvcRunMode/server/attributes/OnMode.lua | 80 + .../server/attributes/StartUpMode.lua | 80 + .../server/attributes/SupportedModes.lua | 74 + .../src/RvcRunMode/server/attributes/init.lua | 23 + .../server/commands/ChangeToMode.lua | 86 + .../src/RvcRunMode/server/commands/init.lua | 22 + .../src/RvcRunMode/types/Feature.lua | 53 + .../src/RvcRunMode/types/ModeOptionStruct.lua | 85 + .../src/RvcRunMode/types/ModeTag.lua | 30 + .../src/RvcRunMode/types/ModeTagStruct.lua | 77 + .../src/RvcRunMode/types/StatusCode.lua | 45 + .../matter-rvc/src/RvcRunMode/types/init.lua | 14 + .../client/commands/SelectAreasResponse.lua | 150 + .../src/ServiceArea/client/commands/init.lua | 42 + .../matter-rvc/src/ServiceArea/init.lua | 183 + .../server/attributes/SelectedAreas.lua | 126 + .../server/attributes/SupportedAreas.lua | 126 + .../ServiceArea/server/attributes/init.lua | 44 + .../server/commands/SelectAreas.lua | 127 + .../src/ServiceArea}/server/commands/init.lua | 13 +- .../src/ServiceArea}/server/events/init.lua | 12 +- .../src/ServiceArea/types/AreaInfoStruct.lua | 98 + .../src/ServiceArea/types/AreaStruct.lua | 106 + .../src/ServiceArea/types/Feature.lua | 137 + .../ServiceArea/types/LandmarkInfoStruct.lua | 98 + .../ServiceArea/types/SelectAreasStatus.lua | 59 + .../src/ServiceArea}/types/init.lua | 13 +- .../matter-rvc/src/embedded_cluster_utils.lua | 49 + drivers/SmartThings/matter-rvc/src/init.lua | 662 ++++ .../matter-rvc/src/test/test_matter_rvc.lua | 1212 ++++++ .../matter-sensor/fingerprints.yml | 250 +- .../matter-sensor/profiles/aqara-fp400.yml | 14 + .../matter-sensor/profiles/aqs-level.yml | 51 + .../profiles/aqs-modular-humidity.yml | 81 + .../profiles/aqs-modular-temp-humidity.yml | 83 + .../profiles/aqs-modular-temp.yml | 81 + .../matter-sensor/profiles/aqs-modular.yml | 78 + .../aqs-temp-humidity-all-level-all-meas.yml | 59 + .../profiles/aqs-temp-humidity-all-level.yml | 41 + .../profiles/aqs-temp-humidity-all-meas.yml | 39 + ...s-temp-humidity-co2-pm1-pm25-pm10-meas.yml | 29 + .../aqs-temp-humidity-co2-pm25-tvoc-meas.yml | 27 + ...aqs-temp-humidity-tvoc-level-pm25-meas.yml | 25 + .../profiles/aqs-temp-humidity-tvoc-meas.yml | 18 + .../matter-sensor/profiles/aqs.yml | 12 + .../matter-sensor/profiles/co-battery.yml | 16 + .../profiles/co-comeas-battery.yml | 18 + .../profiles/co-comeas-colevel-battery.yml | 20 + .../matter-sensor/profiles/co-comeas.yml | 18 + .../SmartThings/matter-sensor/profiles/co.yml | 16 + .../profiles/contact-batteryLevel.yml | 14 + .../profiles/contact-button-battery.yml | 16 + .../matter-sensor/profiles/flow-battery.yml | 14 + .../profiles/flow-batteryLevel.yml | 14 + .../matter-sensor/profiles/flow.yml | 12 + ...freeze-battery-fault-freezeSensitivity.yml | 25 + .../profiles/freeze-battery-fault.yml | 23 + .../freeze-battery-freezeSensitivity.yml | 23 + .../matter-sensor/profiles/freeze-battery.yml | 20 + ...e-batteryLevel-fault-freezeSensitivity.yml | 25 + .../profiles/freeze-batteryLevel-fault.yml | 23 + .../freeze-batteryLevel-freezeSensitivity.yml | 23 + .../profiles/freeze-batteryLevel.yml | 20 + .../freeze-fault-freezeSensitivity.yml | 23 + .../matter-sensor/profiles/freeze-fault.yml | 20 + ...ault-freezeSensitivity-leakSensitivity.yml | 27 + .../matter-sensor/profiles/freeze.yml | 18 + .../profiles/humidity-batteryLevel.yml | 17 + .../profiles/illuminance-batteryLevel.yml | 14 + .../leak-battery-fault-leakSensitivity.yml | 19 + .../profiles/leak-battery-fault.yml | 16 + .../profiles/leak-battery-leakSensitivity.yml | 17 + .../matter-sensor/profiles/leak-battery.yml | 14 + ...eak-batteryLevel-fault-leakSensitivity.yml | 19 + .../profiles/leak-batteryLevel-fault.yml | 16 + .../leak-batteryLevel-leakSensitivity.yml | 17 + .../profiles/leak-batteryLevel.yml | 14 + .../profiles/leak-fault-leakSensitivity.yml | 17 + .../matter-sensor/profiles/leak-fault.yml | 14 + .../matter-sensor/profiles/leak.yml | 12 + .../matter-motion-battery-illuminance.yml | 1 + .../profiles/matter-motion-battery.yml | 1 + ...matter-motion-batteryLevel-illuminance.yml | 17 + .../profiles/matter-motion-batteryLevel.yml | 15 + .../profiles/motion-batteryLevel.yml | 14 + .../profiles/motion-contact-batteryLevel.yml | 16 + .../motion-illuminance-batteryLevel.yml | 16 + ...motion-illuminance-temperature-battery.yml | 5 +- ...n-illuminance-temperature-batteryLevel.yml | 18 + ...luminance-temperature-humidity-battery.yml | 25 + ...ance-temperature-humidity-batteryLevel.yml | 25 + ...otion-illuminance-temperature-humidity.yml | 23 + .../motion-illuminance-temperature.yml | 5 +- .../matter-sensor/profiles/motion.yml | 2 +- ...luminance-temperature-humidity-battery.yml | 25 + .../profiles/pressure-batteryLevel.yml | 14 + .../rain-battery-fault-rainSensitivity.yml | 19 + .../profiles/rain-battery-fault.yml | 16 + .../profiles/rain-battery-rainSensitivity.yml | 17 + .../matter-sensor/profiles/rain-battery.yml | 14 + ...ain-batteryLevel-fault-rainSensitivity.yml | 19 + .../profiles/rain-batteryLevel-fault.yml | 16 + .../rain-batteryLevel-rainSensitivity.yml | 17 + .../profiles/rain-batteryLevel.yml | 14 + .../profiles/rain-fault-rainSensitivity.yml | 17 + .../matter-sensor/profiles/rain-fault.yml | 14 + .../matter-sensor/profiles/rain.yml | 12 + .../profiles/sensor-batteryLevel.yml | 22 + .../matter-sensor/profiles/sensor.yml | 5 + .../matter-sensor/profiles/smoke-battery.yml | 19 + .../profiles/smoke-co-battery.yml | 21 + .../profiles/smoke-co-comeas-battery.yml | 23 + .../profiles/smoke-co-comeas.yml | 23 + .../smoke-co-temp-humidity-comeas-battery.yml | 31 + .../smoke-co-temp-humidity-comeas.yml | 31 + .../matter-sensor/profiles/smoke-co.yml | 21 + .../profiles/smoke-temp-humidity-battery.yml | 25 + .../matter-sensor/profiles/smoke.yml | 19 + .../profiles/soil-sensor-battery.yml | 17 + .../profiles/soil-sensor-batteryLevel.yml | 17 + .../matter-sensor/profiles/soil-sensor.yml | 15 + .../profiles/temperature-batteryLevel.yml | 17 + .../profiles/temperature-humidity-battery.yml | 44 +- .../temperature-humidity-batteryLevel.yml | 63 + .../temperature-humidity-pressure-battery.yml | 50 +- ...erature-humidity-pressure-batteryLevel.yml | 71 + .../temperature-humidity-pressure.yml | 44 +- .../profiles/temperature-humidity.yml | 38 +- .../temperature-soil-sensor-battery.yml | 21 + .../temperature-soil-sensor-batteryLevel.yml | 21 + .../profiles/temperature-soil-sensor.yml | 19 + .../src/embedded_clusters/AirQuality/init.lua | 62 + .../server/attributes/AcceptedCommandList.lua | 78 + .../server/attributes/AirQuality.lua | 71 + .../server/attributes/AttributeList.lua | 78 + .../server/attributes/EventList.lua | 78 + .../AirQuality/server/attributes/init.lua | 27 + .../AirQuality/types/AirQualityEnum.lua | 48 + .../AirQuality/types/Feature.lua | 123 + .../AirQuality/types/init.lua | 18 + .../BooleanStateConfiguration/init.lua | 81 + .../attributes/CurrentSensitivityLevel.lua | 83 + .../attributes/DefaultSensitivityLevel.lua | 70 + .../server/attributes/SensorFault.lua | 70 + .../attributes/SupportedSensitivityLevels.lua | 70 + .../server/attributes/init.lua | 28 + .../types/Feature.lua | 122 + .../types/SensorFaultBitmap.lua | 47 + .../BooleanStateConfiguration/types/init.lua | 17 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../ConcentrationMeasurement/init.lua | 111 + .../server/attributes/LevelValue.lua | 73 + .../server/attributes/MeasuredValue.lua | 73 + .../server/attributes/MeasurementUnit.lua | 72 + .../server/attributes/init.lua | 27 + .../types/Feature.lua | 167 + .../types/LevelValueEnum.lua | 42 + .../types/MeasurementUnitEnum.lua | 51 + .../ConcentrationMeasurement/types/init.lua | 18 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../src/embedded_clusters/Global/init.lua | 5 + .../Global/types/LevelValueEnum.lua | 36 + .../types/MeasurementAccuracyRangeStruct.lua | 119 + .../types/MeasurementAccuracyStruct.lua | 99 + .../Global/types/MeasurementTypeEnum.lua | 75 + .../embedded_clusters/Global/types/init.lua | 14 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../OzoneConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../Pm10ConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../Pm1ConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../Pm25ConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../PressureMeasurement/init.lua | 23 +- .../server/attributes/AcceptedCommandList.lua | 17 +- .../server/attributes/AttributeList.lua | 17 +- .../server/attributes/EventList.lua | 17 +- .../server/attributes/MaxMeasuredValue.lua | 17 +- .../server/attributes/MaxScaledValue.lua | 17 +- .../server/attributes/MeasuredValue.lua | 17 +- .../server/attributes/MinMeasuredValue.lua | 17 +- .../server/attributes/MinScaledValue.lua | 17 +- .../server/attributes/Scale.lua | 17 +- .../server/attributes/ScaledTolerance.lua | 17 +- .../server/attributes/ScaledValue.lua | 17 +- .../server/attributes/Tolerance.lua | 17 +- .../server/attributes/init.lua | 24 +- .../server/commands/init.lua | 30 + .../server/events/init.lua | 31 + .../PressureMeasurement/types/Feature.lua | 17 +- .../PressureMeasurement/types/init.lua | 24 + .../RadonConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../embedded_clusters/SmokeCoAlarm/init.lua | 112 + .../server/attributes/BatteryAlert.lua | 72 + .../server/attributes/COState.lua | 72 + .../server/attributes/HardwareFaultAlert.lua | 71 + .../attributes/SmokeSensitivityLevel.lua | 84 + .../server/attributes/SmokeState.lua | 72 + .../server/attributes/TestInProgress.lua | 72 + .../SmokeCoAlarm/server/attributes/init.lua | 27 + .../server/commands/SelfTestRequest.lua | 94 + .../SmokeCoAlarm/server/commands/init.lua | 26 + .../SmokeCoAlarm/types/AlarmStateEnum.lua | 36 + .../SmokeCoAlarm/types/Feature.lua | 79 + .../SmokeCoAlarm/types/SensitivityEnum.lua | 36 + .../SmokeCoAlarm/types/init.lua | 18 + .../SoilMeasurement/init.lua | 46 + .../attributes/SoilMoistureMeasuredValue.lua | 67 + .../SoilMoistureMeasurementLimits.lua | 67 + .../server/attributes/init.lua | 19 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../SmartThings/matter-sensor/src/init.lua | 377 +- .../matter-sensor/src/lazy_load_subdriver.lua | 14 + .../sensor_handlers/attribute_handlers.lua | 240 ++ .../src/sensor_utils/device_configuration.lua | 133 + .../sensor_utils/embedded_cluster_utils.lua | 93 + .../matter-sensor/src/sensor_utils/fields.lua | 58 + .../matter-sensor/src/sensor_utils/utils.lua | 39 + .../matter-sensor/src/sub_drivers.lua | 11 + .../attribute_handlers.lua | 72 + .../device_configuration.lua | 88 + .../air_quality_sensor_utils/fields.lua | 213 ++ .../legacy_device_configuration.lua | 89 + .../air_quality_sensor_utils/utils.lua | 137 + .../air_quality_sensor/can_handle.lua | 17 + .../sub_drivers/air_quality_sensor/init.lua | 154 + .../sub_drivers/aqara_fp400/can_handle.lua | 12 + .../src/sub_drivers/aqara_fp400/init.lua | 23 + .../bosch_button_contact/can_handle.lua | 16 + .../sub_drivers/bosch_button_contact/init.lua | 138 + .../sub_drivers/smoke_co_alarm/can_handle.lua | 17 + .../src/sub_drivers/smoke_co_alarm/init.lua | 265 ++ .../test/test_matter_air_quality_sensor.lua | 836 ++++ ...test_matter_air_quality_sensor_modular.lua | 517 +++ .../src/test/test_matter_aqara_fp400.lua | 141 + .../test/test_matter_bosch_button_contact.lua | 299 ++ .../src/test/test_matter_flow_sensor.lua | 140 + .../test/test_matter_freeze_leak_sensor.lua | 251 ++ .../src/test/test_matter_pressure_sensor.lua | 56 +- .../src/test/test_matter_rain_sensor.lua | 138 + .../src/test/test_matter_sensor.lua | 179 +- .../src/test/test_matter_sensor_battery.lua | 149 + .../test/test_matter_sensor_featuremap.lua | 86 +- .../src/test/test_matter_sensor_rpc.lua | 79 + .../src/test/test_matter_smoke_co_alarm.lua | 585 +++ .../test_matter_smoke_co_alarm_battery.lua | 134 + .../src/test/test_matter_soil_sensor.lua | 229 ++ .../test/test_unsupported_unit_conversion.lua | 63 + .../matter-switch/capabilities/cubeAction.yml | 29 + .../matter-switch/capabilities/cubeFace.yml | 25 + .../matter-switch/fingerprints.yml | 2298 ++++++++++- .../profiles/12-button-keyboard.yml | 79 + .../profiles/2-button-battery.yml | 20 + .../profiles/2-button-batteryLevel.yml | 20 + .../matter-switch/profiles/2-button.yml | 18 + .../3-button-battery-temperature-humidity.yml | 99 + .../profiles/3-button-battery.yml | 26 + .../profiles/3-button-batteryLevel.yml | 26 + .../profiles/3-button-motion.yml | 26 + .../matter-switch/profiles/3-button.yml | 24 + .../profiles/4-button-battery.yml | 32 + .../profiles/4-button-batteryLevel.yml | 32 + .../matter-switch/profiles/4-button.yml | 30 + .../profiles/5-button-battery.yml | 38 + .../profiles/5-button-batteryLevel.yml | 38 + .../matter-switch/profiles/5-button.yml | 36 + .../profiles/6-button-battery.yml | 44 + .../profiles/6-button-batteryLevel.yml | 44 + .../profiles/6-button-motion.yml | 44 + .../matter-switch/profiles/6-button.yml | 42 + .../profiles/7-button-battery.yml | 50 + .../profiles/7-button-batteryLevel.yml | 50 + .../matter-switch/profiles/7-button.yml | 48 + .../profiles/8-button-battery.yml | 57 + .../profiles/8-button-batteryLevel.yml | 57 + .../matter-switch/profiles/8-button.yml | 55 + .../profiles/9-button-battery.yml | 63 + .../profiles/9-button-batteryLevel.yml | 62 + .../matter-switch/profiles/9-button.yml | 60 + .../matter-switch/profiles/button-battery.yml | 14 + .../profiles/button-batteryLevel.yml | 14 + .../matter-switch/profiles/button.yml | 12 + .../matter-switch/profiles/camera.yml | 159 + .../matter-switch/profiles/cube-t1-pro.yml | 22 + .../matter-switch/profiles/fan-modular.yml | 17 + .../profiles/garage-door-battery.yml | 14 + .../profiles/ikea-2-button-battery.yml | 56 + .../matter-switch/profiles/ikea-scroll.yml | 45 + .../profiles/irrigation-system.yml | 26 + .../light-color-level-1800K-6500K.yml | 5 + .../light-color-level-2000K-7000K.yml | 5 + .../light-color-level-2200K-6500K.yml | 5 + .../light-color-level-2700K-6500K.yml | 5 + .../profiles/light-color-level-fan.yml | 35 + ...-level-illuminance-motion-1000K-15000K.yml | 5 + .../light-color-level-illuminance-motion.yml | 4 + .../profiles/light-color-level.yml | 4 + .../light-energy-powerConsumption.yml | 16 + .../profiles/light-level-2-button.yml | 32 + .../profiles/light-level-3-button.yml | 38 + .../profiles/light-level-4-button.yml | 44 + .../profiles/light-level-5-button.yml | 50 + .../profiles/light-level-6-button.yml | 56 + .../profiles/light-level-7-button.yml | 62 + .../profiles/light-level-8-button.yml | 68 + ...ight-level-ColorTemperature-1500-9000k.yml | 5 + ...battery-illuminance-motion-temperature.yml | 32 + .../profiles/light-level-button.yml | 26 + ...ght-level-colorTemperature-2200K-6500K.yml | 5 + ...ght-level-colorTemperature-2700K-6500K.yml | 29 + ...ght-level-colorTemperature-2710k-6500k.yml | 5 + .../profiles/light-level-colorTemperature.yml | 4 + .../light-level-energy-powerConsumption.yml | 24 + .../profiles/light-level-motion.yml | 22 + ...ht-level-power-energy-powerConsumption.yml | 26 + .../profiles/light-level-power.yml | 22 + .../matter-switch/profiles/light-level.yml | 2 + .../light-power-energy-powerConsumption.yml | 18 + .../matter-switch/profiles/light-power.yml | 14 + .../profiles/motion-illuminance.yml | 14 + .../profiles/plug-energy-powerConsumption.yml | 16 + .../plug-level-energy-powerConsumption.yml | 20 + ...ug-level-power-energy-powerConsumption.yml | 22 + .../{m5stack.yml => plug-level-power.yml} | 14 +- .../matter-switch/profiles/plug-level.yml | 2 + .../plug-power-energy-powerConsumption.yml | 18 + .../matter-switch/profiles/plug-power.yml | 14 + .../matter-switch/profiles/switch-2.yml | 1 + .../matter-switch/profiles/switch-3.yml | 1 + .../matter-switch/profiles/switch-4.yml | 1 + .../matter-switch/profiles/switch-5.yml | 1 + .../matter-switch/profiles/switch-6.yml | 1 + .../matter-switch/profiles/switch-7.yml | 1 + .../profiles/switch-color-level.yml | 4 + .../switch-level-colorTemperature.yml | 4 + .../matter-switch/profiles/switch-level.yml | 2 + .../profiles/water-valve-level.yml | 18 + .../matter-switch/profiles/water-valve.yml | 12 + .../profiles/window-covering.yml | 21 + .../src/embedded_clusters/Descriptor/init.lua | 57 + .../server/attributes/DeviceTypeList.lua | 78 + .../server/attributes/PartsList.lua | 78 + .../Descriptor/server/attributes/init.lua | 27 + .../Descriptor/types/DeviceTypeStruct.lua | 82 + .../Descriptor/types/init.lua | 17 + .../ElectricalEnergyMeasurement/init.lua | 56 + .../attributes/CumulativeEnergyImported.lua | 71 + .../attributes/PeriodicEnergyImported.lua | 71 + .../server/attributes/init.lua | 27 + .../types/EnergyMeasurementStruct.lua | 115 + .../types/Feature.lua | 31 + .../types/init.lua | 18 + .../ElectricalPowerMeasurement/init.lua | 44 + .../server/attributes/ActivePower.lua | 70 + .../server/attributes/init.lua | 27 + .../embedded_clusters/PowerTopology/init.lua | 55 + .../server/attributes/AvailableEndpoints.lua | 72 + .../PowerTopology/server/attributes/init.lua | 23 + .../PowerTopology/types/Feature.lua | 32 + .../PowerTopology/types/init.lua | 14 + .../ValveConfigurationAndControl/init.lua | 84 + .../server/attributes/CurrentLevel.lua | 70 + .../server/attributes/CurrentState.lua | 70 + .../server/attributes/init.lua | 26 + .../server/commands/Close.lua | 92 + .../server/commands/Open.lua | 106 + .../server/commands/init.lua | 25 + .../types/Feature.lua | 26 + .../types/ValveStateEnum.lua | 32 + .../types/init.lua | 17 + .../SmartThings/matter-switch/src/init.lua | 807 ++-- .../src/sub_drivers/aqara_cube/can_handle.lua | 14 + .../src/sub_drivers/aqara_cube/init.lua | 239 ++ .../camera_handlers/attribute_handlers.lua | 478 +++ .../camera_handlers/capability_handlers.lua | 502 +++ .../camera/camera_handlers/event_handlers.lua | 48 + .../camera_utils/device_configuration.lua | 460 +++ .../camera/camera_utils/fields.lua | 70 + .../sub_drivers/camera/camera_utils/utils.lua | 341 ++ .../src/sub_drivers/camera/can_handle.lua | 16 + .../src/sub_drivers/camera/init.lua | 213 ++ .../src/sub_drivers/eve_energy/can_handle.lua | 19 + .../eve_energy}/init.lua | 119 +- .../src/sub_drivers/hager/can_handle.lua | 13 + .../src/sub_drivers/hager/init.lua | 483 +++ .../sub_drivers/ikea_scroll/can_handle.lua | 11 + .../src/sub_drivers/ikea_scroll/init.lua | 62 + .../scroll_handlers/event_handlers.lua | 83 + .../scroll_utils/device_configuration.lua | 37 + .../ikea_scroll/scroll_utils/event_utils.lua | 90 + .../ikea_scroll/scroll_utils/fields.lua | 58 + .../ikea_scroll/scroll_utils/utils.lua | 46 + .../third_reality_garage_door/can_handle.lua | 11 + .../third_reality_garage_door/init.lua | 92 + .../third_reality_mk1/can_handle.lua | 11 + .../sub_drivers/third_reality_mk1/init.lua | 113 + .../switch_handlers/attribute_handlers.lua | 636 ++++ .../switch_handlers/capability_handlers.lua | 255 ++ .../src/switch_handlers/event_handlers.lua | 82 + .../src/{ => switch_utils}/color_utils.lua | 9 +- .../src/switch_utils/device_configuration.lua | 321 ++ .../switch_utils/embedded_cluster_utils.lua | 56 + .../matter-switch/src/switch_utils/fields.lua | 235 ++ .../matter-switch/src/switch_utils/utils.lua | 592 +++ .../test/test_aqara_climate_sensor_w100.lua | 497 +++ .../src/test/test_aqara_cube.lua | 279 ++ .../src/test/test_aqara_light_switch_h2.lua | 351 ++ .../src/test/test_electrical_sensor_set.lua | 714 ++++ .../src/test/test_electrical_sensor_tree.lua | 347 ++ .../src/test/test_eve_energy.lua | 363 +- .../src/test/test_hager_waasys.lua | 1780 +++++++++ .../src/test/test_ikea_scroll.lua | 948 +++++ .../test/test_light_illuminance_motion.lua | 501 +-- .../src/test/test_matter_bridge.lua | 109 +- .../src/test/test_matter_button.lua | 601 +++ .../src/test/test_matter_camera.lua | 3372 +++++++++++++++++ .../test/test_matter_irrigation_system.lua | 475 +++ .../src/test/test_matter_light_fan.lua | 307 ++ .../src/test/test_matter_multi_button.lua | 1000 +++++ .../test/test_matter_multi_button_motion.lua | 816 ++++ .../test_matter_multi_button_switch_mcd.lua | 492 +++ ...est_matter_on_off_device_configuration.lua | 303 ++ .../test/test_matter_on_off_parent_child.lua | 878 +++++ .../test_matter_sensor_offset_preferences.lua | 93 + .../src/test/test_matter_switch.lua | 1013 +++-- .../src/test/test_matter_water_valve.lua | 238 ++ .../src/test/test_multi_switch_mcd.lua | 51 +- .../test_multi_switch_parent_child_lights.lua | 469 --- .../test_multi_switch_parent_child_plugs.lua | 231 -- .../src/test/test_stateless_step.lua | 217 ++ .../test/test_third_reality_subdrivers.lua | 533 +++ .../matter-thermostat/fingerprints.yml | 184 +- .../profiles/air-purifier-ac-rock-wind.yml | 28 + .../profiles/air-purifier-ac-rock.yml | 26 + .../profiles/air-purifier-ac-wind.yml | 26 + .../profiles/air-purifier-ac.yml | 24 + .../profiles/air-purifier-aqs.yml | 16 + ...h2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml | 61 + .../air-purifier-hepa-ac-rock-wind.yml | 37 + .../profiles/air-purifier-hepa-ac-rock.yml | 35 + ...rature-humidity-fan-aqs-pm25-tvoc-meas.yml | 43 + ...n-nostate-nobattery-aqs-pm25-tvoc-meas.yml | 49 + .../profiles/air-purifier-hepa-ac-wind.yml | 35 + .../profiles/air-purifier-hepa-ac.yml | 33 + .../air-purifier-hepa-aqs-pm25-meas.yml | 28 + .../profiles/air-purifier-hepa-aqs.yml | 26 + .../profiles/air-purifier-hepa-rock-wind.yml | 28 + .../profiles/air-purifier-hepa-rock.yml | 26 + ...ier-hepa-wind-aqs-pm25-meas-pm25-level.yml | 32 + .../profiles/air-purifier-hepa-wind.yml | 26 + .../profiles/air-purifier-hepa.yml | 24 + .../profiles/air-purifier-modular.yml | 130 + .../profiles/air-purifier-rock-wind.yml | 18 + .../profiles/air-purifier-rock.yml | 16 + .../air-purifier-temperature-humidity-aqs.yml | 20 + .../air-purifier-thermostat-humidity-aqs.yml | 35 + ...rifier-thermostat-nostate-humidity-aqs.yml | 26 + .../profiles/air-purifier-wind.yml | 16 + .../profiles/air-purifier.yml | 14 + .../profiles/fan-generic.yml | 17 + .../profiles/fan-rock-wind.yml | 21 + .../matter-thermostat/profiles/fan-rock.yml | 19 + .../matter-thermostat/profiles/fan-wind.yml | 19 + .../matter-thermostat/profiles/fan.yml | 20 + ...hermostat-humidity-thermostat-humidity.yml | 46 + ...at-pump-thermostat-humidity-thermostat.yml | 44 + .../heat-pump-thermostat-humidity.yml | 31 + ...at-pump-thermostat-thermostat-humidity.yml | 44 + .../heat-pump-thermostat-thermostat.yml | 42 + .../profiles/heat-pump-thermostat.yml | 29 + .../matter-thermostat/profiles/heat-pump.yml | 16 + ...onditioner-fan-heating-cooling-nostate.yml | 24 + ...om-air-conditioner-fan-heating-cooling.yml | 33 + ...r-conditioner-fan-wind-heating-cooling.yml | 35 + .../room-air-conditioner-heating-cooling.yml | 29 + ...nditioner-humidity-fan-heating-cooling.yml | 35 + ...r-conditioner-humidity-heating-cooling.yml | 34 + .../profiles/room-air-conditioner-modular.yml | 38 + .../profiles/room-air-conditioner.yml | 38 + .../profiles/thermostat-batteryLevel.yml | 29 + .../thermostat-cooling-only-batteryLevel.yml | 26 + .../thermostat-cooling-only-nobattery.yml | 24 + ...stat-cooling-only-nostate-batteryLevel.yml | 18 + .../profiles/thermostat-cooling-only.yml | 6 + .../profiles/thermostat-fan-batteryLevel.yml | 31 + ...ermostat-fan-cooling-only-batteryLevel.yml | 28 + ...-fan-cooling-only-nostate-batteryLevel.yml | 20 + .../profiles/thermostat-fan-cooling-only.yml | 6 + ...ermostat-fan-heating-only-batteryLevel.yml | 28 + ...-fan-heating-only-nostate-batteryLevel.yml | 20 + .../profiles/thermostat-fan-heating-only.yml | 6 + .../thermostat-fan-nostate-batteryLevel.yml | 22 + .../profiles/thermostat-fan.yml | 7 + .../thermostat-heating-only-batteryLevel.yml | 26 + .../thermostat-heating-only-nobattery.yml | 24 + ...stat-heating-only-nostate-batteryLevel.yml | 18 + .../profiles/thermostat-heating-only.yml | 6 + .../thermostat-humidity-batteryLevel.yml | 31 + ...tat-humidity-cooling-only-batteryLevel.yml | 28 + ...dity-cooling-only-nostate-batteryLevel.yml | 20 + ...umidity-cooling-only-nostate-nobattery.yml | 1 + ...ermostat-humidity-cooling-only-nostate.yml | 1 + .../thermostat-humidity-cooling-only.yml | 7 + .../thermostat-humidity-fan-batteryLevel.yml | 33 + ...ity-fan-cooling-only-nostate-nobattery.yml | 1 + ...stat-humidity-fan-cooling-only-nostate.yml | 1 + .../thermostat-humidity-fan-cooling-only.yml | 7 + ...humidity-fan-heating-only-batteryLevel.yml | 30 + ...-fan-heating-only-nostate-batteryLevel.yml | 22 + ...ity-fan-heating-only-nostate-nobattery.yml | 1 + ...stat-humidity-fan-heating-only-nostate.yml | 1 + .../thermostat-humidity-fan-heating-only.yml | 7 + ...stat-humidity-fan-nostate-batteryLevel.yml | 24 + ...rmostat-humidity-fan-nostate-nobattery.yml | 1 + .../thermostat-humidity-fan-nostate.yml | 1 + .../profiles/thermostat-humidity-fan.yml | 8 + ...tat-humidity-heating-only-batteryLevel.yml | 28 + ...dity-heating-only-nostate-batteryLevel.yml | 20 + ...umidity-heating-only-nostate-nobattery.yml | 1 + ...ermostat-humidity-heating-only-nostate.yml | 1 + .../thermostat-humidity-heating-only.yml | 7 + ...ermostat-humidity-nostate-batteryLevel.yml | 22 + .../thermostat-humidity-nostate-nobattery.yml | 1 + .../profiles/thermostat-humidity-nostate.yml | 1 + .../profiles/thermostat-humidity.yml | 8 + .../profiles/thermostat-modular.yml | 47 + .../profiles/thermostat-nobattery.yml | 27 + .../thermostat-nostate-batteryLevel.yml | 20 + .../matter-thermostat/profiles/thermostat.yml | 7 + ...r-heater-power-energy-powerConsumption.yml | 113 + .../profiles/water-heater.yml | 92 + .../ActivatedCarbonFilterMonitoring/init.lua | 105 + .../server/attributes/ChangeIndication.lua | 71 + .../server/attributes/Condition.lua | 71 + .../server/attributes/init.lua | 27 + .../server/commands/ResetCondition.lua | 94 + .../server/commands/init.lua | 26 + .../types/ChangeIndicationEnum.lua | 36 + .../types/Feature.lua | 101 + .../types/init.lua | 18 + .../src/embedded_clusters/AirQuality/init.lua | 62 + .../server/attributes/AcceptedCommandList.lua | 78 + .../server/attributes/AirQuality.lua | 71 + .../server/attributes/AttributeList.lua | 78 + .../server/attributes/EventList.lua | 78 + .../AirQuality/server/attributes/init.lua | 27 + .../AirQuality/types/AirQualityEnum.lua | 48 + .../AirQuality/types/Feature.lua | 123 + .../AirQuality/types/init.lua | 18 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../ConcentrationMeasurement/init.lua | 111 + .../server/attributes/LevelValue.lua | 73 + .../server/attributes/MeasuredValue.lua | 73 + .../server/attributes/MeasurementUnit.lua | 72 + .../server/attributes/init.lua | 27 + .../types/Feature.lua | 167 + .../types/LevelValueEnum.lua | 42 + .../types/MeasurementUnitEnum.lua | 51 + .../ConcentrationMeasurement/types/init.lua | 18 + .../ElectricalEnergyMeasurement/init.lua | 56 + .../attributes/CumulativeEnergyImported.lua | 71 + .../attributes/PeriodicEnergyImported.lua | 71 + .../server/attributes/init.lua | 27 + .../types/EnergyMeasurementStruct.lua | 101 + .../types/Feature.lua | 31 + .../types/init.lua | 18 + .../ElectricalPowerMeasurement/init.lua | 44 + .../server/attributes/ActivePower.lua | 70 + .../server/attributes/init.lua | 27 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../HepaFilterMonitoring/init.lua | 105 + .../server/attributes/ChangeIndication.lua | 71 + .../server/attributes/Condition.lua | 71 + .../server/attributes/init.lua | 27 + .../server/commands/ResetCondition.lua | 94 + .../server/commands/init.lua | 26 + .../types/ChangeIndicationEnum.lua | 36 + .../HepaFilterMonitoring/types/Feature.lua | 101 + .../HepaFilterMonitoring/types/init.lua | 18 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../OzoneConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../Pm10ConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../Pm1ConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../Pm25ConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../RadonConcentrationMeasurement/init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../init.lua | 47 + .../server/attributes/LevelValue.lua | 44 + .../server/attributes/MeasuredValue.lua | 59 + .../server/attributes/MeasurementUnit.lua | 48 + .../server/attributes/init.lua | 27 + .../WaterHeaterMode/init.lua | 95 + .../server/attributes/CurrentMode.lua | 71 + .../server/attributes/SupportedModes.lua | 78 + .../server/attributes/init.lua | 27 + .../server/commands/ChangeToMode.lua | 90 + .../WaterHeaterMode/server/commands/init.lua | 26 + .../WaterHeaterMode/types/Feature.lua | 57 + .../types/ModeOptionStruct.lua | 88 + .../WaterHeaterMode/types/ModeTag.lua | 34 + .../WaterHeaterMode/types/ModeTagStruct.lua | 81 + .../WaterHeaterMode/types/init.lua | 18 + .../matter-thermostat/src/init.lua | 854 +++-- .../src/test/test_matter_air_purifier.lua | 921 +++++ .../test/test_matter_air_purifier_api9.lua | 923 +++++ .../test/test_matter_air_purifier_modular.lua | 401 ++ .../src/test/test_matter_fan.lua | 135 + .../src/test/test_matter_heat_pump.lua | 770 ++++ .../src/test/test_matter_room_ac.lua | 592 +++ .../src/test/test_matter_room_ac_modular.lua | 348 ++ .../src/test/test_matter_thermo_battery.lua | 151 + .../test/test_matter_thermo_featuremap.lua | 159 +- ...st_matter_thermo_multiple_device_types.lua | 293 ++ .../test_matter_thermo_setpoint_limits.lua | 292 +- ...test_matter_thermo_setpoint_limits_rpc.lua | 159 + .../src/test/test_matter_thermostat.lua | 392 +- ...est_matter_thermostat_composed_bridged.lua | 171 +- .../test/test_matter_thermostat_modular.lua | 333 ++ .../src/test/test_matter_thermostat_rpc5.lua | 144 + .../src/test/test_matter_water_heater.lua | 440 +++ .../test/test_unsupported_unit_conversion.lua | 63 + .../attribute_handlers.lua | 657 ++++ .../capability_handlers.lua | 256 ++ .../thermostat_utils/device_configuration.lua | 331 ++ .../embedded_cluster_utils.lua | 94 + .../src/thermostat_utils/fields.lua | 254 ++ .../legacy_device_configuration.lua | 259 ++ .../src/thermostat_utils/utils.lua | 244 ++ .../matter-window-covering/fingerprints.yml | 238 +- .../profiles/covering.yml | 56 + .../matter-window-covering/profiles/door.yml | 56 + .../profiles/garage-door.yml | 56 + .../matter-window-covering/profiles/gate.yml | 56 + .../profiles/window-covering-battery.yml | 4 +- .../profiles/window-covering-batteryLevel.yml | 23 + .../profiles/window-covering-profile.yml | 4 +- .../profiles/window-covering-tilt-battery.yml | 25 + .../window-covering-tilt-only-battery.yml | 19 + .../profiles/window-covering-tilt-only.yml | 17 + .../profiles/window-covering-tilt.yml | 23 + .../profiles/window-covering.yml | 4 +- .../embedded_clusters/ClosureControl/init.lua | 97 + .../server/attributes/AcceptedCommandList.lua | 74 + .../server/attributes/AttributeList.lua | 74 + .../server/attributes/CountdownTime.lua | 67 + .../server/attributes/CurrentErrorList.lua | 74 + .../server/attributes/LatchControlModes.lua | 67 + .../server/attributes/MainState.lua | 67 + .../server/attributes/OverallCurrentState.lua | 67 + .../server/attributes/OverallTargetState.lua | 67 + .../ClosureControl/server/attributes/init.lua | 19 + .../server/commands/Calibrate.lua | 91 + .../ClosureControl/server/commands/MoveTo.lua | 112 + .../ClosureControl/server/commands/Stop.lua | 90 + .../ClosureControl/server/commands/init.lua | 19 + .../ClosureControl/types/ClosureErrorEnum.lua | 36 + .../types/CurrentPositionEnum.lua | 39 + .../ClosureControl/types/Feature.lua | 228 ++ .../types/LatchControlModesBitmap.lua | 64 + .../ClosureControl/types/MainStateEnum.lua | 45 + .../types/OverallCurrentStateStruct.lua | 91 + .../types/OverallTargetStateStruct.lua | 84 + .../types/TargetPositionEnum.lua | 36 + .../ClosureControl/types/init.lua | 10 + .../ClosureDimension/init.lua | 106 + .../server/attributes/CurrentState.lua | 67 + .../server/attributes/LimitRange.lua | 67 + .../server/attributes/StepValue.lua | 67 + .../server/attributes/TargetState.lua | 67 + .../server/attributes/init.lua | 19 + .../server/commands/SetTarget.lua | 112 + .../ClosureDimension/server/commands/Step.lua | 112 + .../ClosureDimension/server/commands/init.lua | 19 + .../types/DimensionStateStruct.lua | 84 + .../ClosureDimension/types/Feature.lua | 207 + .../types/StepDirectionEnum.lua | 27 + .../types/TranslationDirectionEnum.lua | 57 + .../ClosureDimension/types/init.lua | 10 + .../src/embedded_clusters/Descriptor/init.lua | 55 + .../server/attributes/PartsList.lua | 78 + .../Descriptor/server/attributes/TagList.lua | 80 + .../Descriptor/server/attributes/init.lua | 27 + .../Descriptor/types/Feature.lua | 56 + .../Descriptor/types/init.lua | 9 + .../src/embedded_clusters/Global/init.lua | 8 + .../Global/types/SemanticTagStruct.lua | 94 + .../Global/types/ThreeLevelAutoEnum.lua | 38 + .../embedded_clusters/Global/types/init.lua | 18 + .../matter-window-covering/src/init.lua | 511 ++- .../src/lazy_load_subdriver.lua | 14 + .../src/sub_drivers.lua | 8 + .../src/sub_drivers/closure/can_handle.lua | 12 + .../closure_handlers/attribute_handlers.lua | 114 + .../closure_handlers/capability_handlers.lua | 71 + .../closure_utils/embedded_cluster_utils.lua | 62 + .../closure/closure_utils/fields.lua | 37 + .../closure/closure_utils/utils.lua | 319 ++ .../src/sub_drivers/closure/init.lua | 147 + .../src/test/test_matter_closure.lua | 790 ++++ .../src/test/test_matter_window_covering.lua | 1151 ++++-- .../philips-hue/profiles/4-button-remote.yml | 30 + .../philips-hue/profiles/contact-sensor.yml | 14 + .../philips-hue/profiles/legacy-color.yml | 9 +- .../philips-hue/profiles/motion-sensor.yml | 16 + .../SmartThings/philips-hue/profiles/plug.yml | 12 + .../philips-hue/profiles/single-button.yml | 12 + .../philips-hue/profiles/two-button.yml | 18 + .../philips-hue/profiles/white-ambiance.yml | 15 +- .../profiles/white-and-color-ambiance.yml | 15 +- .../philips-hue/profiles/white.yml | 9 +- drivers/SmartThings/philips-hue/run_specs.sh | 5 + .../SmartThings/philips-hue/src/consts.lua | 11 + drivers/SmartThings/philips-hue/src/disco.lua | 465 --- .../philips-hue/src/disco/button.lua | 173 + .../philips-hue/src/disco/contact.lua | 141 + .../philips-hue/src/disco/init.lua | 455 +++ .../philips-hue/src/disco/light.lua | 216 ++ .../philips-hue/src/disco/motion.lua | 152 + .../SmartThings/philips-hue/src/fields.lua | 18 +- .../SmartThings/philips-hue/src/handlers.lua | 533 --- .../src/handlers/attribute_emitters.lua | 391 ++ .../philips-hue/src/handlers/commands.lua | 278 ++ .../src/handlers/grouped_light_commands.lua | 271 ++ .../handlers/lifecycle_handlers/bridge.lua | 194 + .../handlers/lifecycle_handlers/button.lua | 191 + .../handlers/lifecycle_handlers/contact.lua | 135 + .../src/handlers/lifecycle_handlers/init.lua | 205 + .../src/handlers/lifecycle_handlers/light.lua | 244 ++ .../handlers/lifecycle_handlers/motion.lua | 138 + .../handlers/migration_handlers/bridge.lua | 100 + .../src/handlers/migration_handlers/light.lua | 162 + .../src/handlers/refresh_handlers.lua | 383 ++ .../SmartThings/philips-hue/src/hue/api.lua | 260 +- .../SmartThings/philips-hue/src/hue/types.lua | 54 - .../philips-hue/src/hue_debug/init.lua | 107 + .../philips-hue/src/hue_device_types.lua | 73 + .../philips-hue/src/hue_driver_template.lua | 398 ++ drivers/SmartThings/philips-hue/src/init.lua | 1586 +------- .../SmartThings/philips-hue/src/logjam.lua | 127 + .../philips-hue/src/lunchbox/rest.lua | 30 +- .../src/lunchbox/sse/eventsource.lua | 74 +- .../philips-hue/src/lunchbox/util.lua | 15 +- .../philips-hue/src/stray_device_helper.lua | 292 ++ .../src/test/spec/device_faker.lua | 50 + .../src/test/spec/fakers/bridge_faker.lua | 30 + .../src/test/spec/fakers/driver_faker.lua | 55 + .../src/test/spec/fakers/light_faker.lua | 46 + .../src/test/spec/test_helpers/init.lua | 133 + .../src/test/spec/unit/utils_spec.lua | 682 ++++ drivers/SmartThings/philips-hue/src/types.lua | 118 + .../src/utils/batched_command_utils.lua | 215 ++ .../src/{hue => utils}/cie_utils.lua | 34 + .../philips-hue/src/utils/grouped_utils.lua | 327 ++ .../src/utils/hue_bridge_utils.lua | 374 ++ .../hue_multi_service_device_utils/init.lua | 45 + .../hue_multi_service_device_utils/sensor.lua | 26 + .../philips-hue/src/utils/init.lua | 558 +++ .../philips-hue/src/utils/kv_counter.lua | 61 + .../samsung-audio/profiles/samsung-audio.yml | 2 - .../SmartThings/samsung-audio/src/command.lua | 33 +- .../samsung-audio/src/handlers.lua | 2 +- .../SmartThings/samsung-audio/src/init.lua | 22 +- drivers/SmartThings/sonos/.gitignore | 5 + .../capabilities/softwareGeneration.yaml | 17 + .../sonos/profiles/sonos-player.yml | 2 + .../sonos/src/api/cmd_handlers.lua | 59 +- .../sonos/src/api/event_handlers.lua | 69 +- drivers/SmartThings/sonos/src/api/init.lua | 51 +- drivers/SmartThings/sonos/src/api/rest.lua | 96 +- .../sonos/src/api/sonos_connection.lua | 511 ++- .../sonos/src/api/sonos_ssdp_discovery.lua | 474 +++ .../sonos/src/api/sonos_websocket_router.lua | 226 +- drivers/SmartThings/sonos/src/cosock/bus.lua | 227 ++ .../SmartThings/sonos/src/cosock/stream.lua | 92 + drivers/SmartThings/sonos/src/disco.lua | 73 +- drivers/SmartThings/sonos/src/fields.lua | 11 +- drivers/SmartThings/sonos/src/init.lua | 419 +- .../sonos/src/lifecycle_handlers.lua | 212 ++ .../SmartThings/sonos/src/lunchbox/rest.lua | 154 +- .../SmartThings/sonos/src/lunchbox/util.lua | 15 +- drivers/SmartThings/sonos/src/result.lua | 135 + .../SmartThings/sonos/src/sonos_driver.lua | 776 ++++ drivers/SmartThings/sonos/src/sonos_state.lua | 610 +++ drivers/SmartThings/sonos/src/ssdp.lua | 599 ++- .../SmartThings/sonos/src/token_refresher.lua | 152 + drivers/SmartThings/sonos/src/types.lua | 380 +- drivers/SmartThings/sonos/src/utils.lua | 286 +- .../profiles/virtual-dimmer-switch.yml | 3 + .../profiles/virtual-dimmer.yml | 3 + .../profiles/virtual-switch.yml | 3 + .../SmartThings/virtual-switch/src/init.lua | 20 +- .../src/test/test_virtual_switch.lua | 70 +- drivers/SmartThings/wemo/src/protocol.lua | 2 +- .../wemo/src/subscription_server.lua | 16 +- .../zigbee-air-quality-detector/config.yml | 6 + .../fingerprints.yml | 6 + .../profiles/air-quality-detector-MultiIR.yml | 38 + .../src/MultiIR/custom_clusters.lua | 72 + .../src/MultiIR/init.lua | 152 + .../zigbee-air-quality-detector/src/init.lua | 42 + .../test_MultiIR_air_quality_detector.lua | 490 +++ .../zigbee-bed/capabilities/aiMode.yaml | 57 + .../capabilities/autoInflation.yaml | 32 + .../zigbee-bed/capabilities/leftControl.yaml | 85 + .../capabilities/mattressHardness.yaml | 115 + .../zigbee-bed/capabilities/rightControl.yaml | 85 + .../capabilities/strongExpMode.yaml | 32 + .../zigbee-bed/capabilities/yoga.yaml | 50 + drivers/SmartThings/zigbee-bed/config.yml | 6 + .../SmartThings/zigbee-bed/fingerprints.yml | 6 + .../profiles/shus-smart-mattress.yml | 31 + drivers/SmartThings/zigbee-bed/src/init.lua | 20 + .../zigbee-bed/src/lazy_load_subdriver.lua | 15 + .../src/shus-mattress/can_handle.lua | 14 + .../src/shus-mattress/custom_capabilities.lua | 16 + .../src/shus-mattress/custom_clusters.lua | 129 + .../src/shus-mattress/fingerprints.lua | 8 + .../zigbee-bed/src/shus-mattress/init.lua | 214 ++ .../zigbee-bed/src/sub_drivers.lua | 8 + .../src/test/test_shus_mattress.lua | 1119 ++++++ .../zigbee-button/fingerprints.yml | 109 +- .../profiles/aqara-double-buttons-mode.yml | 35 + .../profiles/aqara-double-buttons.yml | 32 + .../profiles/aqara-knob-switch.yml | 19 + .../profiles/aqara-single-button-mode.yml | 17 + .../profiles/button-profile-frient.yml | 46 + .../profiles/button-profile-panic-frient.yml | 85 + .../zigbee-button/profiles/button-profile.yml | 2 +- .../profiles/eight-buttons-temp-battery.yml | 72 + .../four-buttons-without-main-button.yml | 32 + .../profiles/iris-one-button-battery.yml | 2 +- .../one-button-battery-no-fw-update.yml | 12 + .../profiles/one-button-battery.yml | 2 +- .../profiles/one-button-batteryLevel.yml | 14 + .../zigbee-button/profiles/one-button.yml | 3 +- .../profiles/two-buttons-no-fw-update.yml | 22 + .../zigbee-button/src/MultiIR/can_handle.lua | 13 + .../src/MultiIR/fingerprints.lua | 6 + .../zigbee-button/src/MultiIR/init.lua | 37 + .../src/aqara-knob/can_handle.lua | 13 + .../zigbee-button/src/aqara-knob/init.lua | 141 + .../zigbee-button/src/aqara/can_handle.lua | 12 + .../zigbee-button/src/aqara/fingerprints.lua | 13 + .../zigbee-button/src/aqara/init.lua | 233 +- .../zigbee-button/src/button_utils.lua | 21 +- .../src/dimming-remote/can_handle.lua | 14 + .../src/dimming-remote/fingerprints.lua | 9 + .../zigbee-button/src/dimming-remote/init.lua | 32 +- .../zigbee-button/src/ewelink/can_handle.lua | 14 + .../src/ewelink/fingerprints.lua | 9 + .../zigbee-button/src/ewelink/init.lua | 32 +- .../zigbee-button/src/ezviz/can_handle.lua | 18 + .../zigbee-button/src/ezviz/init.lua | 39 + .../zigbee-button/src/frient/can_handle.lua | 11 + .../zigbee-button/src/frient/init.lua | 209 +- .../SmartThings/zigbee-button/src/init.lua | 87 +- .../zigbee-button/src/iris/can_handle.lua | 14 + .../zigbee-button/src/iris/fingerprints.lua | 9 + .../zigbee-button/src/iris/init.lua | 32 +- .../zigbee-button/src/lazy_load_subdriver.lua | 15 + .../src/pushButton/can_handle.lua | 11 + .../zigbee-button/src/pushButton/init.lua | 33 +- .../zigbee-button/src/samjin/can_handle.lua | 11 + .../zigbee-button/src/samjin/init.lua | 21 +- .../zigbee-button/src/st/zigbee/zdo/init.lua | 144 + .../zigbee-button/src/sub_drivers.lua | 19 + .../src/test/test_SLED_button.lua | 34 +- .../src/test/test_aduro_button.lua | 34 +- .../src/test/test_aqara_button.lua | 296 +- .../src/test/test_aqara_knob_switch.lua | 287 ++ .../src/test/test_centralite_button.lua | 44 +- .../src/test/test_dimming_remote.lua | 96 +- .../src/test/test_ewelink_button.lua | 53 +- .../src/test/test_ezviz_button.lua | 206 + .../src/test/test_frient_button.lua | 341 +- .../src/test/test_heiman_button.lua | 59 +- .../src/test/test_ikea_on_off.lua | 87 +- .../src/test/test_ikea_open_close.lua | 86 +- .../src/test/test_ikea_remote_control.lua | 90 +- .../src/test/test_iris_button.lua | 85 +- .../test/test_linxura_aura_smart_button.lua | 123 + ...est_linxura_smart_controller_4x_button.lua | 123 + .../src/test/test_multiir_smart_button.lua | 131 + .../src/test/test_push_only_button.lua | 121 +- .../src/test/test_robb_4x_button.lua | 100 +- .../src/test/test_robb_8x_button.lua | 101 +- .../src/test/test_samjin_button.lua | 37 +- .../src/test/test_shinasystem_button.lua | 52 +- .../src/test/test_somfy_situo_1_button.lua | 44 +- .../src/test/test_somfy_situo_4_button.lua | 39 +- .../src/test/test_sonoff_button.lua | 279 ++ .../src/test/test_thirdreality_button.lua | 23 +- .../src/test/test_vimar_button.lua | 220 ++ .../src/test/test_wallhero_button.lua | 29 +- .../src/test/test_zigbee_button.lua | 216 +- .../src/test/test_zigbee_ecosmart_button.lua | 52 +- .../src/test/test_zunzunbee_8_button.lua | 207 + .../src/thirdreality/can_handle.lua | 11 + .../zigbee-button/src/thirdreality/init.lua | 7 +- .../zigbee-multi-button/SLED/can_handle.lua | 11 + .../src/zigbee-multi-button/SLED/init.lua | 20 +- .../adurosmart/can_handle.lua | 14 + .../adurosmart/fingerprints.lua | 11 + .../zigbee-multi-button/adurosmart/init.lua | 32 +- .../src/zigbee-multi-button/can_handle.lua | 14 + .../centralite/can_handle.lua | 14 + .../centralite/fingerprints.lua | 9 + .../zigbee-multi-button/centralite/init.lua | 30 +- .../ecosmart/can_handle.lua | 11 + .../src/zigbee-multi-button/ecosmart/init.lua | 20 +- .../src/zigbee-multi-button/fingerprints.lua | 43 + .../zigbee-multi-button/heiman/can_handle.lua | 14 + .../heiman/fingerprints.lua | 10 + .../src/zigbee-multi-button/heiman/init.lua | 37 +- .../ikea/TRADFRI_on_off_switch/can_handle.lua | 9 + .../init.lua} | 19 +- .../ikea/TRADFRI_open_close_remote.lua | 36 - .../TRADFRI_open_close_remote/can_handle.lua | 9 + .../ikea/TRADFRI_open_close_remote/init.lua | 23 + .../TRADFRI_remote_control/can_handle.lua | 9 + .../init.lua} | 21 +- .../zigbee-multi-button/ikea/can_handle.lua | 14 + .../zigbee-multi-button/ikea/fingerprints.lua | 10 + .../src/zigbee-multi-button/ikea/init.lua | 42 +- .../zigbee-multi-button/ikea/sub_drivers.lua | 12 + .../src/zigbee-multi-button/init.lua | 72 +- .../linxura/can_handle.lua | 14 + .../linxura/fingerprints.lua | 9 + .../src/zigbee-multi-button/linxura/init.lua | 68 + .../zigbee-multi-button/robb/can_handle.lua | 15 + .../zigbee-multi-button/robb/fingerprints.lua | 15 + .../src/zigbee-multi-button/robb/init.lua | 16 +- .../shinasystems/can_handle.lua | 14 + .../shinasystems/fingerprints.lua | 17 + .../zigbee-multi-button/shinasystems/init.lua | 40 +- .../zigbee-multi-button/somfy/can_handle.lua | 11 + .../src/zigbee-multi-button/somfy/init.lua | 25 +- .../somfy/somfy_situo_1/can_handle.lua | 9 + .../init.lua} | 19 +- .../somfy/somfy_situo_4/can_handle.lua | 9 + .../init.lua} | 19 +- .../zigbee-multi-button/somfy/sub_drivers.lua | 11 + .../zigbee-multi-button/sonoff/can_handle.lua | 16 + .../sonoff/fingerprints.lua | 8 + .../src/zigbee-multi-button/sonoff/init.lua | 63 + .../src/zigbee-multi-button/sub_drivers.lua | 21 + .../zigbee-multi-button/supported_values.lua | 38 +- .../zigbee-multi-button/vimar/can_handle.lua | 11 + .../src/zigbee-multi-button/vimar/init.lua | 81 + .../wallhero/can_handle.lua | 14 + .../wallhero/fingerprints.lua | 8 + .../src/zigbee-multi-button/wallhero/init.lua | 29 +- .../zunzunbee/can_handle.lua | 15 + .../zunzunbee/fingerprints.lua | 9 + .../zigbee-multi-button/zunzunbee/init.lua | 144 + .../fingerprints.yml | 10 + .../frient-smoke-co-temperature-battery.yml | 55 + .../src/ClimaxTechnology/can_handle.lua | 15 + .../src/ClimaxTechnology/fingerprints.lua | 9 + .../src/ClimaxTechnology/init.lua | 31 +- .../src/frient/can_handle.lua | 15 + .../src/frient/fingerprints.lua | 9 + .../src/frient/init.lua | 236 ++ .../src/init.lua | 28 +- .../src/lazy_load_subdriver.lua | 15 + .../src/sub_drivers.lua | 11 + ...test_climax_technology_carbon_monoxide.lua | 22 +- ...st_frient_co_smoke_temperature_battery.lua | 506 +++ .../src/test/test_zigbee_carbon_monoxide.lua | 80 +- .../zigbee-contact/fingerprints.yml | 40 +- ...cceleration-motion-temperature-battery.yml | 50 + ...ion-motion-temperature-contact-battery.yml | 89 + .../contact-battery-tamper-no-fw-update.yml | 14 + .../contact-battery-temperature-tamper.yml | 30 + .../profiles/contact-batteryLevel.yml | 14 + .../frient-contact-battery-temperature.yml | 28 + .../zigbee-contact/src/MultiIR/can_handle.lua | 13 + .../src/MultiIR/fingerprints.lua | 6 + .../zigbee-contact/src/MultiIR/init.lua | 52 + .../zigbee-contact/src/aqara/can_handle.lua | 14 + .../zigbee-contact/src/aqara/fingerprints.lua | 8 + .../zigbee-contact/src/aqara/init.lua | 112 +- .../src/aurora-contact-sensor/can_handle.lua | 14 + .../aurora-contact-sensor/fingerprints.lua | 9 + .../src/aurora-contact-sensor/init.lua | 31 +- .../zigbee-contact/src/configurations.lua | 49 +- .../contact-temperature-sensor/can_handle.lua | 14 + .../ecolink-contact/can_handle.lua | 14 + .../ecolink-contact/fingerprints.lua | 9 + .../ecolink-contact/init.lua | 30 +- .../fingerprints.lua | 24 + .../frient-sensor/init.lua | 63 - .../src/contact-temperature-sensor/init.lua | 54 +- .../sub_drivers.lua | 8 + .../zigbee-contact/src/frient/can_handle.lua | 15 + .../frient/frient-vibration/can_handle.lua | 15 + .../src/frient/frient-vibration/init.lua | 212 ++ .../zigbee-contact/src/frient/init.lua | 89 + .../zigbee-contact/src/frient/sub_drivers.lua | 8 + .../SmartThings/zigbee-contact/src/init.lua | 90 +- .../src/lazy_load_subdriver.lua | 15 + .../src/multi-sensor/can_handle.lua | 14 + .../centralite-multi/can_handle.lua | 11 + .../multi-sensor/centralite-multi/init.lua | 20 +- .../src/multi-sensor/fingerprints.lua | 13 + .../zigbee-contact/src/multi-sensor/init.lua | 42 +- .../src/multi-sensor/multi_utils.lua | 17 +- .../multi-sensor/samjin-multi/can_handle.lua | 11 + .../src/multi-sensor/samjin-multi/init.lua | 20 +- .../smartthings-multi/can_handle.lua | 11 + .../multi-sensor/smartthings-multi/init.lua | 20 +- .../src/multi-sensor/sub_drivers.lua | 11 + .../thirdreality-multi/can_handle.lua | 11 + .../multi-sensor/thirdreality-multi/init.lua | 20 +- .../zigbee-contact/src/sengled/can_handle.lua | 14 + .../src/sengled/fingerprints.lua | 8 + .../zigbee-contact/src/sengled/init.lua | 17 +- .../src/smartsense-multi/can_handle.lua | 17 + .../src/smartsense-multi/fingerprints.lua | 9 + .../src/smartsense-multi/init.lua | 33 +- .../zigbee-contact/src/sub_drivers.lua | 15 + .../src/test/test_aqara_contact_sensor.lua | 212 +- .../src/test/test_aurora_contact_sensor.lua | 34 +- .../src/test/test_centralite_multi_sensor.lua | 55 +- .../test/test_contact_temperature_sensor.lua | 39 +- .../src/test/test_ecolink_contact.lua | 39 +- .../src/test/test_ewelink_heiman_sensor.lua | 34 +- .../src/test/test_frient_contact_sensor.lua | 290 ++ .../test/test_frient_contact_sensor_2_pro.lua | 433 +++ .../test/test_frient_contact_sensor_pro.lua | 453 +++ .../src/test/test_frient_sensor.lua | 150 - .../src/test/test_frient_vibration_sensor.lua | 541 +++ .../src/test/test_multiir_contact_tamper.lua | 147 + .../src/test/test_orvibo_contact_sensor.lua | 34 +- .../src/test/test_samjin_multi_sensor.lua | 35 +- .../src/test/test_sengled_contact_sensor.lua | 34 +- .../src/test/test_smartsense_multi.lua | 206 +- .../test/test_smartthings_multi_sensor.lua | 104 +- .../src/test/test_third_reality_contact.lua | 34 +- .../test/test_thirdreality_multi_sensor.lua | 18 +- .../src/test/test_zigbee_contact.lua | 175 +- .../src/test/test_zigbee_contact_battery.lua | 30 +- .../src/test/test_zigbee_contact_tyco.lua | 43 +- .../zigbee-dimmer-remote/src/init.lua | 37 +- .../src/lazy_load_subdriver.lua | 15 + .../zigbee-dimmer-remote/src/sub_drivers.lua | 11 + .../src/test/test_zigbee_accessory_dimmer.lua | 148 +- .../test_zigbee_battery_accessory_dimmer.lua | Bin 20954 -> 29143 bytes .../zigbee-accessory-dimmer/can_handle.lua | 14 + .../zigbee-accessory-dimmer/fingerprints.lua | 9 + .../src/zigbee-accessory-dimmer/init.lua | 43 +- .../CentraliteSystems/can_handle.lua | 15 + .../CentraliteSystems/fingerprints.lua | 8 + .../CentraliteSystems/init.lua | 45 +- .../IKEAofSweden/can_handle.lua | 15 + .../IKEAofSweden/fingerprints.lua | 8 + .../IKEAofSweden/init.lua | 30 +- .../can_handle.lua | 15 + .../fingerprints.lua | 10 + .../zigbee-battery-accessory-dimmer/init.lua | 42 +- .../sengled/can_handle.lua | 15 + .../sengled/fingerprints.lua | 8 + .../sengled/init.lua | 30 +- .../sub_drivers.lua | 12 + .../zigbee-fan/src/configurations.lua | 15 +- .../zigbee-fan/src/fan-light/can_handle.lua | 14 + .../zigbee-fan/src/fan-light/fingerprints.lua | 8 + .../zigbee-fan/src/fan-light/init.lua | 37 +- drivers/SmartThings/zigbee-fan/src/init.lua | 28 +- .../zigbee-fan/src/lazy_load_subdriver.lua | 15 + .../zigbee-fan/src/sub_drivers.lua | 8 + .../zigbee-fan/src/test/test_fan_light.lua | 125 +- .../zigbee-humidity-sensor/fingerprints.yml | 57 +- ...irquality-humidity-temperature-battery.yml | 54 + .../frient-humidity-temperature-battery.yml | 39 + .../profiles/humidity-temp-battery-aqara.yml | 49 +- .../profiles/humidity-temp-battery.yml | 44 +- .../profiles/humidity-temperature.yml | 38 +- .../src/aqara/can_handle.lua | 14 + .../src/aqara/fingerprints.lua | 8 + .../zigbee-humidity-sensor/src/aqara/init.lua | 42 +- .../src/centralite-sensor/can_handle.lua | 14 + .../src/centralite-sensor/fingerprints.lua | 10 + .../src/centralite-sensor/init.lua | 31 +- .../src/configurations.lua | 27 +- .../frient-sensor/air-quality/can_handle.lua | 14 + .../air-quality/fingerprints.lua | 8 + .../src/frient-sensor/air-quality/init.lua | 147 + .../src/frient-sensor/can_handle.lua | 14 + .../src/frient-sensor/fingerprints.lua | 10 + .../src/frient-sensor/init.lua | 67 +- .../src/frient-sensor/sub_drivers.lua | 9 + .../src/heiman-sensor/can_handle.lua | 14 + .../src/heiman-sensor/fingerprints.lua | 11 + .../src/heiman-sensor/init.lua | 32 +- .../zigbee-humidity-sensor/src/init.lua | 75 +- .../src/lazy_load_subdriver.lua | 15 + .../src/plaid-systems/can_handle.lua | 15 + .../src/plaid-systems/fingerprints.lua | 10 + .../src/plaid-systems/init.lua | 32 +- .../src/plant-link/can_handle.lua | 15 + .../src/plant-link/fingerprints.lua | 12 + .../src/plant-link/init.lua | 32 +- .../src/sub_drivers.lua | 13 + .../src/test/test_aqara_sensor.lua | 129 +- .../src/test/test_centralite_sensor.lua | 26 +- .../src/test/test_ewelink_sensor.lua | 23 +- .../test/test_frient_air_quality_sensor.lua | 352 ++ .../src/test/test_frient_sensor.lua | 203 +- .../src/test/test_heiman_sensor.lua | 23 +- .../src/test/test_humidity_battery_sensor.lua | 33 +- .../src/test/test_humidity_plaid_systems.lua | 57 +- .../src/test/test_humidity_temperature.lua | 70 +- .../test_humidity_temperature_battery.lua | 198 + .../test/test_humidity_temperature_sensor.lua | 64 +- .../src/aqara/can_handle.lua | 14 + .../src/aqara/fingerprints.lua | 8 + .../src/aqara/init.lua | 27 +- .../zigbee-illuminance-sensor/src/init.lua | 30 +- .../src/lazy_load_subdriver.lua | 15 + .../src/sub_drivers.lua | 8 + .../src/test/test_illuminance_sensor.lua | 32 +- .../test/test_illuminance_sensor_aqara.lua | 104 +- .../SmartThings/zigbee-lock/fingerprints.yml | 108 + .../zigbee-lock/profiles/base-lock.yml | 4 + .../src/bad-battery-reporter/can_handle.lua | 13 + .../src/bad-battery-reporter/fingerprints.lua | 13 + .../src/bad-battery-reporter/init.lua | 26 + .../zigbee-lock/src/configurations.lua | 15 +- drivers/SmartThings/zigbee-lock/src/init.lua | 521 +-- .../zigbee-lock/src/lazy_load_subdriver.lua | 15 + .../src/legacy-handlers/can_handle.lua | 12 + .../zigbee-lock/src/legacy-handlers/init.lua | 495 +++ .../src/legacy-handlers/legacy_lock_utils.lua | 89 + .../lock-without-codes/can_handle.lua | 13 + .../lock-without-codes/fingerprints.lua | 9 + .../lock-without-codes/init.lua | 31 +- .../src/legacy-handlers/sub_drivers.lua | 10 + .../yale-fingerprint-lock/can_handle.lua | 13 + .../yale-fingerprint-lock/init.lua | 27 + .../src/legacy-handlers/yale/can_handle.lua | 11 + .../src/{ => legacy-handlers}/yale/init.lua | 28 +- .../src/lock_handlers/capabilities.lua | 248 ++ .../src/lock_handlers/zigbee_responses.lua | 438 +++ .../zigbee-lock/src/lock_utils.lua | 98 - .../zigbee-lock/src/lock_utils/constants.lua | 47 + .../zigbee-lock/src/lock_utils/tables.lua | 268 ++ .../zigbee-lock/src/lock_utils/utils.lua | 66 + .../zigbee-lock/src/samsungsds/can_handle.lua | 11 + .../zigbee-lock/src/samsungsds/init.lua | 116 +- .../zigbee-lock/src/sub_drivers.lua | 11 + ...rter.lua => test_bad_battery_reporter.lua} | 60 +- .../zigbee-lock/src/test/test_c2o_lock.lua | 55 +- ...st_generic_fingerprint_profile_update.lua} | 39 +- .../src/test/test_init_lifecycle_handlers.lua | 484 +++ .../test/test_lock_code_slga_migration.lua | 76 + .../test/test_lock_credentials_commands.lua | 578 +++ .../src/test/test_lock_pre_configured.lua | 653 ++++ .../src/test/test_lock_programming_events.lua | 574 +++ .../zigbee-lock/src/test/test_lock_tables.lua | 728 ++++ .../src/test/test_lock_users_commands.lua | 654 ++++ .../test/test_zigbee_lock_code_migration.lua | 246 -- .../src/test/test_zigbee_lock_legacy.lua | 946 +++++ ...gbee_lock.lua => test_zigbee_lock_v10.lua} | 121 +- .../src/test/test_zigbee_responses.lua | 566 +++ .../src/test/test_zigbee_samsungsds.lua | 221 +- .../test_zigbee_yale-bad-battery-reporter.lua | 51 - .../test_zigbee_yale-fingerprint-lock.lua | 51 - .../test_zigbee_yale_fingerprint-lock.lua | 78 + ...e_yale.lua => test_zigbee_yale_legacy.lua} | 205 +- .../src/yale-fingerprint-lock/can_handle.lua | 17 + .../yale-fingerprint-lock/fingerprints.lua | 11 + .../src/yale-fingerprint-lock/init.lua | 38 +- .../yale/yale-bad-battery-reporter/init.lua | 52 - .../zigbee-motion-sensor/fingerprints.yml | 21 +- .../profiles/frient-motion-battery.yml | 42 + ...frient-motion-temp-illuminance-battery.yml | 57 + ...motion-temp-illuminance-tamper-battery.yml | 59 + ...nce-sensitivity-frequency-no-fw-update.yml | 26 + .../src/MultiIR/can_handle.lua | 13 + .../src/MultiIR/fingerprints.lua | 6 + .../zigbee-motion-sensor/src/MultiIR/init.lua | 117 + .../src/aqara/aqara_utils.lua | 3 + .../src/aqara/can_handle.lua | 14 + .../src/aqara/fingerprints.lua | 10 + .../high-precision-motion/can_handle.lua | 9 + .../src/aqara/high-precision-motion/init.lua | 7 +- .../zigbee-motion-sensor/src/aqara/init.lua | 23 +- .../src/aqara/sub_drivers.lua | 8 + .../src/aurora/can_handle.lua | 11 + .../zigbee-motion-sensor/src/aurora/init.lua | 20 +- .../src/battery-voltage/can_handle.lua | 14 + .../src/battery-voltage/fingerprints.lua | 12 + .../src/battery-voltage/init.lua | 35 +- .../src/centralite/can_handle.lua | 11 + .../src/centralite/init.lua | 20 +- .../src/compacta/can_handle.lua | 11 + .../src/compacta/init.lua | 20 +- .../src/frient/can_handle.lua | 14 + .../src/frient/fingerprints.lua | 10 + .../zigbee-motion-sensor/src/frient/init.lua | 228 +- .../src/gatorsystem/can_handle.lua | 11 + .../src/gatorsystem/init.lua | 20 +- .../src/ikea/can_handle.lua | 14 + .../src/ikea/fingerprints.lua | 8 + .../zigbee-motion-sensor/src/ikea/init.lua | 29 +- .../zigbee-motion-sensor/src/init.lua | 119 +- .../src/iris/can_handle.lua | 14 + .../src/iris/fingerprints.lua | 8 + .../zigbee-motion-sensor/src/iris/init.lua | 29 +- .../src/lazy_load_subdriver.lua | 15 + .../src/motion_timeout/can_handle.lua | 14 + .../src/motion_timeout/fingerprints.lua | 10 + .../src/motion_timeout/init.lua | 34 +- .../src/nyce/can_handle.lua | 14 + .../src/nyce/fingerprints.lua | 10 + .../zigbee-motion-sensor/src/nyce/init.lua | 31 +- .../src/samjin/can_handle.lua | 11 + .../zigbee-motion-sensor/src/samjin/init.lua | 20 +- .../src/sengled/can_handle.lua | 14 + .../src/sengled/fingerprints.lua | 8 + .../zigbee-motion-sensor/src/sengled/init.lua | 17 +- .../src/smartsense/can_handle.lua | 18 + .../src/smartsense/init.lua | 28 +- .../src/smartthings/can_handle.lua | 11 + .../src/smartthings/init.lua | 20 +- .../src/st/zigbee/zdo/init.lua | 145 + .../test_all_capabilities_zigbee_motion.lua | 198 +- .../src/test/test_aqara_high_precision.lua | 65 +- .../test/test_aqara_motion_illuminance.lua | 128 +- .../src/test/test_aurora_motion.lua | 80 +- .../src/test/test_battery_voltage_motion.lua | 26 +- .../src/test/test_centralite_motion.lua | 30 +- .../src/test/test_compacta_motion.lua | 30 +- .../src/test/test_frient_motion_sensor.lua | 291 ++ .../test/test_frient_motion_sensor2_pet.lua | 409 ++ .../test/test_frient_motion_sensor_pro.lua | 457 +++ .../src/test/test_frient_sensor.lua | 175 - .../src/test/test_gator_motion.lua | 79 +- .../src/test/test_ikea_motion.lua | 76 +- .../src/test/test_multiir_motion_pir.lua | 224 ++ .../src/test/test_samjin_sensor.lua | 30 +- .../src/test/test_sengled_motion.lua | 35 +- .../test/test_smartsense_motion_sensor.lua | 58 +- .../src/test/test_smartthings_motion.lua | 25 +- .../src/test/test_thirdreality_sensor.lua | 62 +- .../src/test/test_zigbee_motion_iris.lua | 44 +- .../src/test/test_zigbee_motion_nyce.lua | 32 +- .../src/test/test_zigbee_motion_orvibo.lua | 42 +- .../test/test_zigbee_plugin_motion_sensor.lua | 51 +- .../src/thirdreality/can_handle.lua | 14 + .../src/thirdreality/fingerprints.lua | 9 + .../src/thirdreality/init.lua | 30 +- .../can_handle.lua | 14 + .../fingerprints.lua | 8 + .../src/zigbee-plugin-motion-sensor/init.lua | 29 +- .../zigbee-power-meter/fingerprints.yml | 84 +- ...ower-energy-battery-consumption-report.yml | 37 + ...frient-power-energy-consumption-report.yml | 35 + .../frient-power-energy-current-voltage.yml | 48 + .../frient-power-meter-consumption-report.yml | 16 + .../profiles/power-meter-1p.yml | 31 + .../profiles/power-meter-2p.yml | 42 + .../profiles/power-meter-3p.yml | 53 + .../src/bituo/can_handle.lua | 15 + .../src/bituo/fingerprints.lua | 20 + .../zigbee-power-meter/src/bituo/init.lua | 231 ++ .../zigbee-power-meter/src/configurations.lua | 148 + .../src/ezex/can_handle.lua | 15 + .../src/ezex/fingerprints.lua | 8 + .../zigbee-power-meter/src/ezex/init.lua | 58 +- .../src/frient/EMIZB-151/can_handle.lua | 15 + .../src/frient/EMIZB-151/fingerprints.lua | 8 + .../src/frient/EMIZB-151/init.lua | 258 ++ .../src/frient/can_handle.lua | 15 + .../src/frient/fingerprints.lua | 11 + .../zigbee-power-meter/src/frient/init.lua | 192 +- .../src/frient/sub_drivers.lua | 9 + .../zigbee-power-meter/src/frient/utils.lua | 10 + .../zigbee-power-meter/src/init.lua | 45 +- .../src/lazy_load_subdriver.lua | 15 + .../src/shinasystems/can_handle.lua | 15 + .../src/shinasystems/fingerprints.lua | 10 + .../src/shinasystems/init.lua | 46 +- .../zigbee-power-meter/src/sub_drivers.lua | 11 + ...ower_energy_battery_consumption_report.lua | 299 ++ ...frient_power_energy_consumption_report.lua | 272 ++ ...st_frient_power_energy_current_voltage.lua | 361 ++ .../src/test/test_zigbee_power_meter.lua | 166 +- .../src/test/test_zigbee_power_meter_1p.lua | 355 ++ .../test/test_zigbee_power_meter_1p_ZM.lua | 211 ++ .../src/test/test_zigbee_power_meter_2p.lua | 309 ++ .../test/test_zigbee_power_meter_2p_ZM.lua | 284 ++ .../src/test/test_zigbee_power_meter_3p.lua | 392 ++ .../test/test_zigbee_power_meter_3p_ZM.lua | 358 ++ ...e_power_meter_consumption_report_sihas.lua | 143 +- ...t.lua => test_zigbee_power_meter_ezex.lua} | 113 +- .../test/test_zigbee_power_meter_frient.lua | 200 + .../src/aqara/can_handle.lua | 13 + .../zigbee-presence-sensor/src/aqara/init.lua | 16 +- .../src/arrival-sensor-v1/can_handle.lua | 12 + .../src/arrival-sensor-v1/init.lua | 30 +- .../zigbee-presence-sensor/src/init.lua | 32 +- .../src/lazy_load_subdriver.lua | 15 + .../src/presence_utils.lua | 15 +- .../src/sub_drivers.lua | 9 + .../test/test_aqara_presence_sensor_fp1.lua | 59 +- .../src/test/test_st_arrival_sensor_v1.lua | 64 +- .../src/test/test_zigbee_presence_sensor.lua | 172 +- .../zigbee-range-extender/fingerprints.yml | 5 + .../range-extender-battery-source.yml | 20 + .../src/frient/can_handle.lua | 11 + .../zigbee-range-extender/src/frient/init.lua | 66 + .../zigbee-range-extender/src/init.lua | 37 +- .../src/lazy_load_subdriver.lua | 15 + .../zigbee-range-extender/src/sub_drivers.lua | 8 + .../test_frient_zigbee_range_extender.lua | 236 ++ .../src/test/test_zigbee_extend.lua | 49 +- drivers/SmartThings/zigbee-sensor/config.yml | 6 + .../zigbee-sensor/fingerprints.yml | 16 + .../profiles/generic-contact-sensor.yml | 14 + .../profiles/generic-motion-illuminance.yml | 20 + .../profiles/generic-motion-sensor.yml | 14 + .../zigbee-sensor/profiles/generic-sensor.yml | 10 + .../profiles/generic-waterleak-sensor.yml | 14 + .../zigbee-sensor/src/contact/init.lua | 32 + .../SmartThings/zigbee-sensor/src/init.lua | 111 + .../src/motion-illuminance/init.lua | 33 + .../zigbee-sensor/src/motion/init.lua | 32 + .../src/test/test_zigbee_sensor.lua | 1015 +++++ .../zigbee-sensor/src/waterleak/init.lua | 32 + .../SmartThings/zigbee-siren/fingerprints.yml | 25 +- .../frient-siren-battery-source-tamper.yml | 53 + .../profiles/frient-siren-battery-source.yml | 51 + ...er-warningduration-volume-no-fw-update.yml | 36 + .../zigbee-siren/src/MultiIR/can_handle.lua | 13 + .../zigbee-siren/src/MultiIR/fingerprints.lua | 6 + .../zigbee-siren/src/MultiIR/init.lua | 119 + .../zigbee-siren/src/frient/can_handle.lua | 15 + .../zigbee-siren/src/frient/init.lua | 423 ++- drivers/SmartThings/zigbee-siren/src/init.lua | 45 +- .../zigbee-siren/src/lazy_load_subdriver.lua | 15 + .../zigbee-siren/src/ozom/can_handle.lua | 11 + .../zigbee-siren/src/ozom/init.lua | 20 +- .../zigbee-siren/src/sub_drivers.lua | 10 + .../src/test/test_frient_siren.lua | 1228 +++++- .../src/test/test_frient_siren_tamper.lua | 1142 ++++++ .../test/test_multiir_zigbee_siren_tamper.lua | 312 ++ .../zigbee-siren/src/test/test_ozom_siren.lua | 17 +- .../src/test/test_zigbee_siren.lua | 128 +- .../zigbee-smoke-detector/fingerprints.yml | 17 +- .../profiles/heat-temp-battery-alarm.yml | 55 + .../smoke-battery-tamper-no-fw-update.yml | 14 + .../profiles/smoke-temp-battery-alarm.yml | 50 + .../src/MultiIR/can_handle.lua | 13 + .../src/MultiIR/fingerprints.lua | 6 + .../src/MultiIR/init.lua | 53 + .../src/aqara-gas/can_handle.lua | 14 + .../src/aqara-gas/fingerprints.lua | 8 + .../src/aqara-gas/init.lua | 31 +- .../src/aqara/can_handle.lua | 14 + .../src/aqara/fingerprints.lua | 8 + .../zigbee-smoke-detector/src/aqara/init.lua | 31 +- .../src/frient/can_handle.lua | 11 + .../zigbee-smoke-detector/src/frient/init.lua | 279 +- .../zigbee-smoke-detector/src/init.lua | 33 +- .../src/lazy_load_subdriver.lua | 15 + .../src/shinasystem/can_handle.lua | 11 + .../src/shinasystem/init.lua | 34 + .../zigbee-smoke-detector/src/sub_drivers.lua | 12 + .../src/test/test_aqara_gas_detector.lua | 154 +- .../src/test/test_aqara_smoke_detector.lua | 124 +- .../src/test/test_frient_heat_detector.lua | 650 ++++ .../src/test/test_frient_smoke_detector.lua | 860 ++++- .../src/test/test_multiir_smoke_detector.lua | 195 + .../src/test/test_zigbee_smoke_detector.lua | 136 +- .../zigbee-sound-sensor/src/init.lua | 41 +- .../src/test/test_zigbee_sound_sensor.lua | 91 +- .../zigbee-switch/fingerprints.yml | 385 +- .../profiles/abl-light-z-001-bulb.yml | 4 + .../zigbee-switch/profiles/aqara-led-bulb.yml | 4 + .../zigbee-switch/profiles/aqara-light.yml | 8 +- .../profiles/aqara-switch-module-no-power.yml | 17 + .../profiles/aqara-switch-no-power.yml | 19 + .../zigbee-switch/profiles/color-bulb.yml | 2 + .../profiles/color-temp-bulb-2000K-6500K.yml | 28 + .../profiles/color-temp-bulb-2200K-4000K.yml | 4 + .../profiles/color-temp-bulb-2200K-5000K.yml | 4 + .../profiles/color-temp-bulb-2200K-6500K.yml | 4 + .../profiles/color-temp-bulb-2500K-6000K.yml | 4 + .../profiles/color-temp-bulb-2700K-5000K.yml | 4 + .../profiles/color-temp-bulb-2700K-6500K.yml | 4 + .../profiles/color-temp-bulb.yml | 4 + .../profiles/frient-io-output-switch.yml | 25 + .../frient-switch-power-energy-voltage.yml | 29 + .../zigbee-switch/profiles/ge-link-bulb.yml | 2 + .../profiles/inovelli-vzm30-sn.yml | 233 ++ .../profiles/inovelli-vzm31-sn.yml | 271 ++ .../profiles/inovelli-vzm32-sn.yml | 357 ++ .../light-color-temp-time-restore.yml | 74 + .../profiles/light-level-power-energy.yml | 2 + .../profiles/light-level-power.yml | 2 + .../profiles/on-off-level-intensity.yml | 2 + .../profiles/on-off-level-motion-sensor.yml | 2 + .../on-off-level-no-firmware-update.yml | 2 + .../zigbee-switch/profiles/on-off-level.yml | 2 + .../profiles/plug-level-power.yml | 2 + .../profiles/rgbw-bulb-1800K-6500K.yml | 4 + .../profiles/rgbw-bulb-2000K-6500K.yml | 4 + .../profiles/rgbw-bulb-2200K-4000K.yml | 4 + .../profiles/rgbw-bulb-2200K-5000K.yml | 4 + .../profiles/rgbw-bulb-2200K-6500K.yml | 4 + .../profiles/rgbw-bulb-2500K-6000K.yml | 4 + .../profiles/rgbw-bulb-2700K-5000K.yml | 4 + .../profiles/rgbw-bulb-2700K-6500K.yml | 26 + .../zigbee-switch/profiles/rgbw-bulb.yml | 4 + .../profiles/switch-4inputs-2outputs.yml | 107 + .../switch-button-light-restore-wireless.yml | 39 + .../profiles/switch-button-wireless.yml | 17 + .../profiles/switch-dimmer-power-energy.yml | 2 + .../profiles/switch-level-power.yml | 2 + .../zigbee-switch/profiles/switch-level.yml | 2 + .../switch-light-restore-wireless.yml | 37 + .../switch-smart-bath-heater-laisiao.yml | 62 + .../profiles/switch-wireless.yml | 15 + .../src/aqara-light/can_handle.lua | 18 + .../zigbee-switch/src/aqara-light/init.lua | 44 +- .../zigbee-switch/src/aqara/can_handle.lua | 13 + .../zigbee-switch/src/aqara/fingerprints.lua | 22 + .../zigbee-switch/src/aqara/init.lua | 67 +- .../src/aqara/multi-switch/can_handle.lua | 12 + .../src/aqara/multi-switch/fingerprints.lua | 14 + .../src/aqara/multi-switch/init.lua | 28 +- .../zigbee-switch/src/aqara/sub_drivers.lua | 9 + .../src/aqara/version/can_handle.lua | 13 + .../zigbee-switch/src/aqara/version/init.lua | 27 +- .../src/bad_on_off_data_type/can_handle.lua | 24 + .../src/bad_on_off_data_type/init.lua | 39 +- .../color_temp_range_handlers/can_handle.lua | 12 + .../src/color_temp_range_handlers/init.lua | 75 + .../zigbee-switch/src/configurations.lua | 87 - .../src/configurations/devices.lua | 176 + .../zigbee-switch/src/configurations/init.lua | 136 + .../zigbee-switch/src/ezex/can_handle.lua | 17 + .../zigbee-switch/src/ezex/init.lua | 64 +- .../src/firstled-io/can_handle.lua | 12 + .../src/firstled-io/fingerprints.lua | 26 + .../zigbee-switch/src/firstled-io/init.lua | 209 + .../src/firstled-light/can_handle.lua | 13 + .../src/firstled-light/fingerprints.lua | 6 + .../zigbee-switch/src/firstled-light/init.lua | 63 + .../src/frient-IO/can_handle.lua | 12 + .../zigbee-switch/src/frient-IO/init.lua | 488 +++ .../src/frient-IO/unbind_request.lua | 84 + .../zigbee-switch/src/frient/can_handle.lua | 14 + .../zigbee-switch/src/frient/fingerprints.lua | 17 + .../zigbee-switch/src/frient/init.lua | 162 + .../src/ge-link-bulb/can_handle.lua | 22 + .../zigbee-switch/src/ge-link-bulb/init.lua | 37 +- .../zigbee-switch/src/hanssem/can_handle.lua | 13 + .../src/hanssem/fingerprints.lua | 11 + .../zigbee-switch/src/hanssem/init.lua | 42 +- .../src/ikea-xy-color-bulb/can_handle.lua | 17 + .../ikea-xy-color-bulb/init.lua | 32 +- .../SmartThings/zigbee-switch/src/init.lua | 122 +- .../zigbee-switch/src/inovelli/can_handle.lua | 18 + .../zigbee-switch/src/inovelli/common.lua | 73 + .../zigbee-switch/src/inovelli/init.lua | 426 +++ .../src/inovelli/sub_drivers.lua | 9 + .../src/inovelli/vzm30-sn/can_handle.lua | 16 + .../src/inovelli/vzm30-sn/init.lua | 53 + .../src/inovelli/vzm32-sn/can_handle.lua | 16 + .../src/inovelli/vzm32-sn/init.lua | 68 + .../zigbee-switch/src/jasco/can_handle.lua | 18 + .../zigbee-switch/src/jasco/init.lua | 33 +- .../zigbee-switch/src/laisiao/can_handle.lua | 17 + .../zigbee-switch/src/laisiao/init.lua | 59 + .../zigbee-switch/src/lazy_load_subdriver.lua | 15 + .../src/lifecycle_handlers/device_added.lua | 50 + .../src/lifecycle_handlers/do_configure.lua | 27 + .../src/lifecycle_handlers/find_child.lua | 6 + .../src/lifecycle_handlers/info_changed.lua | 7 + .../src/multi-switch-no-master/can_handle.lua | 13 + .../multi-switch-no-master/fingerprints.lua | 47 + .../src/multi-switch-no-master/init.lua | 72 +- .../src/non_zigbee_devices/can_handle.lua | 11 + .../src/non_zigbee_devices/init.lua | 39 + .../zigbee-switch/src/preferences.lua | 47 +- .../zigbee-switch/src/rexense/can_handle.lua | 16 + .../zigbee-switch/src/rexense/init.lua | 31 +- .../zigbee-switch/src/rgb-bulb/can_handle.lua | 23 + .../zigbee-switch/src/rgb-bulb/init.lua | 36 +- .../src/rgbw-bulb/can_handle.lua | 13 + .../src/rgbw-bulb/fingerprints.lua | 74 + .../zigbee-switch/src/rgbw-bulb/init.lua | 99 +- .../zigbee-switch/src/robb/can_handle.lua | 17 + .../zigbee-switch/src/robb/init.lua | 58 +- .../src/sinope-dimmer/can_handle.lua | 12 + .../zigbee-switch/src/sinope-dimmer/init.lua | 25 +- .../zigbee-switch/src/sinope/can_handle.lua | 12 + .../zigbee-switch/src/sinope/init.lua | 25 +- .../src/stateless_handlers/can_handle.lua | 14 + .../src/stateless_handlers/init.lua | 97 + .../zigbee-switch/src/sub_drivers.lua | 42 + .../zigbee-switch/src/switch_utils.lua | 23 + .../test/test_all_capability_zigbee_bulb.lua | 641 +++- .../src/test/test_aqara_led_bulb.lua | 115 +- .../src/test/test_aqara_light.lua | 169 +- .../src/test/test_aqara_smart_plug.lua | 93 +- .../src/test/test_aqara_smart_plug_t1.lua | 98 +- .../src/test/test_aqara_switch_module.lua | 66 +- .../test_aqara_switch_module_no_power.lua | 153 + .../src/test/test_aqara_switch_no_power.lua | 345 ++ .../src/test/test_aqara_switch_power.lua | 104 +- .../src/test/test_aqara_wall_switch.lua | 78 +- .../src/test/test_aurora_relay.lua | 50 +- .../src/test/test_bad_data_type.lua | 19 +- .../src/test/test_bad_device_kind.lua | 52 + .../zigbee-switch/src/test/test_cree_bulb.lua | 35 +- .../test/test_duragreen_color_temp_bulb.lua | 157 +- .../test/test_enbrighten_metering_dimmer.lua | 175 +- .../src/test/test_firstled_light.lua | 106 + .../src/test/test_firstled_switch.lua | 481 +++ .../src/test/test_frient_IO_module.lua | 698 ++++ .../src/test/test_frient_switch.lua | 364 ++ .../src/test/test_ge_link_bulb.lua | 80 +- .../src/test/test_hanssem_switch.lua | 315 +- .../src/test/test_inovelli_vzm30_sn.lua | 563 +++ .../src/test/test_inovelli_vzm30_sn_child.lua | 372 ++ .../test_inovelli_vzm30_sn_preferences.lua | 234 ++ .../src/test/test_inovelli_vzm31_sn.lua | 440 +++ .../src/test/test_inovelli_vzm31_sn_child.lua | 362 ++ .../test_inovelli_vzm31_sn_preferences.lua | 224 ++ .../src/test/test_inovelli_vzm32_sn.lua | 557 +++ .../src/test/test_inovelli_vzm32_sn_child.lua | 361 ++ .../test_inovelli_vzm32_sn_preferences.lua | 182 + .../src/test/test_jasco_switch.lua | 80 +- .../src/test/test_laisiao_bath_heather.lua | 574 +++ .../src/test/test_ledvance_metering_plug.lua | 107 + .../src/test/test_multi_switch.lua | 34 +- .../src/test/test_multi_switch_no_master.lua | 676 ++-- .../src/test/test_multi_switch_power.lua | 239 +- .../src/test/test_on_off_zigbee_bulb.lua | 106 +- .../src/test/test_osram_iqbr30_light.lua | 31 +- .../src/test/test_osram_light.lua | 28 +- .../zigbee-switch/src/test/test_rgb_bulb.lua | 41 +- .../zigbee-switch/src/test/test_rgbw_bulb.lua | 129 +- .../test/test_robb_smarrt_2-wire_dimmer.lua | 78 +- .../src/test/test_robb_smarrt_knob_dimmer.lua | 58 +- .../src/test/test_sengled_color_temp_bulb.lua | 199 +- ...sengled_dimmer_bulb_with_motion_sensor.lua | 87 +- .../src/test/test_sinope_dimmer.lua | 70 +- .../src/test/test_sinope_switch.lua | 37 +- .../src/test/test_switch_power.lua | 221 +- .../src/test/test_tuya_multi.lua | 85 + .../src/test/test_tuya_multi_switch.lua | 125 + .../src/test/test_wallhero_switch.lua | 275 +- .../src/test/test_white_color_temp_bulb.lua | 153 +- .../src/test/test_yanmi_switch.lua | 445 +++ .../test/test_zigbee_dimmer_power_energy.lua | 157 - .../src/test/test_zigbee_ezex_switch.lua | 58 +- ...metering_plug_power_consumption_report.lua | 97 +- .../test_zigbee_metering_plug_rexense.lua | 25 +- .../src/test/test_zll_color_temp_bulb.lua | 278 +- .../src/test/test_zll_dimmer.lua | 104 +- .../src/test/test_zll_dimmer_bulb.lua | 101 +- .../src/test/test_zll_rgb_bulb.lua | 335 +- .../src/test/test_zll_rgbw_bulb.lua | 335 +- .../src/tuya-multi/can_handle.lua | 21 + .../zigbee-switch/src/tuya-multi/init.lua | 54 + .../zigbee-switch/src/wallhero/can_handle.lua | 14 + .../src/wallhero/fingerprints.lua | 11 + .../zigbee-switch/src/wallhero/init.lua | 42 +- .../src/white-color-temp-bulb/can_handle.lua | 13 + .../duragreen/can_handle.lua | 18 + .../white-color-temp-bulb/duragreen/init.lua | 29 +- .../white-color-temp-bulb/fingerprints.lua | 99 + .../src/white-color-temp-bulb/init.lua | 128 +- .../src/white-color-temp-bulb/sub_drivers.lua | 7 + .../zigbee-dimmer-power-energy/can_handle.lua | 16 + .../enbrighten-metering-dimmer/init.lua | 66 - .../src/zigbee-dimmer-power-energy/init.lua | 58 +- .../src/zigbee-dimming-light/can_handle.lua | 13 + .../src/zigbee-dimming-light/fingerprints.lua | 36 + .../src/zigbee-dimming-light/init.lua | 77 +- .../osram-iqbr30/can_handle.lua | 10 + .../osram-iqbr30/init.lua | 21 +- .../src/zigbee-dimming-light/sub_drivers.lua | 9 + .../zll-dimmer/can_handle.lua | 12 + .../zll-dimmer/fingerprints.lua | 11 + .../zigbee-dimming-light/zll-dimmer/init.lua | 35 +- .../can_handle.lua | 16 + .../src/zigbee-dual-metering-switch/init.lua | 34 +- .../can_handle.lua | 12 + .../init.lua | 58 +- .../aurora-relay/can_handle.lua | 17 + .../zigbee-switch-power/aurora-relay/init.lua | 32 +- .../src/zigbee-switch-power/can_handle.lua | 13 + .../src/zigbee-switch-power/fingerprints.lua | 20 + .../src/zigbee-switch-power/init.lua | 48 +- .../ledvance-metering-plug/can_handle.lua | 13 + .../ledvance-metering-plug/fingerprints.lua | 7 + .../ledvance-metering-plug/init.lua | 23 + .../src/zigbee-switch-power/sub_drivers.lua | 10 + .../zigbee-switch-power/vimar/can_handle.lua | 15 + .../src/zigbee-switch-power/vimar/init.lua | 30 +- .../src/zll-dimmer-bulb/can_handle.lua | 13 + .../src/zll-dimmer-bulb/fingerprints.lua | 117 + .../src/zll-dimmer-bulb/init.lua | 145 +- .../src/zll-polling/can_handle.lua | 14 + .../zigbee-switch/src/zll-polling/init.lua | 69 + .../capabilities/invisibleCapabilities.yaml | 18 + .../capabilities/valveCalibration.yaml | 24 + .../zigbee-thermostat/fingerprints.yml | 15 + .../profiles/thermostat-aqara.yml | 40 + .../thermostat-thirty-buttons-wallhero.yml | 249 ++ .../src/aqara/can_handle.lua | 14 + .../src/aqara/fingerprints.lua | 8 + .../zigbee-thermostat/src/aqara/init.lua | 262 ++ .../src/danfoss/can_handle.lua | 14 + .../src/danfoss/fingerprints.lua | 8 + .../zigbee-thermostat/src/danfoss/init.lua | 18 +- .../src/fidure/can_handle.lua | 11 + .../zigbee-thermostat/src/fidure/init.lua | 20 +- .../zigbee-thermostat/src/init.lua | 128 +- .../src/lazy_load_subdriver.lua | 15 + .../src/leviton/can_handle.lua | 11 + .../zigbee-thermostat/src/leviton/init.lua | 20 +- .../src/lux-konoz/can_handle.lua | 14 + .../src/lux-konoz/fingerprints.lua | 8 + .../zigbee-thermostat/src/lux-konoz/init.lua | 29 +- .../zigbee-thermostat/src/popp/can_handle.lua | 14 + .../src/popp/fingerprints.lua | 15 + .../zigbee-thermostat/src/popp/init.lua | 73 +- .../src/resideo_korea/can_handle.lua | 11 + .../src/resideo_korea/init.lua | 20 +- .../src/sinope/can_handle.lua | 13 + .../zigbee-thermostat/src/sinope/init.lua | 27 +- .../can_handle.lua | 14 + .../fingerprints.lua | 9 + .../src/stelpro-ki-zigbee-thermostat/init.lua | 32 +- .../src/stelpro/can_handle.lua | 14 + .../src/stelpro/fingerprints.lua | 10 + .../zigbee-thermostat/src/stelpro/init.lua | 33 +- .../stelpro_maestrostat/can_handle.lua | 14 + .../stelpro_maestrostat/fingerprints.lua | 6 + .../init.lua} | 30 +- .../src/stelpro/stelpro_sorb/can_handle.lua | 14 + .../src/stelpro/stelpro_sorb/fingerprints.lua | 7 + .../init.lua} | 31 +- .../src/stelpro/sub_drivers.lua | 9 + .../zigbee-thermostat/src/sub_drivers.lua | 20 + .../src/test/test_aqara_thermostat.lua | 477 +++ .../src/test/test_centralite_thermostat.lua | 38 +- .../src/test/test_danfoss_thermostat.lua | 35 +- .../src/test/test_fidure_thermostat.lua | 27 +- .../src/test/test_leviton_rc.lua | 124 +- .../src/test/test_popp_thermostat.lua | 260 +- .../src/test/test_resideo_dt300st_m000.lua | 331 +- .../test/test_sinope_th1300_thermostat.lua | 35 +- .../test/test_sinope_th1400_thermostat.lua | 35 +- .../src/test/test_sinope_thermostat.lua | 65 +- .../test_stelpro_ki_zigbee_thermostat.lua | 178 +- .../src/test/test_stelpro_thermostat.lua | 133 +- .../src/test/test_vimar_thermostat.lua | 100 +- .../src/test/test_zenwithin_thermostat.lua | 469 ++- .../src/test/test_zigbee_thermostat.lua | 293 +- .../test_zigbee_thermostat_wallhero_3in1.lua | 1240 ++++++ .../src/vimar/can_handle.lua | 17 + .../zigbee-thermostat/src/vimar/init.lua | 27 +- .../src/wallhero/can_handle.lua | 13 + .../src/wallhero/fingerprints.lua | 6 + .../zigbee-thermostat/src/wallhero/init.lua | 232 ++ .../src/zenwithin/can_handle.lua | 11 + .../zigbee-thermostat/src/zenwithin/init.lua | 47 +- drivers/SmartThings/zigbee-thing/src/init.lua | 1 + .../zigbee-valve/src/ezex/can_handle.lua | 12 + .../zigbee-valve/src/ezex/init.lua | 67 + drivers/SmartThings/zigbee-valve/src/init.lua | 22 +- .../zigbee-valve/src/lazy_load_subdriver.lua | 15 + .../zigbee-valve/src/sinope/can_handle.lua | 11 + .../zigbee-valve/src/sinope/init.lua | 24 +- .../zigbee-valve/src/sub_drivers.lua | 9 + .../zigbee-valve/src/test/test_ezex_valve.lua | 360 ++ .../src/test/test_sinope_valve.lua | 56 +- .../src/test/test_zigbee_valve.lua | 58 +- drivers/SmartThings/zigbee-vent/src/init.lua | 37 +- .../zigbee-vent/src/test/test_zigbee_vent.lua | 101 +- .../zigbee-water-leak-sensor/fingerprints.yml | 7 +- .../profiles/water-battery-no-fw-update.yml | 12 + .../src/aqara/can_handle.lua | 14 + .../src/aqara/fingerprints.lua | 8 + .../src/aqara/init.lua | 17 +- .../src/configurations.lua | 16 +- .../src/frient/can_handle.lua | 11 + .../src/frient/init.lua | 21 +- .../zigbee-water-leak-sensor/src/init.lua | 77 +- .../src/lazy_load_subdriver.lua | 15 + .../src/leaksmart/can_handle.lua | 11 + .../src/leaksmart/init.lua | 20 +- .../src/sengled/can_handle.lua | 14 + .../src/sengled/fingerprints.lua | 8 + .../src/sengled/init.lua | 17 +- .../src/sinope/can_handle.lua | 13 + .../src/sinope/init.lua | 29 +- .../src/sub_drivers.lua | 14 + .../src/test/test_aqara_water_leak_sensor.lua | 50 +- .../test_centralite_water_leak_sensor.lua | 83 +- .../test/test_frient_water_leak_sensor.lua | 83 +- .../src/test/test_leaksmart_water.lua | 65 +- .../test/test_samjin_water_leak_sensor.lua | 77 +- .../test/test_sengled_water_leak_sensor.lua | 35 +- .../src/test/test_sinope_zigbee_water.lua | 155 +- .../test_smartthings_water_leak_sensor.lua | 76 +- .../test_thirdreality_water_leak_sensor.lua | 52 +- .../src/test/test_zigbee_water.lua | 222 +- .../src/test/test_zigbee_water_freeze.lua | 82 +- .../src/thirdreality/can_handle.lua | 14 + .../src/thirdreality/fingerprints.lua | 9 + .../src/thirdreality/init.lua | 32 +- .../src/zigbee-water-freeze/can_handle.lua | 11 + .../src/zigbee-water-freeze/init.lua | 20 +- .../zigbee-watering-kit/config.yml | 6 + .../zigbee-watering-kit/fingerprints.yml | 6 + .../profiles/watering-kit-thirdreality.yml | 23 + .../zigbee-watering-kit/src/init.lua | 23 + .../src/lazy_load_subdriver.lua | 15 + .../zigbee-watering-kit/src/sub_drivers.lua | 8 + .../test/test_thirdreality_watering_kit.lua | 318 ++ .../src/thirdreality/can_handle.lua | 11 + .../src/thirdreality/init.lua | 106 + .../capabilities/chargingState.yaml | 42 + .../capabilities/hookLockState.yaml | 32 + .../zigbee-window-treatment/fingerprints.yml | 47 +- .../projector-screen-VWSDSTUST120H.yml | 23 + .../profiles/window-shade-only.yml | 12 + ...ndow-treatment-aqara-curtain-driver-e1.yml | 32 + ...ow-treatment-aqara-roller-shade-rotate.yml | 2 + .../profiles/window-treatment-aqara.yml | 2 + .../profiles/window-treatment-battery.yml | 5 +- .../profiles/window-treatment-no-preset.yml | 2 + .../profiles/window-treatment-powerSource.yml | 22 + ...w-treatment-profile-no-firmware-update.yml | 7 +- .../profiles/window-treatment-profile.yml | 6 +- .../profiles/window-treatment-reverse.yml | 4 +- .../src/HOPOsmart/can_handle.lua | 14 + .../src/HOPOsmart/custom_clusters.lua | 20 + .../src/HOPOsmart/fingerprints.lua | 8 + .../src/HOPOsmart/init.lua | 68 + .../src/VIVIDSTORM/can_handle.lua | 14 + .../src/VIVIDSTORM/custom_clusters.lua | 24 + .../src/VIVIDSTORM/fingerprints.lua | 8 + .../src/VIVIDSTORM/init.lua | 141 + .../src/aqara/aqara_utils.lua | 16 + .../src/aqara/can_handle.lua | 14 + .../aqara/curtain-driver-e1/can_handle.lua | 11 + .../src/aqara/curtain-driver-e1/init.lua | 220 ++ .../src/aqara/fingerprints.lua | 11 + .../src/aqara/init.lua | 41 +- .../src/aqara/roller-shade/can_handle.lua | 11 + .../src/aqara/roller-shade/init.lua | 282 +- .../src/aqara/sub_drivers.lua | 10 + .../src/aqara/version/can_handle.lua | 13 + .../src/aqara/version/init.lua | 10 +- .../src/axis/axis_version/can_handle.lua | 15 + .../src/axis/axis_version/init.lua | 32 +- .../src/axis/can_handle.lua | 11 + .../zigbee-window-treatment/src/axis/init.lua | 32 +- .../src/axis/sub_drivers.lua | 8 + .../src/feibit/can_handle.lua | 15 + .../src/feibit/fingerprints.lua | 9 + .../src/feibit/init.lua | 36 +- .../src/hanssem/can_handle.lua | 11 + .../src/hanssem/init.lua | 13 +- .../zigbee-window-treatment/src/init.lua | 61 +- .../src/invert-lift-percentage/can_handle.lua | 14 + .../src/invert-lift-percentage/init.lua | 29 +- .../src/lazy_load_subdriver.lua | 15 + .../src/rooms-beautiful/can_handle.lua | 14 + .../src/rooms-beautiful/fingerprints.lua | 8 + .../src/rooms-beautiful/init.lua | 29 +- .../src/screen-innovations/can_handle.lua | 11 + .../src/screen-innovations/init.lua | 170 + .../src/sombra/can_handle.lua | 14 + .../src/sombra/fingerprints.lua | 8 + .../src/sombra/init.lua | 175 + .../src/somfy/can_handle.lua | 14 + .../src/somfy/fingerprints.lua | 10 + .../src/somfy/init.lua | 36 +- .../src/sub_drivers.lua | 20 + .../test_zigbee_window_shade_battery_ikea.lua | 216 +- ...est_zigbee_window_shade_battery_yoolax.lua | 180 +- ...est_zigbee_window_shade_only_HOPOsmart.lua | 215 ++ .../src/test/test_zigbee_window_treatment.lua | 350 +- ..._zigbee_window_treatment_VWSDSTUST120H.lua | 368 ++ .../test_zigbee_window_treatment_aqara.lua | 287 +- ...ndow_treatment_aqara_curtain_driver_e1.lua | 452 +++ ...ow_treatment_aqara_roller_shade_rotate.lua | 259 +- .../test_zigbee_window_treatment_axis.lua | 115 +- .../test_zigbee_window_treatment_feibit.lua | 113 +- .../test_zigbee_window_treatment_hanssem.lua | 75 +- ..._zigbee_window_treatment_inverted_step.lua | 346 ++ ...ee_window_treatment_level_cluster_step.lua | 400 ++ .../test_zigbee_window_treatment_rooms.lua | 86 +- ...ee_window_treatment_screen_innovations.lua | 464 +++ .../test_zigbee_window_treatment_sombra.lua | 434 +++ .../test_zigbee_window_treatment_somfy.lua | 248 +- .../test_zigbee_window_treatment_vimar.lua | 475 +++ .../src/vimar/can_handle.lua | 14 + .../src/vimar/fingerprints.lua | 9 + .../src/vimar/init.lua | 48 +- .../src/window_shade_utils.lua | 74 + .../src/window_treatment_utils.lua | 13 + .../src/yoolax/can_handle.lua | 14 + .../src/yoolax/fingerprints.lua | 9 + .../src/yoolax/init.lua | 34 +- .../src/aeon-multiwhite-bulb/can_handle.lua | 14 + .../src/aeon-multiwhite-bulb/fingerprints.lua | 10 + .../src/aeon-multiwhite-bulb/init.lua | 31 +- .../src/aeotec-led-bulb-6/can_handle.lua | 23 + .../zwave-bulb/src/aeotec-led-bulb-6/init.lua | 34 +- .../src/fibaro-rgbw-controller/can_handle.lua | 20 + .../src/fibaro-rgbw-controller/init.lua | 31 +- drivers/SmartThings/zwave-bulb/src/init.lua | 25 +- .../zwave-bulb/src/lazy_load_subdriver.lua | 18 + .../zwave-bulb/src/sub_drivers.lua | 10 + .../src/test/test_aeon_multiwhite_bulb.lua | 85 +- .../src/test/test_aeotec_led_bulb_6.lua | 28 +- .../src/test/test_fibaro_rgbw_controller.lua | 59 +- .../zwave-bulb/src/test/test_zwave_bulb.lua | 55 +- .../SmartThings/zwave-button/fingerprints.yml | 12 + .../zwave-button/src/apiv6_bugfix/init.lua | 26 - .../zwave-button/src/configurations.lua | 38 +- drivers/SmartThings/zwave-button/src/init.lua | 22 +- .../zwave-button/src/lazy_load_subdriver.lua | 18 + .../zwave-button/src/sub_drivers.lua | 8 + .../src/test/test_zwave_aeotec_minimote.lua | 50 +- .../test/test_zwave_aeotec_nanomote_one.lua | 21 +- .../src/test/test_zwave_button.lua | 38 +- .../src/test/test_zwave_fibaro_button.lua | 21 +- .../src/test/test_zwave_multi_button.lua | 126 +- .../aeotec-keyfob/can_handle.lua | 14 + .../aeotec-keyfob/fingerprints.lua | 10 + .../zwave-multi-button/aeotec-keyfob/init.lua | 33 +- .../aeotec-minimote/can_handle.lua | 14 + .../aeotec-minimote/fingerprints.lua | 8 + .../aeotec-minimote/init.lua | 30 +- .../src/zwave-multi-button/can_handle.lua | 14 + .../fibaro-keyfob/can_handle.lua | 14 + .../fibaro-keyfob/fingerprints.lua | 10 + .../zwave-multi-button/fibaro-keyfob/init.lua | 32 +- .../src/zwave-multi-button/fingerprints.lua | 23 + .../src/zwave-multi-button/init.lua | 50 +- .../shelly_wave_i4/can_handle.lua | 14 + .../shelly_wave_i4/fingerprints.lua | 9 + .../shelly_wave_i4/init.lua | 30 + .../src/zwave-multi-button/sub_drivers.lua | 11 + .../zwave-electric-meter/fingerprints.yml | 16 + ...tec-home-energy-meter-gen8-1-phase-con.yml | 107 + ...tec-home-energy-meter-gen8-1-phase-pro.yml | 22 + ...tec-home-energy-meter-gen8-2-phase-con.yml | 148 + ...tec-home-energy-meter-gen8-2-phase-pro.yml | 31 + ...tec-home-energy-meter-gen8-3-phase-con.yml | 189 + ...tec-home-energy-meter-gen8-3-phase-pro.yml | 40 + ...aeotec-home-energy-meter-gen8-sald-con.yml | 13 + ...aeotec-home-energy-meter-gen8-sald-pro.yml | 13 + .../src/aeon-meter/can_handle.lua | 14 + .../src/aeon-meter/fingerprints.lua | 9 + .../src/aeon-meter/init.lua | 31 +- .../src/aeotec-gen5-meter/can_handle.lua | 14 + .../src/aeotec-gen5-meter/fingerprints.lua | 9 + .../src/aeotec-gen5-meter/init.lua | 31 +- .../1-phase/can_handle.lua | 18 + .../1-phase/init.lua | 123 + .../2-phase/can_handle.lua | 17 + .../2-phase/init.lua | 123 + .../3-phase/can_handle.lua | 18 + .../3-phase/init.lua | 123 + .../can_handle.lua | 22 + .../aeotec-home-energy-meter-gen8/init.lua | 49 + .../power_consumption.lua | 32 + .../sub_drivers.lua | 10 + .../zwave-electric-meter/src/init.lua | 44 +- .../src/lazy_load_subdriver.lua | 18 + .../zwave-electric-meter/src/preferences.lua | 95 + .../src/qubino-meter/can_handle.lua | 14 + .../src/qubino-meter/fingerprints.lua | 9 + .../src/qubino-meter/init.lua | 30 +- .../zwave-electric-meter/src/sub_drivers.lua | 11 + .../src/test/test_aeon_meter.lua | 30 +- .../src/test/test_aeotec_gen5_meter.lua | 30 +- ..._aeotec_home_energy_meter_gen8_1_phase.lua | 560 +++ ..._aeotec_home_energy_meter_gen8_2_phase.lua | 654 ++++ ..._aeotec_home_energy_meter_gen8_3_phase.lua | 749 ++++ .../src/test/test_qubino_3_phase_meter.lua | 32 +- .../src/test/test_qubino_smart_meter.lua | 30 +- .../src/test/test_zwave_electric_meter.lua | 25 +- drivers/SmartThings/zwave-fan/src/init.lua | 22 +- .../zwave-fan/src/lazy_load_subdriver.lua | 18 + .../SmartThings/zwave-fan/src/sub_drivers.lua | 9 + .../src/test/test_zwave_fan_3_speed.lua | 33 +- .../src/test/test_zwave_fan_4_speed.lua | 33 +- .../src/zwave-fan-3-speed/can_handle.lua | 14 + .../src/zwave-fan-3-speed/fingerprints.lua | 12 + .../zwave-fan/src/zwave-fan-3-speed/init.lua | 34 +- .../src/zwave-fan-4-speed/can_handle.lua | 14 + .../src/zwave-fan-4-speed/fingerprints.lua | 8 + .../zwave-fan/src/zwave-fan-4-speed/init.lua | 30 +- .../zwave-fan/src/zwave_fan_helpers.lua | 16 +- .../src/ecolink-zw-gdo/can_handle.lua | 14 + .../src/ecolink-zw-gdo/fingerprints.lua | 9 + .../src/ecolink-zw-gdo/init.lua | 28 +- .../zwave-garage-door-opener/src/init.lua | 22 +- .../src/lazy_load_subdriver.lua | 18 + .../src/mimolite-garage-door/can_handle.lua | 14 + .../src/mimolite-garage-door/fingerprints.lua | 8 + .../src/mimolite-garage-door/init.lua | 30 +- .../src/sub_drivers.lua | 9 + .../test_ecolink_garage_door_operator.lua | 62 +- .../src/test/test_mimolite_garage_door.lua | 58 +- .../test/test_zwave_garage_door_opener.lua | 37 +- .../SmartThings/zwave-lock/fingerprints.yml | 7 + .../zwave-lock/profiles/base-lock-tamper.yml | 4 + .../zwave-lock/profiles/base-lock.yml | 4 + .../zwave-lock/src/apiv6_bugfix/init.lua | 26 - drivers/SmartThings/zwave-lock/src/init.lua | 238 +- .../zwave-lock/src/keywe-lock/can_handle.lua | 16 + .../zwave-lock/src/keywe-lock/init.lua | 31 +- .../zwave-lock/src/lazy_load_subdriver.lua | 18 + .../src/legacy-handlers/can_handle.lua | 12 + .../zwave-lock/src/legacy-handlers/init.lua | 133 + .../legacy-handlers/keywe-lock/can_handle.lua | 12 + .../src/legacy-handlers/keywe-lock/init.lua | 71 + .../samsung-lock/can_handle.lua | 11 + .../src/legacy-handlers/samsung-lock/init.lua | 96 + .../schlage-lock/can_handle.lua | 11 + .../src/legacy-handlers/schlage-lock/init.lua | 178 + .../src/legacy-handlers/sub_drivers.lua | 11 + .../zwave-alarm-v1-lock/can_handle.lua | 10 + .../zwave-alarm-v1-lock/init.lua | 146 + .../src/lock_handlers/capabilities.lua | 252 ++ .../src/lock_handlers/zwave_responses.lua | 256 ++ .../zwave-lock/src/lock_utils/constants.lua | 48 + .../zwave-lock/src/lock_utils/tables.lua | 268 ++ .../zwave-lock/src/lock_utils/utils.lua | 160 + .../src/samsung-lock/can_handle.lua | 15 + .../zwave-lock/src/samsung-lock/init.lua | 79 +- .../src/schlage-lock/can_handle.lua | 15 + .../zwave-lock/src/schlage-lock/init.lua | 152 +- .../zwave-lock/src/sub_drivers.lua | 12 + .../src/test/test_init_lifecycle_handlers.lua | 135 + .../zwave-lock/src/test/test_keywe_lock.lua | 80 +- .../src/test/test_keywe_lock_legacy.lua | 101 + .../zwave-lock/src/test/test_lock_battery.lua | 58 +- .../test/test_lock_code_slga_migration.lua | 127 + .../test/test_lock_credentials_commands.lua | 480 +++ .../src/test/test_lock_pre_configured.lua | 514 +++ .../zwave-lock/src/test/test_lock_tables.lua | 761 ++++ .../src/test/test_lock_users_commands.lua | 705 ++++ ..._lock.lua => test_samsung_lock_legacy.lua} | 41 +- ..._lock.lua => test_schlage_lock_legacy.lua} | 56 +- .../test/test_zwave_lock_code_migration.lua | 246 -- ...ve_lock.lua => test_zwave_lock_legacy.lua} | 151 +- .../src/test/test_zwave_responses.lua | 393 ++ .../src/zwave-alarm-v1-lock/can_handle.lua | 14 + .../src/zwave-alarm-v1-lock/init.lua | 115 +- .../src/test/test_zwave_mouse_trap.lua | 38 +- .../SmartThings/zwave-sensor/fingerprints.yml | 28 +- .../profiles/humidity-temperature-battery.yml | 57 +- .../profiles/shelly-wave-motion.yml | 59 + .../profiles/zooz-zse42-water.yml | 52 + .../src/aeotec-multisensor/can_handle.lua | 15 + .../src/aeotec-multisensor/fingerprints.lua | 9 + .../src/aeotec-multisensor/init.lua | 37 +- .../multisensor-6/can_handle.lua | 11 + .../aeotec-multisensor/multisensor-6/init.lua | 24 +- .../multisensor-7/can_handle.lua | 12 + .../aeotec-multisensor/multisensor-7/init.lua | 25 +- .../src/aeotec-multisensor/sub_drivers.lua | 9 + .../src/aeotec-water-sensor/can_handle.lua | 15 + .../src/aeotec-water-sensor/fingerprints.lua | 11 + .../src/aeotec-water-sensor/init.lua | 38 +- .../zwave-sensor/src/apiv6_bugfix/init.lua | 45 - .../zwave-sensor/src/configurations.lua | 48 +- .../src/enerwave-motion-sensor/can_handle.lua | 12 + .../src/enerwave-motion-sensor/init.lua | 25 +- .../can_handle.lua | 17 + .../everspring-motion-light-sensor/init.lua | 29 +- .../can_handle.lua | 15 + .../ezmultipli-multipurpose-sensor/init.lua | 35 +- .../fibaro-door-window-sensor/can_handle.lua | 15 + .../can_handle.lua | 14 + .../fingerprints.lua | 8 + .../fibaro-door-window-sensor-1/init.lua | 42 +- .../can_handle.lua | 14 + .../fingerprints.lua | 10 + .../fibaro-door-window-sensor-2/init.lua | 39 +- .../fingerprints.lua | 15 + .../src/fibaro-door-window-sensor/init.lua | 43 +- .../fibaro-door-window-sensor/sub_drivers.lua | 9 + .../src/fibaro-flood-sensor/can_handle.lua | 14 + .../src/fibaro-flood-sensor/init.lua | 40 +- .../src/fibaro-motion-sensor/can_handle.lua | 15 + .../src/fibaro-motion-sensor/init.lua | 27 +- .../src/firmware-version/can_handle.lua | 21 + .../src/firmware-version/init.lua | 66 + .../can_handle.lua | 20 + .../glentronics-water-leak-sensor/init.lua | 38 +- .../src/homeseer-multi-sensor/can_handle.lua | 20 + .../src/homeseer-multi-sensor/init.lua | 38 +- drivers/SmartThings/zwave-sensor/src/init.lua | 41 +- .../zwave-sensor/src/lazy_load_subdriver.lua | 18 + .../zwave-sensor/src/preferences.lua | 46 +- .../src/sensative-strip/can_handle.lua | 14 + .../zwave-sensor/src/sensative-strip/init.lua | 25 +- .../zwave-sensor/src/sub_drivers.lua | 25 + .../src/test/test_aeon_multisensor.lua | 48 +- .../src/test/test_aeotec_multisensor_6.lua | 83 +- .../src/test/test_aeotec_multisensor_7.lua | 40 +- .../src/test/test_aeotec_multisensor_gen5.lua | 21 +- .../src/test/test_aeotec_water_sensor.lua | 54 +- .../src/test/test_aeotec_water_sensor_7.lua | 36 +- .../src/test/test_enerwave_motion_sensor.lua | 31 +- .../src/test/test_everpsring_sp817.lua | 26 +- .../src/test/test_everspring_PIR_sensor.lua | 38 +- .../src/test/test_everspring_ST814.lua | 21 +- .../test_everspring_illuminance_sensor.lua | 21 +- .../test_everspring_motion_light_sensor.lua | 19 +- .../test_ezmultipli_multipurpose_sensor.lua | 39 +- .../test/test_fibaro_door_window_sensor.lua | 39 +- .../test/test_fibaro_door_window_sensor_1.lua | 114 +- .../test/test_fibaro_door_window_sensor_2.lua | 57 +- ...ro_door_window_sensor_with_temperature.lua | 475 +-- .../src/test/test_fibaro_flood_sensor.lua | 97 +- .../src/test/test_fibaro_flood_sensor_zw5.lua | 21 +- .../src/test/test_fibaro_motion_sensor.lua | 89 +- .../test/test_fibaro_motion_sensor_zw5.lua | 26 +- .../src/test/test_firmware_version.lua | 132 + .../src/test/test_generic_sensor.lua | 268 +- .../test_glentronics_water_leak_sensor.lua | 37 +- .../src/test/test_homeseer_multi_sensor.lua | 35 +- .../src/test/test_no_wakeup_poll.lua | 21 +- .../src/test/test_sensative_strip.lua | 31 +- .../test_smartthings_water_leak_sensor.lua | 70 +- .../src/test/test_v1_contact_event.lua | 27 +- .../src/test/test_vision_motion_detector.lua | 36 +- .../src/test/test_zooz_4_in_1_sensor.lua | 66 +- .../test/test_zwave_motion_light_sensor.lua | 67 +- .../test_zwave_motion_temp_light_sensor.lua | 61 +- .../src/test/test_zwave_sensor.lua | 165 +- .../src/test/test_zwave_water_sensor.lua | 54 +- .../src/timed-tamper-clear/can_handle.lua | 23 + .../src/timed-tamper-clear/init.lua | 40 +- .../src/v1-contact-event/can_handle.lua | 22 + .../src/v1-contact-event/init.lua | 27 +- .../src/vision-motion-detector/can_handle.lua | 17 + .../src/vision-motion-detector/init.lua | 38 +- .../src/wakeup-no-poll/can_handle.lua | 12 + .../zwave-sensor/src/wakeup-no-poll/init.lua | 28 +- .../src/zooz-4-in-1-sensor/can_handle.lua | 15 + .../src/zooz-4-in-1-sensor/fingerprints.lua | 10 + .../src/zooz-4-in-1-sensor/init.lua | 37 +- .../zwave-water-leak-sensor/can_handle.lua | 15 + .../zwave-water-leak-sensor/fingerprints.lua | 19 + .../src/zwave-water-leak-sensor/init.lua | 43 +- .../SmartThings/zwave-siren/fingerprints.yml | 8 +- .../zwave-siren/profiles/zooz-zse50.yml | 309 ++ .../zwave-siren/src/aeon-siren/can_handle.lua | 14 + .../zwave-siren/src/aeon-siren/init.lua | 24 +- .../src/aeotec-doorbell-siren/can_handle.lua | 14 + .../aeotec-doorbell-siren/fingerprints.lua | 13 + .../src/aeotec-doorbell-siren/init.lua | 49 +- .../zwave-siren/src/apiv6_bugfix/init.lua | 26 - .../zwave-siren/src/configurations.lua | 16 +- .../src/ecolink-wireless-siren/can_handle.lua | 14 + .../ecolink-wireless-siren/fingerprints.lua | 8 + .../src/ecolink-wireless-siren/init.lua | 29 +- .../zwave-siren/src/fortrezz/can_handle.lua | 13 + .../zwave-siren/src/fortrezz/init.lua | 25 +- drivers/SmartThings/zwave-siren/src/init.lua | 36 +- .../zwave-siren/src/lazy_load_subdriver.lua | 18 + .../src/multifunctional-siren/can_handle.lua | 14 + .../multifunctional-siren/fingerprints.lua | 9 + .../src/multifunctional-siren/init.lua | 30 +- .../src/philio-sound-siren/can_handle.lua | 14 + .../src/philio-sound-siren/fingerprints.lua | 8 + .../src/philio-sound-siren/init.lua | 29 +- .../zwave-siren/src/preferences.lua | 44 +- .../zwave-siren/src/sub_drivers.lua | 18 + .../zwave-siren/src/test/test_aeon_siren.lua | 62 +- .../src/test/test_aeotec_doorbell_siren.lua | 384 +- .../src/test/test_ecolink_wireless_siren.lua | 79 +- .../src/test/test_fortrezz_siren.lua | 38 +- .../src/test/test_philio_sound_siren.lua | 92 +- .../src/test/test_utilitech_siren.lua | 24 +- .../zwave-siren/src/test/test_yale_siren.lua | 57 +- .../src/test/test_zipato_siren.lua | 37 +- .../zwave-siren/src/test/test_zooz_zse50.lua | 717 ++++ .../test/test_zwave_multifunctional-siren.lua | 33 +- .../test/test_zwave_notification_siren.lua | 28 +- .../zwave-siren/src/test/test_zwave_siren.lua | 56 +- .../src/test/test_zwave_sound_sensor.lua | 25 +- .../src/utilitech-siren/can_handle.lua | 13 + .../zwave-siren/src/utilitech-siren/init.lua | 25 +- .../zwave-siren/src/yale-siren/can_handle.lua | 12 + .../zwave-siren/src/yale-siren/init.lua | 24 +- .../src/zipato-siren/can_handle.lua | 12 + .../zwave-siren/src/zipato-siren/init.lua | 22 +- .../zwave-siren/src/zooz-zse50/can_handle.lua | 14 + .../src/zooz-zse50/fingerprints.lua | 8 + .../zwave-siren/src/zooz-zse50/init.lua | 367 ++ .../src/zwave-sound-sensor/can_handle.lua | 14 + .../src/zwave-sound-sensor/fingerprints.lua | 8 + .../src/zwave-sound-sensor/init.lua | 29 +- .../zwave-smoke-alarm/fingerprints.yml | 6 + .../src/apiv6_bugfix/init.lua | 26 - .../src/fibaro-smoke-sensor/can_handle.lua | 14 + .../src/fibaro-smoke-sensor/fingerprints.lua | 11 + .../src/fibaro-smoke-sensor/init.lua | 33 +- .../zwave-smoke-alarm/src/init.lua | 28 +- .../src/lazy_load_subdriver.lua | 18 + .../zwave-smoke-alarm/src/preferences.lua | 16 +- .../zwave-smoke-alarm/src/sub_drivers.lua | 10 + .../src/test/test_fibaro_co_sensor_zw5.lua | 111 +- .../src/test/test_fibaro_smoke_sensor.lua | 31 +- .../src/test/test_zwave_alarm_v1.lua | 37 +- .../src/test/test_zwave_co_detector.lua | 37 +- .../src/test/test_zwave_smoke_detector.lua | 61 +- .../zwave-smoke-co-alarm-v1/can_handle.lua | 13 + .../src/zwave-smoke-co-alarm-v1/init.lua | 28 +- .../zwave-smoke-co-alarm-v2/can_handle.lua | 14 + .../fibaro-co-sensor-zw5/can_handle.lua | 14 + .../fibaro-co-sensor-zw5/fingerprints.lua | 9 + .../fibaro-co-sensor-zw5/init.lua | 30 +- .../zwave-smoke-co-alarm-v2/fingerprints.lua | 9 + .../src/zwave-smoke-co-alarm-v2/init.lua | 35 +- .../zwave-smoke-co-alarm-v2/sub_drivers.lua | 8 + .../SmartThings/zwave-switch/fingerprints.yml | 121 +- .../profiles/aeotec-heavy-duty.yml | 126 + .../profiles/aeotec-smart-switch-7-eu.yml | 68 + .../profiles/aeotec-smart-switch-7-us.yml | 48 + .../profiles/inovelli-dimmer-power-energy.yml | 24 + .../profiles/inovelli-dimmer-vzw31-sn.yml | 284 ++ .../inovelli-mmwave-dimmer-vzw32-sn.yml | 400 ++ .../zwave-switch/profiles/leviton-zw15s.yml | 19 + .../zwave-switch/profiles/leviton-zw6hd.yml | 99 + .../zwave-switch/profiles/rgbw-bulb.yml | 16 + .../zwave-switch/profiles/zooz-zen05-plug.yml | 90 + .../src/aeon-smart-strip/can_handle.lua | 24 + .../src/aeon-smart-strip/init.lua | 36 +- .../src/aeotec-heavy-duty/can_handle.lua | 18 + .../src/aeotec-heavy-duty/init.lua | 107 + .../src/aeotec-smart-switch/can_handle.lua | 21 + .../src/aeotec-smart-switch/init.lua | 119 +- .../zwave-switch/src/configurations.lua | 15 +- .../src/dawon-smart-plug/can_handle.lua | 24 + .../src/dawon-smart-plug/init.lua | 35 +- .../dawon-wall-smart-switch/can_handle.lua | 21 + .../dawon-wall-smart-switch/fingerprints.lua | 11 + .../src/dawon-wall-smart-switch/init.lua | 41 +- .../src/eaton-5-scene-keypad/can_handle.lua | 19 + .../src/eaton-5-scene-keypad/init.lua | 31 +- .../src/eaton-accessory-dimmer/can_handle.lua | 18 + .../src/eaton-accessory-dimmer/init.lua | 31 +- .../src/eaton-anyplace-switch/can_handle.lua | 19 + .../src/eaton-anyplace-switch/init.lua | 33 +- .../src/ecolink-switch/can_handle.lua | 16 + .../src/ecolink-switch/fingerprints.lua | 10 + .../zwave-switch/src/ecolink-switch/init.lua | 35 +- .../src/fibaro-double-switch/can_handle.lua | 21 + .../src/fibaro-double-switch/init.lua | 33 +- .../src/fibaro-single-switch/can_handle.lua | 20 + .../src/fibaro-single-switch/init.lua | 33 +- .../src/fibaro-wall-plug-us/can_handle.lua | 19 + .../src/fibaro-wall-plug-us/init.lua | 32 +- drivers/SmartThings/zwave-switch/src/init.lua | 58 +- .../can_handle.lua | 16 + .../fingerprints.lua | 13 + .../inovelli-2-channel-smart-plug/init.lua | 38 +- .../zwave-switch/src/inovelli-LED/init.lua | 111 - .../inovelli-LED/inovelli-lzw31sn/init.lua | 112 - .../zwave-switch/src/inovelli/can_handle.lua | 21 + .../zwave-switch/src/inovelli/init.lua | 501 +++ .../src/inovelli/lzw31-sn/can_handle.lua | 19 + .../src/inovelli/lzw31-sn/init.lua | 74 + .../zwave-switch/src/inovelli/sub_drivers.lua | 10 + .../src/inovelli/vzw31-sn/can_handle.lua | 19 + .../src/inovelli/vzw31-sn/init.lua | 77 + .../src/inovelli/vzw32-sn/can_handle.lua | 19 + .../src/inovelli/vzw32-sn/init.lua | 73 + .../zwave-switch/src/lazy_load_subdriver.lua | 18 + .../src/multi-metering-switch/can_handle.lua | 16 + .../multi-metering-switch/fingerprints.lua | 18 + .../src/multi-metering-switch/init.lua | 63 +- .../multi_metering_switch_configurations.lua | 39 +- .../src/multichannel-device/can_handle.lua | 14 + .../src/multichannel-device/init.lua | 28 +- .../zwave-switch/src/preferences.lua | 180 +- .../src/qubino-switches/can_handle.lua | 14 + .../constants/qubino-constants.lua | 15 +- .../src/qubino-switches/fingerprints.lua | 12 + .../zwave-switch/src/qubino-switches/init.lua | 42 +- .../qubino-dimmer/can_handle.lua | 15 + .../qubino-dimmer/fingerprints.lua | 9 + .../qubino-switches/qubino-dimmer/init.lua | 37 +- .../qubino-din-dimmer/can_handle.lua | 12 + .../qubino-dimmer/qubino-din-dimmer/init.lua | 25 +- .../qubino-dimmer/sub_drivers.lua | 8 + .../qubino-relays/can_handle.lua | 19 + .../qubino-switches/qubino-relays/init.lua | 38 +- .../qubino-flush-1-relay/can_handle.lua | 14 + .../qubino-flush-1-relay/init.lua | 23 +- .../qubino-flush-1d-relay/can_handle.lua | 14 + .../qubino-flush-1d-relay/init.lua | 23 +- .../qubino-flush-2-relay/can_handle.lua | 13 + .../qubino-flush-2-relay/init.lua | 23 +- .../qubino-relays/sub_drivers.lua | 9 + .../src/qubino-switches/sub_drivers.lua | 9 + .../zwave-switch/src/sub_drivers.lua | 28 + .../zwave-switch/src/switch_utils.lua | 12 + .../src/test/test_aeon_smart_strip.lua | 77 +- .../src/test/test_aeotec_dimmer_switch.lua | 108 +- ..._aeotec_dual_nano_switch_configuration.lua | 20 +- .../test/test_aeotec_heavy_duty_switch.lua | 523 +++ ...t_aeotec_metering_switch_configuration.lua | 20 +- .../src/test/test_aeotec_nano_dimmer.lua | 108 +- .../test_aeotec_nano_dimmer_preferences.lua | 21 +- .../src/test/test_aeotec_smart_switch.lua | 25 +- ...st_aeotec_smart_switch_7_configuration.lua | 64 - .../test/test_aeotec_smart_switch_7_eu.lua | 231 ++ .../test/test_aeotec_smart_switch_7_us.lua | 200 + .../test/test_aeotec_smart_switch_gen5.lua | 20 +- .../src/test/test_dawon_smart_plug.lua | 21 +- .../src/test/test_dawon_wall_smart_switch.lua | 47 +- .../src/test/test_eaton_5_scene_keypad.lua | 80 +- .../src/test/test_eaton_accessory_dimmer.lua | 56 +- .../src/test/test_eaton_anyplace_switch.lua | 71 +- .../src/test/test_eaton_rf_dimmer.lua | 20 +- .../src/test/test_ecolink_switch.lua | 86 +- .../src/test/test_fibaro_double_switch.lua | 57 +- .../src/test/test_fibaro_single_switch.lua | 84 +- .../src/test/test_fibaro_wall_plug_eu.lua | 20 +- ...test_fibaro_wall_plug_uk_configuration.lua | 20 +- .../src/test/test_fibaro_wall_plug_us.lua | 43 +- .../test_fibaro_walli_dimmer_preferences.lua | 57 +- .../test/test_fibaro_walli_double_switch.lua | 90 +- ...fibaro_walli_double_switch_preferences.lua | 45 +- .../src/test/test_generic_zwave_device1.lua | 91 +- ...go_control_plug_in_switch_configuraton.lua | 20 +- .../src/test/test_honeywell_dimmer.lua | 20 +- .../test_inovelli_2_channel_smart_plug.lua | 69 +- .../src/test/test_inovelli_button.lua | 78 +- .../src/test/test_inovelli_dimmer.lua | 41 +- .../src/test/test_inovelli_dimmer_led.lua | 25 +- .../test_inovelli_dimmer_power_energy.lua | 52 +- .../test/test_inovelli_dimmer_preferences.lua | 45 +- .../src/test/test_inovelli_dimmer_scenes.lua | 68 +- .../src/test/test_inovelli_vzw31_sn.lua | 287 ++ .../src/test/test_inovelli_vzw31_sn_child.lua | 335 ++ .../test_inovelli_vzw31_sn_preferences.lua | 151 + .../src/test/test_inovelli_vzw32_sn.lua | 340 ++ .../src/test/test_inovelli_vzw32_sn_child.lua | 351 ++ .../test_inovelli_vzw32_sn_preferences.lua | 172 + .../src/test/test_multi_metering_switch.lua | 90 +- .../src/test/test_multichannel_device.lua | 514 ++- .../test_popp_outdoor_plug_configuration.lua | 20 +- .../src/test/test_qubino_din_dimmer.lua | 113 +- .../test_qubino_din_dimmer_preferences.lua | 57 +- .../test_qubino_flush_1_relay_preferences.lua | 45 +- ...test_qubino_flush_1d_relay_preferences.lua | 33 +- .../src/test/test_qubino_flush_2_relay.lua | 98 +- .../test_qubino_flush_2_relay_preferences.lua | 45 +- .../src/test/test_qubino_flush_dimmer.lua | 113 +- ..._qubino_flush_dimmer_0_10V_preferences.lua | 57 +- .../test_qubino_flush_dimmer_preferences.lua | 69 +- .../test_qubino_mini_dimmer_preferences.lua | 63 +- ...t_qubino_temperature_sensor_with_power.lua | 77 +- ...ubino_temperature_sensor_without_power.lua | 58 +- .../test_shelly_multi_metering_switch.lua | 731 ++++ .../zwave-switch/src/test/test_wyfy_touch.lua | 59 +- .../test/test_wyfy_touch_configuration.lua | 20 +- .../src/test/test_zooz_double_plug.lua | 82 +- .../src/test/test_zooz_power_strip.lua | 116 +- .../test/test_zooz_zen_30_dimmer_relay.lua | 282 +- ...t_zooz_zen_30_dimmer_relay_preferences.lua | 21 +- .../test/test_zwave_dimmer_power_energy.lua | 102 +- .../src/test/test_zwave_dual_switch.lua | 85 +- .../test/test_zwave_dual_switch_migration.lua | 25 +- .../src/test/test_zwave_switch.lua | 77 +- .../src/test/test_zwave_switch_battery.lua | 24 +- .../test/test_zwave_switch_electric_meter.lua | 35 +- .../test/test_zwave_switch_energy_meter.lua | 40 +- .../src/test/test_zwave_switch_level.lua | 21 +- .../test/test_zwave_switch_power_meter.lua | 35 +- .../src/zooz-power-strip/can_handle.lua | 18 + .../src/zooz-power-strip/init.lua | 31 +- .../zooz-zen-30-dimmer-relay/can_handle.lua | 17 + .../src/zooz-zen-30-dimmer-relay/init.lua | 31 +- .../src/zwave-dual-switch/can_handle.lua | 16 + .../dual_switch_configurations.lua | 15 +- .../src/zwave-dual-switch/fingerprints.lua | 14 + .../src/zwave-dual-switch/init.lua | 62 +- .../aeotec-radiator-thermostat/can_handle.lua | 14 + .../src/aeotec-radiator-thermostat/init.lua | 24 +- .../src/apiv6_bugfix/init.lua | 36 - .../src/ct100-thermostat/can_handle.lua | 15 + .../src/ct100-thermostat/fingerprints.lua | 9 + .../src/ct100-thermostat/init.lua | 33 +- .../src/fibaro-heat-controller/can_handle.lua | 15 + .../fibaro-heat-controller/fingerprints.lua | 9 + .../src/fibaro-heat-controller/init.lua | 31 +- .../SmartThings/zwave-thermostat/src/init.lua | 33 +- .../src/lazy_load_subdriver.lua | 18 + .../popp-radiator-thermostat/can_handle.lua | 14 + .../src/popp-radiator-thermostat/init.lua | 24 +- .../qubino-flush-thermostat/can_handle.lua | 14 + .../qubino-flush-thermostat/fingerprints.lua | 8 + .../src/qubino-flush-thermostat/init.lua | 29 +- .../src/stelpro-ki-thermostat/can_handle.lua | 15 + .../stelpro-ki-thermostat/fingerprints.lua | 8 + .../src/stelpro-ki-thermostat/init.lua | 30 +- .../zwave-thermostat/src/sub_drivers.lua | 14 + .../test/test_aeotec_radiator_thermostat.lua | 36 +- .../src/test/test_ct100_thermostat.lua | 44 +- .../src/test/test_fibaro_heat_controller.lua | 47 +- .../test/test_popp_radiator_thermostat.lua | 30 +- .../src/test/test_qubino_flush_thermostat.lua | 63 +- .../src/test/test_stelpro_ki_thermostat.lua | 36 +- .../test/test_thermostat_heating_battery.lua | 62 +- .../src/test/test_zwave_thermostat.lua | 142 +- .../thermostat-heating-battery/can_handle.lua | 15 + .../fingerprints.lua | 9 + .../src/thermostat-heating-battery/init.lua | 33 +- drivers/SmartThings/zwave-valve/src/init.lua | 22 +- .../src/inverse_valve/can_handle.lua | 11 + .../zwave-valve/src/inverse_valve/init.lua | 20 +- .../zwave-valve/src/lazy_load_subdriver.lua | 18 + .../zwave-valve/src/sub_drivers.lua | 8 + .../src/test/test_inverse.valve.lua | 38 +- .../zwave-valve/src/test/test_zwave_valve.lua | 48 +- .../test_zwave_virtual_momentary_switch.lua | 27 +- .../zwave-window-treatment/fingerprints.yml | 6 + .../window-treatment-preset-reverse.yml | 2 - .../src/aeotec-nano-shutter/can_handle.lua | 14 + .../src/aeotec-nano-shutter/fingerprints.lua | 11 + .../src/aeotec-nano-shutter/init.lua | 32 +- .../iblinds-window-treatment/can_handle.lua | 14 + .../iblinds-window-treatment/fingerprints.lua | 10 + .../src/iblinds-window-treatment/init.lua | 43 +- .../iblinds-window-treatment/sub_drivers.lua | 8 + .../v3/can_handle.lua | 19 + .../v3/fingerprints.lua | 9 + .../{v3.lua => v3/init.lua} | 28 +- .../zwave-window-treatment/src/init.lua | 49 +- .../src/lazy_load_subdriver.lua | 18 + .../src/preferences.lua | 16 +- .../can_handle.lua | 14 + .../fingerprints.lua | 9 + .../src/springs-window-fashion-shade/init.lua | 39 +- .../src/sub_drivers.lua | 11 + .../src/test/test_fibaro_roller_shutter.lua | 191 +- .../src/test/test_qubino_flush_shutter.lua | 381 +- .../test/test_zwave_aeotec_nano_shutter.lua | 74 +- .../test_zwave_iblinds_window_treatment.lua | 168 +- .../test_zwave_springs_window_treatment.lua | 27 +- .../src/test/test_zwave_window_treatment.lua | 143 +- .../window-treatment-venetian/can_handle.lua | 14 + .../fibaro-roller-shutter/can_handle.lua | 14 + .../fibaro-roller-shutter/fingerprints.lua | 8 + .../fibaro-roller-shutter/init.lua | 29 +- .../fingerprints.lua | 10 + .../src/window-treatment-venetian/init.lua | 37 +- .../qubino-flush-shutter/can_handle.lua | 14 + .../qubino-flush-shutter/fingerprints.lua | 9 + .../qubino-flush-shutter/init.lua | 30 +- .../window-treatment-venetian/sub_drivers.lua | 9 + .../src/window_preset_defaults.lua | 55 + drivers/Unofficial/tuya-zigbee/config.yml | 6 + .../Unofficial/tuya-zigbee/fingerprints.yml | 51 + .../tuya-zigbee/profiles/basic-switch.yml | 12 + .../tuya-zigbee/profiles/motion-sensor.yml | 14 + .../profiles/one-button-battery.yml | 14 + .../tuya-zigbee/profiles/smoke-battery.yml | 14 + .../tuya-zigbee/profiles/smoke-detector.yml | 12 + .../tuya-zigbee/profiles/thermostat.yml | 24 + .../profiles/window-treatment-reverse.yml | 24 + .../tuya-zigbee/src/button/init.lua | 86 + .../src/button/meian-button/init.lua | 98 + .../tuya-zigbee/src/curtain/init.lua | 229 ++ drivers/Unofficial/tuya-zigbee/src/init.lua | 37 + .../tuya-zigbee/src/motion-sensor/init.lua | 86 + .../tuya-zigbee/src/smoke-detector/init.lua | 102 + .../tuya-zigbee/src/switch/init.lua | 114 + .../src/test/test_meian_button.lua | 189 + .../tuya-zigbee/src/test/test_tuya_button.lua | 126 + .../src/test/test_tuya_curtain.lua | 411 ++ .../src/test/test_tuya_motion_sensor.lua | 125 + .../src/test/test_tuya_smoke_detector.lua | 190 + .../tuya-zigbee/src/test/test_tuya_switch.lua | 463 +++ .../src/test/test_tuya_thermostat.lua | 545 +++ .../tuya-zigbee/src/thermostat/init.lua | 187 + .../Unofficial/tuya-zigbee/src/tuya_utils.lua | 173 + tools/config.luacov | 47 +- tools/deploy.py | 102 +- tools/fetch_capability_definitions.py | 583 +++ tools/localizations/cn.csv | 112 +- tools/pre-commit | 45 + tools/run_driver_tests.py | 46 +- tools/run_driver_tests_p.py | 12 +- tools/update_min_tags.py | 100 + 3209 files changed, 253488 insertions(+), 27764 deletions(-) create mode 100644 .agents/skills/dev-workflow/SKILL.md create mode 100644 .agents/skills/linting-and-style/SKILL.md create mode 100644 .agents/skills/testing-edge-drivers/SKILL.md create mode 100644 .agents/skills/understanding-lua-libraries/SKILL.md create mode 100644 .agents/skills/understanding-profiles/SKILL.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/scripts/check_profile_categories.py create mode 100644 .github/workflows/check-profile-categories-comment.yml create mode 100644 .github/workflows/check-profile-categories.yml create mode 100644 .github/workflows/jenkins-driver-tests.yml create mode 100644 AGENTS.md create mode 100644 drivers/ABB/insite-scu200/config.yml create mode 100644 drivers/ABB/insite-scu200/profiles/auxiliary-contact.yml create mode 100644 drivers/ABB/insite-scu200/profiles/bridge.yml create mode 100644 drivers/ABB/insite-scu200/profiles/current-sensor-consumption.yml create mode 100644 drivers/ABB/insite-scu200/profiles/current-sensor-production.yml create mode 100644 drivers/ABB/insite-scu200/profiles/energy-meter.yml create mode 100644 drivers/ABB/insite-scu200/profiles/gas-meter.yml create mode 100644 drivers/ABB/insite-scu200/profiles/output-module.yml create mode 100644 drivers/ABB/insite-scu200/profiles/usb-energy-meter.yml create mode 100644 drivers/ABB/insite-scu200/profiles/water-meter.yml create mode 100644 drivers/ABB/insite-scu200/search-parameters.yaml create mode 100644 drivers/ABB/insite-scu200/src/abb/api.lua create mode 100644 drivers/ABB/insite-scu200/src/abb/device_manager.lua create mode 100644 drivers/ABB/insite-scu200/src/abb/device_refresher.lua create mode 100644 drivers/ABB/insite-scu200/src/commands.lua create mode 100644 drivers/ABB/insite-scu200/src/config.lua create mode 100644 drivers/ABB/insite-scu200/src/connection_monitor.lua create mode 100644 drivers/ABB/insite-scu200/src/discovery.lua create mode 100644 drivers/ABB/insite-scu200/src/eventsource_handler.lua create mode 100644 drivers/ABB/insite-scu200/src/fields.lua create mode 100644 drivers/ABB/insite-scu200/src/init.lua create mode 100644 drivers/ABB/insite-scu200/src/lifecycles.lua create mode 100644 drivers/ABB/insite-scu200/src/lunchbox/init.lua create mode 100644 drivers/ABB/insite-scu200/src/lunchbox/rest.lua create mode 100644 drivers/ABB/insite-scu200/src/lunchbox/sse/eventsource.lua create mode 100644 drivers/ABB/insite-scu200/src/lunchbox/util.lua create mode 100644 drivers/ABB/insite-scu200/src/utils.lua create mode 100644 drivers/Aqara/aqara-bath-heater/config.yml create mode 100644 drivers/Aqara/aqara-bath-heater/fingerprints.yml create mode 100644 drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml create mode 100644 drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua create mode 100644 drivers/Aqara/aqara-bath-heater/src/init.lua create mode 100644 drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua create mode 100644 drivers/Aqara/aqara-lock/capabilities/antiLockStatus.yaml create mode 100644 drivers/Aqara/aqara-lock/capabilities/lockAlarm.yaml create mode 100644 drivers/Aqara/aqara-lock/src/test/test_aqara_lock_L100.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/config.yml create mode 100644 drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-fallDetection.yml create mode 100644 drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-sleepMonitoring.yml create mode 100644 drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-zoneDetection.yml create mode 100644 drivers/Aqara/aqara-presence-sensor/search-parameters.yml create mode 100644 drivers/Aqara/aqara-presence-sensor/src/discovery.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/discovery_mdns.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/fields.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/fp2/api.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/fp2/device_manager.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/fp2/discovery_helper.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/init.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/lunchbox/init.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/lunchbox/rest.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/lunchbox/util.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/multipleZonePresence.lua create mode 100644 drivers/Aqara/aqara-presence-sensor/src/selfSignedRootByAqaraLife.crt rename drivers/{SmartThings/philips-hue => Aqara/aqara-presence-sensor}/src/utils.lua (59%) create mode 100644 drivers/DeepSmart/deepsmart/CLAUDE.md create mode 100644 drivers/DeepSmart/deepsmart/config.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Ac.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Curtain.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Heater.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Hue.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Light.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Newfan.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Slider.yml create mode 100644 drivers/DeepSmart/deepsmart/search-parameters.yml create mode 100644 drivers/DeepSmart/deepsmart/src/commands.lua create mode 100644 drivers/DeepSmart/deepsmart/src/config.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/api.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua create mode 100644 drivers/DeepSmart/deepsmart/src/discovery.lua create mode 100644 drivers/DeepSmart/deepsmart/src/init.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lifecycles.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/init.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/util.lua create mode 100644 drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt create mode 100644 drivers/DeepSmart/deepsmart/src/ssdp.lua create mode 100644 drivers/DeepSmart/deepsmart/src/utils.lua create mode 100644 drivers/SinuxSoft/britzyhub/README.md create mode 100644 drivers/SinuxSoft/britzyhub/config.yml create mode 100644 drivers/SinuxSoft/britzyhub/fingerprints.yml create mode 100644 drivers/SinuxSoft/britzyhub/profiles/elevator.yml create mode 100644 drivers/SinuxSoft/britzyhub/profiles/gas-valve.yml create mode 100644 drivers/SinuxSoft/britzyhub/profiles/vent.yml create mode 100644 drivers/SinuxSoft/britzyhub/src/elevator/init.lua create mode 100644 drivers/SinuxSoft/britzyhub/src/gas-valve/init.lua create mode 100644 drivers/SinuxSoft/britzyhub/src/init.lua create mode 100644 drivers/SinuxSoft/britzyhub/src/vent/init.lua delete mode 100644 drivers/SmartThings/harman-luxury/src/handlers.lua create mode 100644 drivers/SmartThings/harman-luxury/src/hl_websocket.lua create mode 100644 drivers/SmartThings/harman-luxury/src/lustre/ws.lua create mode 100644 drivers/SmartThings/hub/config.yml create mode 100644 drivers/SmartThings/hub/fingerprints.yml create mode 100644 drivers/SmartThings/hub/profiles/cooktop-hub.yml create mode 100644 drivers/SmartThings/hub/profiles/hub-thing.yml create mode 100644 drivers/SmartThings/hub/profiles/refrigerator-hub.yml create mode 100644 drivers/SmartThings/hub/profiles/soundbar-hub.yml create mode 100644 drivers/SmartThings/hub/profiles/tv-hub.yml create mode 100644 drivers/SmartThings/hub/profiles/v4-hub.yml create mode 100644 drivers/SmartThings/hub/profiles/washer-dryer-hub.yml create mode 100644 drivers/SmartThings/hub/src/init.lua create mode 100644 drivers/SmartThings/matter-appliance/config.yml create mode 100644 drivers/SmartThings/matter-appliance/fingerprints.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/cook-surface-one.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/cook-top.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/dishwasher-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/dishwasher-tn-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/dishwasher-tn.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/dishwasher.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind-light.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/extractor-hood.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-dryer.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-washer-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/laundry-washer.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/microwave-oven.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn-tl.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn.yml create mode 100644 drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer.yml create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Mask.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/State.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Supported.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/commands/ModifyEnabledAlarms.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/AlarmMap.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/Condition.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/ChangeIndicationEnum.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/NumberOfRinses.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeedCurrent.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeeds.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SupportedRinses.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/NumberOfRinsesEnum.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/OnMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/StartUpMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/CookTime.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/MaxCookTime.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/AddMoreTime.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/SetCookingParameters.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CountdownTime.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CurrentPhase.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalError.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalState.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalStateList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/PhaseList.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Pause.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Resume.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Start.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Stop.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/events/OperationalError.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/server/events/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateEnum.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateEnum.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Mask.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/State.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Supported.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/AlarmMap.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MaxTemperature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MinTemperature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SelectedTemperatureLevel.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/Step.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SupportedTemperatureLevels.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/TemperatureSetpoint.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/SetTemperature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/types/Feature.lua create mode 100644 drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/common-utils.lua create mode 100644 drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua create mode 100644 drivers/SmartThings/matter-appliance/src/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-cook-top/can_handle.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-dishwasher/can_handle.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-extractor-hood/can_handle.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-laundry/can_handle.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-microwave-oven/can_handle.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-oven/can_handle.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-oven/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-refrigerator/can_handle.lua create mode 100644 drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua create mode 100644 drivers/SmartThings/matter-appliance/src/sub_drivers.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_oven.lua create mode 100644 drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua delete mode 100644 drivers/SmartThings/matter-button/fingerprints.yml create mode 100644 drivers/SmartThings/matter-button/profiles/5-button-battery.yml create mode 100644 drivers/SmartThings/matter-button/profiles/5-button.yml create mode 100644 drivers/SmartThings/matter-energy/config.yml create mode 100644 drivers/SmartThings/matter-energy/fingerprints.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/battery-storage.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/evse-energy-meas-energy-mgmt-mode.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/evse-energy-meas-power-meas-energy-mgmt-mode.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/evse-energy-meas.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/evse-power-meas-energy-mgmt-mode.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/evse-power-meas.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/evse.yml create mode 100644 drivers/SmartThings/matter-energy/profiles/solar-power.yml create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyExported.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyExported.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/ActivePower.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/PowerMode.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/PowerModeEnum.lua create mode 100644 drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ApproximateEVEfficiency.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/BatteryCapacity.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ChargingEnabledUntil.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/CircuitCapacity.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/DischargingEnabledUntil.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/EventList.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/FaultState.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumChargeCurrent.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumDischargeCurrent.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MinimumChargeCurrent.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeRequiredEnergy.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeStartTime.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetSoC.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetTime.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/RandomizationDelayWindow.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionDuration.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyCharged.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyDischarged.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionID.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/State.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/StateOfCharge.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SupplyState.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/UserMaximumChargeCurrent.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/VehicleID.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/ClearTargets.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/Disable.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableCharging.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableDischarging.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/GetTargets.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/SetTargets.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/StartDiagnostics.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVConnected.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVNotDetected.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStarted.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStopped.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Fault.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Rfid.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetScheduleStruct.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetStruct.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/EnergyTransferStoppedReasonEnum.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/FaultStateEnum.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/Feature.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/StateEnum.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/SupplyStateEnum.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/TargetDayOfWeekBitmap.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvse/types/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/embedded_cluster_utils.lua create mode 100644 drivers/SmartThings/matter-energy/src/init.lua create mode 100644 drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua create mode 100644 drivers/SmartThings/matter-energy/src/test/test_evse.lua create mode 100644 drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua create mode 100644 drivers/SmartThings/matter-energy/src/test/test_solar_power.lua create mode 100644 drivers/SmartThings/matter-hrap/config.yml create mode 100644 drivers/SmartThings/matter-hrap/fingerprints.yml create mode 100644 drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml create mode 100644 drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua create mode 100644 drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua create mode 100644 drivers/SmartThings/matter-hrap/src/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua create mode 100755 drivers/SmartThings/matter-lock/profiles/base-lock-batteryLevel.yml create mode 100755 drivers/SmartThings/matter-lock/profiles/base-lock-nobattery.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-battery.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-batteryLevel.yml create mode 100755 drivers/SmartThings/matter-lock/profiles/lock-lockalarm-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-lockalarm-nobattery.yml create mode 100755 drivers/SmartThings/matter-lock/profiles/lock-lockalarm.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-modular.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-unlatch-battery.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-unlatch-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-unlatch.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-battery.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-battery.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-battery.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin-unlatch.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-pin.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-schedule-unlatch.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-schedule.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user-unlatch.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock-user.yml create mode 100755 drivers/SmartThings/matter-lock/profiles/lock-without-codes-batteryLevel.yml create mode 100755 drivers/SmartThings/matter-lock/profiles/lock-without-codes-nobattery.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/lock.yml create mode 100644 drivers/SmartThings/matter-lock/profiles/nonfunctional-lock-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetCredentialStatusResponse.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetUserResponse.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetWeekDayScheduleResponse.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetYearDayScheduleResponse.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/client/commands/SetCredentialResponse.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/client/commands/init.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/init.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/ActuatorEnabled.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroBLEAdvertisingVersion.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroExpeditedTransactionSupportedProtocolVersions.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroGroupResolvingKey.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupIdentifier.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupSubIdentifier.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderVerificationKey.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/DoorState.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/EventList.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/LockState.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MaxPINCodeLength.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MinPINCodeLength.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroCredentialIssuerKeysSupported.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroEndpointKeysSupported.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfCredentialsSupportedPerUser.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfPINUsersSupported.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfTotalUsersSupported.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfWeekDaySchedulesSupportedPerUser.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfYearDaySchedulesSupportedPerUser.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/OperatingMode.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/RequirePINforRemoteOperation.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SendPINOverTheAir.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SupportedOperatingModes.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/UserCodeTemporaryDisableTime.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/WrongCodeEntryLimit.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearAliroReaderConfig.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearCredential.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearUser.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearWeekDaySchedule.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearYearDaySchedule.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetCredentialStatus.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetUser.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetWeekDaySchedule.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetYearDaySchedule.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/LockDoor.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetAliroReaderConfig.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetCredential.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetUser.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetWeekDaySchedule.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetYearDaySchedule.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/UnlockDoor.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorLockAlarm.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorStateChange.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperation.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperationError.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockUserChange.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/server/events/init.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/AlarmCodeEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialRuleEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialStruct.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialTypeEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DataOperationTypeEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DaysMaskMap.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadOperationEventMask.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadProgrammingEventMask.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlLocalProgrammingFeatures.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlLockState.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlManualOperationEventMask.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDOperationEventMask.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDProgrammingEventMask.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteOperationEventMask.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteProgrammingEventMask.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlStatus.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DlSupportedOperatingModes.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockDayOfWeek.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockOperationEventCode.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockProgrammingEventCode.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockSetPinOrIdStatus.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserStatus.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserType.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/DoorStateEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/Feature.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/LockDataTypeEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/LockOperationTypeEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/OperatingModeEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/OperationErrorEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/OperationSourceEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/UserStatusEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/UserTypeEnum.lua create mode 100644 drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua create mode 100644 drivers/SmartThings/matter-lock/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/matter-lock/src/sub_drivers.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_matter_lock_migration.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_aliro.lua create mode 100644 drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua create mode 100644 drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/can_handle.lua create mode 100644 drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/init.lua create mode 100644 drivers/SmartThings/matter-pump/config.yml create mode 100644 drivers/SmartThings/matter-pump/fingerprints.yml rename drivers/SmartThings/{matter-switch/profiles/switch-2-level.yml => matter-pump/profiles/pump-level.yml} (56%) create mode 100644 drivers/SmartThings/matter-pump/profiles/pump-only.yml create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/init.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/ControlMode.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveControlMode.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveOperationMode.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/OperationMode.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/PumpStatus.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/Speed.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/ControlModeEnum.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/Feature.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/OperationModeEnum.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/PumpStatusBitmap.lua create mode 100644 drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/init.lua create mode 100644 drivers/SmartThings/matter-pump/src/embedded-cluster-utils.lua create mode 100644 drivers/SmartThings/matter-pump/src/init.lua create mode 100644 drivers/SmartThings/matter-rvc/capabilities/robotCleanerOperatingState.yml create mode 100644 drivers/SmartThings/matter-rvc/config.yml create mode 100644 drivers/SmartThings/matter-rvc/fingerprints.yml create mode 100644 drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode-service-area.yml create mode 100644 drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode.yml create mode 100644 drivers/SmartThings/matter-rvc/profiles/rvc-service-area.yml create mode 100644 drivers/SmartThings/matter-rvc/profiles/rvc.yml create mode 100644 drivers/SmartThings/matter-rvc/src/Global/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/Global/types/AreaTypeTag.lua create mode 100644 drivers/SmartThings/matter-rvc/src/Global/types/LandmarkTag.lua create mode 100644 drivers/SmartThings/matter-rvc/src/Global/types/LocationDescriptorStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/Global/types/RelativePositionTag.lua create mode 100644 drivers/SmartThings/matter-rvc/src/Global/types/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/OperationalState/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateEnum.lua create mode 100644 drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateEnum.lua create mode 100644 drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/OperationalState/types/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/ChangeToModeResponse.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/StatusCode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalError.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalState.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalStateList.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/GoHome.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Pause.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Resume.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Start.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Stop.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/OperationalError.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateEnum.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateEnum.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/ChangeToModeResponse.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/OnMode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/StartUpMode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/types/StatusCode.lua create mode 100644 drivers/SmartThings/matter-rvc/src/RvcRunMode/types/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/SelectAreasResponse.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SelectedAreas.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SupportedAreas.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/server/commands/SelectAreas.lua rename drivers/SmartThings/{matter-sensor/src/PressureMeasurement => matter-rvc/src/ServiceArea}/server/commands/init.lua (75%) rename drivers/SmartThings/{matter-sensor/src/PressureMeasurement => matter-rvc/src/ServiceArea}/server/events/init.lua (78%) create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaInfoStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/types/Feature.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/types/LandmarkInfoStruct.lua create mode 100644 drivers/SmartThings/matter-rvc/src/ServiceArea/types/SelectAreasStatus.lua rename drivers/SmartThings/{matter-sensor/src/PressureMeasurement => matter-rvc/src/ServiceArea}/types/init.lua (73%) create mode 100644 drivers/SmartThings/matter-rvc/src/embedded_cluster_utils.lua create mode 100644 drivers/SmartThings/matter-rvc/src/init.lua create mode 100644 drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-level.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-modular-humidity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp-humidity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-modular.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level-all-meas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-meas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm1-pm25-pm10-meas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm25-tvoc-meas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-level-pm25-meas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-meas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqs.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/co-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/co-comeas-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/co-comeas-colevel-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/co-comeas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/co.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/contact-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/contact-button-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/flow-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/flow-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/flow.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault-freezeSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-battery-freezeSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault-freezeSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-freezeSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-fault-freezeSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze-leak-fault-freezeSensitivity-leakSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/freeze.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/humidity-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/illuminance-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-battery-fault-leakSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-battery-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-battery-leakSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault-leakSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-leakSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-fault-leakSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/leak.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel-illuminance.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/motion-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/motion-contact-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/motion-illuminance-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/presence-illuminance-temperature-humidity-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/pressure-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-battery-fault-rainSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-battery-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-battery-rainSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault-rainSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-rainSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-fault-rainSensitivity.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain-fault.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/rain.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/sensor-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-co-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-co.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke-temp-humidity-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/smoke.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/soil-sensor-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/soil-sensor-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/soil-sensor.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/temperature-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/temperature-humidity-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-battery.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor.yml create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/EventList.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/Feature.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/CurrentSensitivityLevel.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/DefaultSensitivityLevel.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SensorFault.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SupportedSensitivityLevels.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/Feature.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/SensorFaultBitmap.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/LevelValueEnum.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyRangeStruct.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyStruct.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/init.lua (82%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/AcceptedCommandList.lua (85%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/AttributeList.lua (84%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/EventList.lua (84%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/MaxMeasuredValue.lua (83%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/MaxScaledValue.lua (83%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/MeasuredValue.lua (82%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/MinMeasuredValue.lua (83%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/MinScaledValue.lua (83%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/Scale.lua (82%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/ScaledTolerance.lua (83%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/ScaledValue.lua (82%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/Tolerance.lua (82%) rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/server/attributes/init.lua (66%) create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/events/init.lua rename drivers/SmartThings/matter-sensor/src/{ => embedded_clusters}/PressureMeasurement/types/Feature.lua (73%) create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/types/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/BatteryAlert.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/COState.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/HardwareFaultAlert.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeSensitivityLevel.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeState.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/TestInProgress.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/SelfTestRequest.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/AlarmStateEnum.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/Feature.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/SensitivityEnum.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasurementLimits.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sensor_utils/device_configuration.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/legacy_device_configuration.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/can_handle.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua 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/sub_drivers/bosch_button_contact/can_handle.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/bosch_button_contact/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/can_handle.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua create mode 100644 drivers/SmartThings/matter-switch/capabilities/cubeAction.yml create mode 100644 drivers/SmartThings/matter-switch/capabilities/cubeFace.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/2-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/2-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/2-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/3-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/3-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/3-button-motion.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/3-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/4-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/4-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/4-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/5-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/5-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/5-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/6-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/6-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/6-button-motion.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/6-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/7-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/7-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/7-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/8-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/8-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/8-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/9-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/9-button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/9-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/button-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/camera.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/cube-t1-pro.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/fan-modular.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/garage-door-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/ikea-2-button-battery.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/irrigation-system.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-battery-illuminance-motion-temperature.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-button.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-motion.yml create mode 100755 drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-level-power.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/light-power.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/plug-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml rename drivers/SmartThings/matter-switch/profiles/{m5stack.yml => plug-level-power.yml} (63%) create mode 100644 drivers/SmartThings/matter-switch/profiles/plug-power-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/plug-power.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/water-valve-level.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/water-valve.yml create mode 100644 drivers/SmartThings/matter-switch/profiles/window-covering.yml create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/DeviceTypeList.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/DeviceTypeStruct.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/AvailableEndpoints.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/Feature.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentState.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Close.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Open.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/Feature.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/ValveStateEnum.lua create mode 100644 drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua rename drivers/SmartThings/matter-switch/src/{eve-energy => sub_drivers/eve_energy}/init.lua (76%) create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua create mode 100644 drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua create mode 100644 drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua create mode 100644 drivers/SmartThings/matter-switch/src/switch_handlers/event_handlers.lua rename drivers/SmartThings/matter-switch/src/{ => switch_utils}/color_utils.lua (88%) create mode 100644 drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua create mode 100644 drivers/SmartThings/matter-switch/src/switch_utils/embedded_cluster_utils.lua create mode 100644 drivers/SmartThings/matter-switch/src/switch_utils/fields.lua create mode 100644 drivers/SmartThings/matter-switch/src/switch_utils/utils.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_button.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_on_off_device_configuration.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_on_off_parent_child.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua delete mode 100644 drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua delete mode 100644 drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_stateless_step.lua create mode 100644 drivers/SmartThings/matter-switch/src/test/test_third_reality_subdrivers.lua create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-aqs.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-temperature-humidity-fan-aqs-pm25-tvoc-meas.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-thermostat-humidity-fan-nostate-nobattery-aqs-pm25-tvoc-meas.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs-pm25-meas.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind-aqs-pm25-meas-pm25-level.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-temperature-humidity-aqs.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-humidity-aqs.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-nostate-humidity-aqs.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/air-purifier.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/fan-generic.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/fan-rock-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/fan-rock.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/fan-wind.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/fan.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat-humidity.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat-humidity.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/heat-pump.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling-nostate.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-wind-heating-cooling.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-heating-cooling.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-fan-heating-cooling.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-heating-cooling.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-modular.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nobattery.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nobattery.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-nobattery.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/thermostat-nostate-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/water-heater-power-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-thermostat/profiles/water-heater.yml create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/ResetCondition.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/Feature.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/EventList.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/Feature.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/ChangeIndication.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/Condition.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/ResetCondition.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/ChangeIndicationEnum.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/Feature.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/CurrentMode.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/SupportedModes.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/ChangeToMode.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/Feature.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeOptionStruct.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTag.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTagStruct.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/init.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/thermostat_utils/embedded_cluster_utils.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/thermostat_utils/legacy_device_configuration.lua create mode 100644 drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua create mode 100644 drivers/SmartThings/matter-window-covering/profiles/covering.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/door.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/garage-door.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/gate.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml create mode 100644 drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AcceptedCommandList.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AttributeList.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CountdownTime.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CurrentErrorList.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/LatchControlModes.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/MainState.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallCurrentState.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallTargetState.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Calibrate.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/MoveTo.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Stop.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/ClosureErrorEnum.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/CurrentPositionEnum.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/Feature.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/LatchControlModesBitmap.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/MainStateEnum.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallCurrentStateStruct.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallTargetStateStruct.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/TargetPositionEnum.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/CurrentState.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/LimitRange.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/StepValue.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/TargetState.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/SetTarget.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/Step.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/DimensionStateStruct.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/Feature.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/StepDirectionEnum.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/TranslationDirectionEnum.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/TagList.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/Feature.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/SemanticTagStruct.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/ThreeLevelAutoEnum.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/can_handle.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/attribute_handlers.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/capability_handlers.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/embedded_cluster_utils.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/fields.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/utils.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/init.lua create mode 100644 drivers/SmartThings/matter-window-covering/src/test/test_matter_closure.lua create mode 100644 drivers/SmartThings/philips-hue/profiles/4-button-remote.yml create mode 100644 drivers/SmartThings/philips-hue/profiles/contact-sensor.yml create mode 100644 drivers/SmartThings/philips-hue/profiles/motion-sensor.yml create mode 100644 drivers/SmartThings/philips-hue/profiles/plug.yml create mode 100644 drivers/SmartThings/philips-hue/profiles/single-button.yml create mode 100644 drivers/SmartThings/philips-hue/profiles/two-button.yml create mode 100755 drivers/SmartThings/philips-hue/run_specs.sh create mode 100644 drivers/SmartThings/philips-hue/src/consts.lua delete mode 100644 drivers/SmartThings/philips-hue/src/disco.lua create mode 100644 drivers/SmartThings/philips-hue/src/disco/button.lua create mode 100644 drivers/SmartThings/philips-hue/src/disco/contact.lua create mode 100644 drivers/SmartThings/philips-hue/src/disco/init.lua create mode 100644 drivers/SmartThings/philips-hue/src/disco/light.lua create mode 100644 drivers/SmartThings/philips-hue/src/disco/motion.lua delete mode 100644 drivers/SmartThings/philips-hue/src/handlers.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/commands.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/bridge.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/button.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/init.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/migration_handlers/bridge.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua create mode 100644 drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua delete mode 100644 drivers/SmartThings/philips-hue/src/hue/types.lua create mode 100644 drivers/SmartThings/philips-hue/src/hue_debug/init.lua create mode 100644 drivers/SmartThings/philips-hue/src/hue_device_types.lua create mode 100644 drivers/SmartThings/philips-hue/src/hue_driver_template.lua create mode 100644 drivers/SmartThings/philips-hue/src/logjam.lua create mode 100644 drivers/SmartThings/philips-hue/src/stray_device_helper.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/spec/device_faker.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/spec/fakers/bridge_faker.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/spec/fakers/driver_faker.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/spec/fakers/light_faker.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/spec/test_helpers/init.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/spec/unit/utils_spec.lua create mode 100644 drivers/SmartThings/philips-hue/src/types.lua create mode 100644 drivers/SmartThings/philips-hue/src/utils/batched_command_utils.lua rename drivers/SmartThings/philips-hue/src/{hue => utils}/cie_utils.lua (83%) create mode 100644 drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua create mode 100644 drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua create mode 100644 drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/init.lua create mode 100644 drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/sensor.lua create mode 100644 drivers/SmartThings/philips-hue/src/utils/init.lua create mode 100644 drivers/SmartThings/philips-hue/src/utils/kv_counter.lua create mode 100644 drivers/SmartThings/sonos/capabilities/softwareGeneration.yaml create mode 100644 drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua create mode 100644 drivers/SmartThings/sonos/src/cosock/bus.lua create mode 100644 drivers/SmartThings/sonos/src/cosock/stream.lua create mode 100644 drivers/SmartThings/sonos/src/lifecycle_handlers.lua create mode 100644 drivers/SmartThings/sonos/src/result.lua create mode 100644 drivers/SmartThings/sonos/src/sonos_driver.lua create mode 100644 drivers/SmartThings/sonos/src/sonos_state.lua create mode 100644 drivers/SmartThings/sonos/src/token_refresher.lua create mode 100755 drivers/SmartThings/zigbee-air-quality-detector/config.yml create mode 100755 drivers/SmartThings/zigbee-air-quality-detector/fingerprints.yml create mode 100755 drivers/SmartThings/zigbee-air-quality-detector/profiles/air-quality-detector-MultiIR.yml create mode 100755 drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/custom_clusters.lua create mode 100755 drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/init.lua create mode 100755 drivers/SmartThings/zigbee-air-quality-detector/src/init.lua create mode 100644 drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua create mode 100755 drivers/SmartThings/zigbee-bed/capabilities/aiMode.yaml create mode 100755 drivers/SmartThings/zigbee-bed/capabilities/autoInflation.yaml create mode 100755 drivers/SmartThings/zigbee-bed/capabilities/leftControl.yaml create mode 100755 drivers/SmartThings/zigbee-bed/capabilities/mattressHardness.yaml create mode 100755 drivers/SmartThings/zigbee-bed/capabilities/rightControl.yaml create mode 100755 drivers/SmartThings/zigbee-bed/capabilities/strongExpMode.yaml create mode 100755 drivers/SmartThings/zigbee-bed/capabilities/yoga.yaml create mode 100755 drivers/SmartThings/zigbee-bed/config.yml create mode 100755 drivers/SmartThings/zigbee-bed/fingerprints.yml create mode 100755 drivers/SmartThings/zigbee-bed/profiles/shus-smart-mattress.yml create mode 100755 drivers/SmartThings/zigbee-bed/src/init.lua create mode 100644 drivers/SmartThings/zigbee-bed/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-bed/src/shus-mattress/can_handle.lua create mode 100755 drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua create mode 100755 drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua create mode 100644 drivers/SmartThings/zigbee-bed/src/shus-mattress/fingerprints.lua create mode 100755 drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua create mode 100644 drivers/SmartThings/zigbee-bed/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua create mode 100644 drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons-mode.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/aqara-knob-switch.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/aqara-single-button-mode.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/button-profile-frient.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/button-profile-panic-frient.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/eight-buttons-temp-battery.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/four-buttons-without-main-button.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/one-button-battery-no-fw-update.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/one-button-batteryLevel.yml create mode 100644 drivers/SmartThings/zigbee-button/profiles/two-buttons-no-fw-update.yml create mode 100644 drivers/SmartThings/zigbee-button/src/MultiIR/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/MultiIR/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/MultiIR/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/aqara-knob/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/aqara-knob/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/dimming-remote/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/dimming-remote/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/ewelink/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/ewelink/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/ezviz/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/ezviz/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/iris/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/iris/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-button/src/pushButton/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/samjin/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_aqara_knob_switch.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_multiir_smart_button.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_sonoff_button.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua create mode 100644 drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua create mode 100644 drivers/SmartThings/zigbee-button/src/thirdreality/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch/can_handle.lua rename drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/{TRADFRI_on_off_switch.lua => TRADFRI_on_off_switch/init.lua} (55%) delete mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control/can_handle.lua rename drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/{TRADFRI_remote_control.lua => TRADFRI_remote_control/init.lua} (79%) create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1/can_handle.lua rename drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/{somfy_situo_1.lua => somfy_situo_1/init.lua} (78%) create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4/can_handle.lua rename drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/{somfy_situo_4.lua => somfy_situo_4/init.lua} (83%) create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/init.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/init.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/profiles/frient-smoke-co-temperature-battery.yml create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/init.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_frient_co_smoke_temperature_battery.lua create mode 100644 drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-battery.yml create mode 100644 drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-contact-battery.yml create mode 100644 drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper-no-fw-update.yml create mode 100644 drivers/SmartThings/zigbee-contact/profiles/contact-battery-temperature-tamper.yml create mode 100644 drivers/SmartThings/zigbee-contact/profiles/contact-batteryLevel.yml create mode 100644 drivers/SmartThings/zigbee-contact/profiles/frient-contact-battery-temperature.yml create mode 100644 drivers/SmartThings/zigbee-contact/src/MultiIR/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/MultiIR/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/MultiIR/init.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/fingerprints.lua delete mode 100644 drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/frient-sensor/init.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/init.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/frient/init.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/frient/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/multi-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/multi-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/multi-sensor/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/sengled/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/sengled/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/smartsense-multi/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/smartsense-multi/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua delete mode 100644 drivers/SmartThings/zigbee-contact/src/test/test_frient_sensor.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/test/test_frient_vibration_sensor.lua create mode 100644 drivers/SmartThings/zigbee-contact/src/test/test_multiir_contact_tamper.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-fan/src/fan-light/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-fan/src/fan-light/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-fan/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-fan/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-humidity-temperature-battery.yml create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/init.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua create mode 100644 drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-illuminance-sensor/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-illuminance-sensor/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua rename drivers/SmartThings/zigbee-lock/src/{ => legacy-handlers}/lock-without-codes/init.lua (65%) create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua rename drivers/SmartThings/zigbee-lock/src/{ => legacy-handlers}/yale/init.lua (86%) create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/lock_utils.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/sub_drivers.lua rename drivers/SmartThings/zigbee-lock/src/test/{test_yale_fingerprint_bad_battery_reporter.lua => test_bad_battery_reporter.lua} (52%) rename drivers/SmartThings/zigbee-lock/src/test/{test_generic_lock_migration.lua => test_generic_fingerprint_profile_update.lua} (50%) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua rename drivers/SmartThings/zigbee-lock/src/test/{test_zigbee_lock.lua => test_zigbee_lock_v10.lua} (96%) create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua rename drivers/SmartThings/zigbee-lock/src/test/{test_zigbee_yale.lua => test_zigbee_yale_legacy.lua} (65%) create mode 100644 drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua delete mode 100644 drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-battery.yml create mode 100644 drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-battery.yml create mode 100644 drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-tamper-battery.yml create mode 100644 drivers/SmartThings/zigbee-motion-sensor/profiles/motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/init.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/aqara/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/aurora/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/centralite/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/compacta/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/frient/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/ikea/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/ikea/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/iris/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/iris/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/nyce/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/nyce/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/samjin/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/sengled/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/sengled/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/smartsense/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/smartthings/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/st/zigbee/zdo/init.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua delete mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_sensor.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/test/test_multiir_motion_pir.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-battery-consumption-report.yml create mode 100644 drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-consumption-report.yml create mode 100644 drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-current-voltage.yml create mode 100644 drivers/SmartThings/zigbee-power-meter/profiles/frient-power-meter-consumption-report.yml create mode 100644 drivers/SmartThings/zigbee-power-meter/profiles/power-meter-1p.yml create mode 100644 drivers/SmartThings/zigbee-power-meter/profiles/power-meter-2p.yml create mode 100644 drivers/SmartThings/zigbee-power-meter/profiles/power-meter-3p.yml create mode 100644 drivers/SmartThings/zigbee-power-meter/src/bituo/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/configurations.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/ezex/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/ezex/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/init.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/frient/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/frient/utils.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/shinasystems/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/shinasystems/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_battery_consumption_report.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_consumption_report.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_current_voltage.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p_ZM.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p_ZM.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p_ZM.lua rename drivers/SmartThings/zigbee-power-meter/src/test/{test_zigbee_power_meter_consumption_report.lua => test_zigbee_power_meter_ezex.lua} (57%) create mode 100644 drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_frient.lua create mode 100644 drivers/SmartThings/zigbee-presence-sensor/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-presence-sensor/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-presence-sensor/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-range-extender/profiles/range-extender-battery-source.yml create mode 100644 drivers/SmartThings/zigbee-range-extender/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-range-extender/src/frient/init.lua create mode 100644 drivers/SmartThings/zigbee-range-extender/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua create mode 100644 drivers/SmartThings/zigbee-sensor/config.yml create mode 100644 drivers/SmartThings/zigbee-sensor/fingerprints.yml create mode 100644 drivers/SmartThings/zigbee-sensor/profiles/generic-contact-sensor.yml create mode 100755 drivers/SmartThings/zigbee-sensor/profiles/generic-motion-illuminance.yml create mode 100644 drivers/SmartThings/zigbee-sensor/profiles/generic-motion-sensor.yml create mode 100644 drivers/SmartThings/zigbee-sensor/profiles/generic-sensor.yml create mode 100644 drivers/SmartThings/zigbee-sensor/profiles/generic-waterleak-sensor.yml create mode 100755 drivers/SmartThings/zigbee-sensor/src/contact/init.lua create mode 100644 drivers/SmartThings/zigbee-sensor/src/init.lua create mode 100755 drivers/SmartThings/zigbee-sensor/src/motion-illuminance/init.lua create mode 100755 drivers/SmartThings/zigbee-sensor/src/motion/init.lua create mode 100644 drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua create mode 100755 drivers/SmartThings/zigbee-sensor/src/waterleak/init.lua create mode 100644 drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source-tamper.yml create mode 100644 drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source.yml create mode 100644 drivers/SmartThings/zigbee-siren/profiles/switch-alarm-tamper-warningduration-volume-no-fw-update.yml create mode 100644 drivers/SmartThings/zigbee-siren/src/MultiIR/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/MultiIR/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/MultiIR/init.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/ozom/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/test/test_frient_siren_tamper.lua create mode 100644 drivers/SmartThings/zigbee-siren/src/test/test_multiir_zigbee_siren_tamper.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/profiles/heat-temp-battery-alarm.yml create mode 100644 drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-battery-tamper-no-fw-update.yml create mode 100644 drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-temp-battery-alarm.yml create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/init.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/init.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua create mode 100644 drivers/SmartThings/zigbee-smoke-detector/src/test/test_multiir_smoke_detector.lua create mode 100644 drivers/SmartThings/zigbee-switch/profiles/aqara-switch-module-no-power.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/aqara-switch-no-power.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/frient-switch-power-energy-voltage.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/light-color-temp-time-restore.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml 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 100755 drivers/SmartThings/zigbee-switch/profiles/switch-smart-bath-heater-laisiao.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara-light/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara/version/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/init.lua delete mode 100644 drivers/SmartThings/zigbee-switch/src/configurations.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/configurations/devices.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/configurations/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/ezex/can_handle.lua 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/firstled-light/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-light/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-light/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/frient/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/frient/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/ge-link-bulb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/hanssem/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/hanssem/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/can_handle.lua rename drivers/SmartThings/zigbee-switch/src/{zll-dimmer-bulb => }/ikea-xy-color-bulb/init.lua (84%) create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/common.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/jasco/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua create mode 100755 drivers/SmartThings/zigbee-switch/src/laisiao/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/device_added.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/find_child.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/info_changed.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/rexense/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/rgb-bulb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/rgbw-bulb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/rgbw-bulb/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/robb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/sinope-dimmer/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/sinope/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/stateless_handlers/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/switch_utils.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_firstled_light.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_ledvance_metering_plug.lua mode change 100755 => 100644 drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua delete mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_zigbee_dimmer_power_energy.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/wallhero/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/wallhero/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/enbrighten-metering-dimmer/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zll-polling/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/capabilities/invisibleCapabilities.yaml create mode 100644 drivers/SmartThings/zigbee-thermostat/capabilities/valveCalibration.yaml create mode 100644 drivers/SmartThings/zigbee-thermostat/profiles/thermostat-aqara.yml create mode 100644 drivers/SmartThings/zigbee-thermostat/profiles/thermostat-thirty-buttons-wallhero.yml create mode 100644 drivers/SmartThings/zigbee-thermostat/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/danfoss/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/danfoss/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/fidure/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/leviton/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/lux-konoz/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/lux-konoz/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/popp/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/popp/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/resideo_korea/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sinope/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/fingerprints.lua rename drivers/SmartThings/zigbee-thermostat/src/stelpro/{stelpro_maestrostat.lua => stelpro_maestrostat/init.lua} (67%) create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/fingerprints.lua rename drivers/SmartThings/zigbee-thermostat/src/stelpro/{stelpro_sorb.lua => stelpro_sorb/init.lua} (66%) create mode 100644 drivers/SmartThings/zigbee-thermostat/src/stelpro/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat_wallhero_3in1.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/vimar/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/wallhero/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/wallhero/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/wallhero/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/zenwithin/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/ezex/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/ezex/init.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/sinope/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/profiles/water-battery-no-fw-update.yml create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/frient/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-watering-kit/config.yml create mode 100644 drivers/SmartThings/zigbee-watering-kit/fingerprints.yml create mode 100644 drivers/SmartThings/zigbee-watering-kit/profiles/watering-kit-thirdreality.yml create mode 100644 drivers/SmartThings/zigbee-watering-kit/src/init.lua create mode 100644 drivers/SmartThings/zigbee-watering-kit/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-watering-kit/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua create mode 100644 drivers/SmartThings/zigbee-watering-kit/src/thirdreality/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/capabilities/chargingState.yaml create mode 100644 drivers/SmartThings/zigbee-window-treatment/capabilities/hookLockState.yaml create mode 100755 drivers/SmartThings/zigbee-window-treatment/profiles/projector-screen-VWSDSTUST120H.yml create mode 100755 drivers/SmartThings/zigbee-window-treatment/profiles/window-shade-only.yml create mode 100644 drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml create mode 100644 drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/can_handle.lua create mode 100755 drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/fingerprints.lua create mode 100755 drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/can_handle.lua create mode 100755 drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/fingerprints.lua create mode 100755 drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/aqara/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/aqara/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/aqara/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/aqara/version/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/axis/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/axis/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/feibit/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/feibit/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/hanssem/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua mode change 100755 => 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/can_handle.lua create mode 100644 drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/can_handle.lua create mode 100644 drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/can_handle.lua create mode 100644 drivers/SmartThings/zwave-bulb/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-bulb/src/sub_drivers.lua delete mode 100644 drivers/SmartThings/zwave-button/src/apiv6_bugfix/init.lua create mode 100644 drivers/SmartThings/zwave-button/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-button/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/can_handle.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/can_handle.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/can_handle.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/can_handle.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/can_handle.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua create mode 100644 drivers/SmartThings/zwave-button/src/zwave-multi-button/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-con.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-pro.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-con.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-pro.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-con.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-pro.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-con.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-pro.yml create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeon-meter/can_handle.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeon-meter/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/can_handle.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/can_handle.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/init.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/can_handle.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/init.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/can_handle.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/init.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/can_handle.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/init.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/power_consumption.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/preferences.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/qubino-meter/can_handle.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/qubino-meter/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua create mode 100644 drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua create mode 100644 drivers/SmartThings/zwave-fan/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-fan/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/can_handle.lua create mode 100644 drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/can_handle.lua create mode 100644 drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/can_handle.lua create mode 100644 drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-garage-door-opener/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/can_handle.lua create mode 100644 drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-garage-door-opener/src/sub_drivers.lua delete mode 100644 drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua create mode 100644 drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua create mode 100644 drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-lock/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua rename drivers/SmartThings/zwave-lock/src/test/{test_samsung_lock.lua => test_samsung_lock_legacy.lua} (91%) rename drivers/SmartThings/zwave-lock/src/test/{test_schlage_lock.lua => test_schlage_lock_legacy.lua} (92%) delete mode 100644 drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua rename drivers/SmartThings/zwave-lock/src/test/{test_zwave_lock.lua => test_zwave_lock_legacy.lua} (94%) create mode 100644 drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua create mode 100644 drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/profiles/shelly-wave-motion.yml create mode 100644 drivers/SmartThings/zwave-sensor/profiles/zooz-zse42-water.yml create mode 100644 drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/fingerprints.lua delete mode 100644 drivers/SmartThings/zwave-sensor/src/apiv6_bugfix/init.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/firmware-version/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/firmware-version/init.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/sensative-strip/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/test/test_firmware_version.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/v1-contact-event/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/vision-motion-detector/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml create mode 100644 drivers/SmartThings/zwave-siren/src/aeon-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/fingerprints.lua delete mode 100644 drivers/SmartThings/zwave-siren/src/apiv6_bugfix/init.lua create mode 100644 drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-siren/src/fortrezz/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-siren/src/multifunctional-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/multifunctional-siren/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-siren/src/philio-sound-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/philio-sound-siren/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-siren/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua create mode 100644 drivers/SmartThings/zwave-siren/src/utilitech-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/yale-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/zipato-siren/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua create mode 100644 drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/fingerprints.lua delete mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/apiv6_bugfix/init.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/can_handle.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/can_handle.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/can_handle.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/can_handle.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-switch/profiles/aeotec-heavy-duty.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-eu.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-us.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/inovelli-mmwave-dimmer-vzw32-sn.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/leviton-zw15s.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/leviton-zw6hd.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/rgbw-bulb.yml create mode 100644 drivers/SmartThings/zwave-switch/profiles/zooz-zen05-plug.yml create mode 100644 drivers/SmartThings/zwave-switch/src/aeon-smart-strip/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/init.lua create mode 100644 drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/dawon-smart-plug/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/ecolink-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/ecolink-switch/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-switch/src/fibaro-double-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/fibaro-single-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/fingerprints.lua delete mode 100644 drivers/SmartThings/zwave-switch/src/inovelli-LED/init.lua delete mode 100644 drivers/SmartThings/zwave-switch/src/inovelli-LED/inovelli-lzw31sn/init.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/init.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/init.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/init.lua create mode 100644 drivers/SmartThings/zwave-switch/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-switch/src/multi-metering-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/multi-metering-switch/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-switch/src/multichannel-device/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-switch/src/qubino-switches/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-switch/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-switch/src/switch_utils.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua delete mode 100644 drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_configuration.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua create mode 100644 drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua create mode 100644 drivers/SmartThings/zwave-switch/src/zooz-power-strip/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/zwave-dual-switch/can_handle.lua create mode 100644 drivers/SmartThings/zwave-switch/src/zwave-dual-switch/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/can_handle.lua delete mode 100644 drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/init.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/can_handle.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/can_handle.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/can_handle.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/can_handle.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/can_handle.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua mode change 100755 => 100644 drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua mode change 100755 => 100644 drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/can_handle.lua create mode 100644 drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-valve/src/inverse_valve/can_handle.lua create mode 100644 drivers/SmartThings/zwave-valve/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-valve/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/can_handle.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/can_handle.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/can_handle.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/fingerprints.lua rename drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/{v3.lua => v3/init.lua} (81%) create mode 100644 drivers/SmartThings/zwave-window-treatment/src/lazy_load_subdriver.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/can_handle.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/can_handle.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/can_handle.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/can_handle.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/fingerprints.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/sub_drivers.lua create mode 100644 drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua create mode 100644 drivers/Unofficial/tuya-zigbee/config.yml create mode 100644 drivers/Unofficial/tuya-zigbee/fingerprints.yml create mode 100644 drivers/Unofficial/tuya-zigbee/profiles/basic-switch.yml create mode 100644 drivers/Unofficial/tuya-zigbee/profiles/motion-sensor.yml create mode 100644 drivers/Unofficial/tuya-zigbee/profiles/one-button-battery.yml create mode 100644 drivers/Unofficial/tuya-zigbee/profiles/smoke-battery.yml create mode 100644 drivers/Unofficial/tuya-zigbee/profiles/smoke-detector.yml create mode 100644 drivers/Unofficial/tuya-zigbee/profiles/thermostat.yml create mode 100644 drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml create mode 100644 drivers/Unofficial/tuya-zigbee/src/button/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/button/meian-button/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/curtain/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/motion-sensor/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/smoke-detector/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/switch/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/test/test_meian_button.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/test/test_tuya_button.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/test/test_tuya_motion_sensor.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/test/test_tuya_smoke_detector.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/test/test_tuya_thermostat.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/thermostat/init.lua create mode 100644 drivers/Unofficial/tuya-zigbee/src/tuya_utils.lua create mode 100644 tools/fetch_capability_definitions.py create mode 100755 tools/pre-commit create mode 100644 tools/update_min_tags.py diff --git a/.agents/skills/dev-workflow/SKILL.md b/.agents/skills/dev-workflow/SKILL.md new file mode 100644 index 0000000000..23f4ce17df --- /dev/null +++ b/.agents/skills/dev-workflow/SKILL.md @@ -0,0 +1,264 @@ +--- +name: dev-workflow +description: Setting up the development environment, deploying Edge Drivers to hubs, and sharing drivers with other users via channels and invites +--- + +# SmartThings Edge Driver Development Workflow + +This skill covers environment setup, driver deployment to hubs, and sharing +drivers with other users through channels and invite links. + +--- + +## Environment Setup + +### 1. Install Lua 5.3 + +Edge Drivers are Lua-based. Install the Lua 5.3 runtime for local development +and linting: + +```bash +# Ubuntu / Debian +sudo apt install lua5.3 + +# macOS +brew install lua@5.3 + +# Windows +# Download the Lua 5.3 binary from https://luabinaries.sourceforge.net/download.html +# Or install via scoop: +scoop install lua +# Or via chocolatey: +choco install lua53 +``` + +### 2. lua_libs Directory + +The `lua_libs/` directory contains the SmartThings Lua libraries that are +available on the hub at runtime. These correspond to the assets attached to the +latest release on GitHub: + + + +Download the lua_libs archive from the release assets and +extract it into the repository root if it is missing or needs updating. + +### 3. Configure LUA_PATH + +Set `LUA_PATH` so that `require` resolves both your driver modules and the +SmartThings library modules in `lua_libs/`: + +```bash +export LUA_PATH="./?.lua;./?/init.lua;$(pwd)/lua_libs/?.lua;$(pwd)/lua_libs/?/init.lua;;" +``` + +Run it from the repository root so `$(pwd)` resolves correctly. + + +### 4. Install the SmartThings CLI + +The CLI is required for packaging, deploying, and managing drivers and +channels on the platform. + +```bash +# Via npm (requires Node.js >= 24.8.0) +npm install -g @smartthings/cli + +# macOS via Homebrew +brew install smartthingscommunity/smartthings/smartthings + +# Linux / Windows +# Download the binary or installer from: +# https://github.com/SmartThingsCommunity/smartthings-cli/releases +``` + +Verify the installation: + +```bash +smartthings --version +``` + +The CLI uses browser-based OAuth login by default. Run `smartthings devices` to trigger +the login flow. + +### 5. Python Requirements (Testing) + +Some test and tooling scripts require Python dependencies: + +```bash +pip install -r tools/requirements.txt +``` + +### 6. Install Luacheck (Linting) + +Luacheck provides static analysis for Lua source files. It requires LuaRocks +(the Lua package manager). + +**Install LuaRocks first:** + +```bash +# Ubuntu / Debian +sudo apt install luarocks + +# macOS +brew install luarocks + +# Windows +# Download the installer from https://luarocks.org/releases/ +# Or via chocolatey: +choco install luarocks +``` + +**Then install Luacheck:** + +```bash +# Via LuaRocks (all platforms) +luarocks install luacheck + +# macOS alternative (installs both luarocks and luacheck) +brew install luacheck +``` + +Run it against a driver directory: + +```bash +luacheck --config .github/workflows/.luacheckrc drivers/SmartThings/zigbee-switch/ +``` + +--- + +## Deploying Drivers + +### Overview + +Deploying a driver to a physical hub requires three things: + +1. A **channel** you own. +2. The hub **enrolled** in that channel. +3. The driver **packaged and uploaded** through the CLI. + +### Step 1: Create a Channel + +```bash +smartthings edge:channels:create +``` + +You will be prompted for a name and description. Note the returned channel ID. + +### Step 2: Enroll Your Hub + +```bash +smartthings edge:channels:enroll +``` + +Select the channel when prompted, or pass `--channel `. + +Find your hub ID with: + +```bash +smartthings devices --type HUB +``` + +### Step 3: Package and Install the Driver + +The `edge:drivers:package` command can build, upload, assign to a channel, and +install in one step: + +```bash +smartthings edge:drivers:package \ + --hub= \ + --channel= +``` + +For example: + +```bash +smartthings edge:drivers:package drivers/SmartThings/zwave-switch \ + --hub=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee \ + --channel=11111111-2222-3333-4444-555555555555 +``` + +### Other Useful Deployment Commands + +```bash +# List drivers installed on a hub +smartthings edge:drivers:installed --hub= + +# Stream logs from a driver on the hub +smartthings edge:drivers:logcat --hub= + +# Uninstall a driver from a hub +smartthings edge:drivers:uninstall --hub= + +# Remove unused drivers from a hub +smartthings edge:drivers:prune --hub= + +# Switch a device to a different driver +smartthings edge:drivers:switch +``` + +--- + +## Sharing Drivers + +### Creating an Invite Link + +Invite links let other users install your driver from your channel without +giving them ownership of the driver or channel. + +```bash +smartthings edge:channels:invites:create +``` + +You will be prompted to select a channel and a driver. The command returns an +invite URL of the form: + +``` +https://bestow-regional.api.smartthings.com/invite/ +``` + +Share this URL with users. They open it in a browser or the SmartThings mobile +app to accept the invitation. + +### Enrollment Flow for Recipients + +1. The recipient opens the invite link. +2. They log in to their Samsung / SmartThings account. +3. They select a hub to enroll in the channel. +4. The driver can be selected to install to that hub. + +### Managing Invites + +```bash +# List existing invites +smartthings edge:channels:invites + +# Delete an invite +smartthings edge:channels:invites:delete +``` + +### Managing Channel Assignments + +```bash +# Assign a specific driver version to a channel +smartthings edge:channels:assign + +# List drivers assigned to a channel +smartthings edge:channels:drivers + +# Remove a driver from a channel +smartthings edge:channels:unassign +``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Create channel | `smartthings edge:channels:create` | +| Enroll hub | `smartthings edge:channels:enroll ` | +| Package & deploy | `smartthings edge:drivers:package --hub= --channel=` | +| Stream logs | `smartthings edge:drivers:logcat --hub=` | +| Create invite | `smartthings edge:channels:invites:create` | +| List installed drivers | `smartthings edge:drivers:installed --hub=` | diff --git a/.agents/skills/linting-and-style/SKILL.md b/.agents/skills/linting-and-style/SKILL.md new file mode 100644 index 0000000000..83578854ce --- /dev/null +++ b/.agents/skills/linting-and-style/SKILL.md @@ -0,0 +1,96 @@ +--- +name: linting-and-style +description: Running luacheck for Lua linting and following code style conventions in Edge Driver development +--- + +# Linting and Code Style for Edge Drivers + +## Running Luacheck + +```bash +luacheck --config .github/workflows/.luacheckrc +``` + +### Examples + +```bash +# Lint a specific driver +luacheck --config .github/workflows/.luacheckrc drivers/SmartThings/zigbee-switch/ + +# Lint a single file +luacheck --config .github/workflows/.luacheckrc drivers/SmartThings/zigbee-switch/src/init.lua + +# Lint the entire repo +luacheck --config .github/workflows/.luacheckrc . +``` + +Luacheck runs automatically in CI on pull requests that modify files under `drivers/` (see `.github/workflows/luacheck.yml`). + +## Code Style Conventions + +These conventions are observed across the Edge Driver codebase: + +### General + +- **Indentation**: 2 spaces, no tabs +- **Strings**: Use double quotes `"string"` for module requires and general strings +- **Local variables**: Always use `local` for variables and functions at module scope +- **Line length**: No enforced limit, but most code stays under 120 characters + +### Naming + +- **Variables and functions**: `snake_case` (e.g., `local mock_device`, `local function test_init()`) +- **Constants**: `UPPER_SNAKE_CASE` for true constants (e.g., `SENSOR_BINARY`) +- **Modules**: Return a table at the end of the file (`return module_name`) + +### Requires and Imports + +```lua +-- Standard library requires first +local capabilities = require "st.capabilities" +local zw = require "st.zwave" + +-- Then test/integration requires +local test = require "integration_test" +local t_utils = require "integration_test.utils" + +-- Then protocol-specific requires +local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) +``` + +### Function Style + +- Prefer `local function name()` over `local name = function()` +- Handler functions typically receive `(driver, device, ...)` arguments +- Use early returns for guard clauses + +### Tables + +- Trailing commas are common and acceptable in multi-line tables +- Align table entries for readability in test manifests + +### Comments + +- Use `--` for single-line comments +- Minimal inline comments; code should be self-documenting + +Copyright header at the top of every file: +```lua +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +``` + +### File Organization for Drivers + +``` +driver-name/ + src/ + init.lua -- Main driver entry point + .lua -- Additional driver modules + test/ + test_*.lua -- Test files (must start with test_) + profiles/ + *.yml -- Device profiles + fingerprints.yml -- Device fingerprints + config.yml -- Driver configuration +``` diff --git a/.agents/skills/testing-edge-drivers/SKILL.md b/.agents/skills/testing-edge-drivers/SKILL.md new file mode 100644 index 0000000000..a34a22c901 --- /dev/null +++ b/.agents/skills/testing-edge-drivers/SKILL.md @@ -0,0 +1,349 @@ +--- +name: testing-edge-drivers +description: Running and writing integration tests for SmartThings Edge Drivers using the Python test harness and Lua integration test framework +--- + +# Testing SmartThings Edge Drivers + +## Running Tests + +Tests are run via the Python test harness: + +```bash +python3 tools/run_driver_tests.py [options] +``` + +### Options + +| Flag | Description | +|------|-------------| +| `-v` | Print individual test names and pass/fail status | +| `-vv` | Print test names, status, and full logs on failures (recommended) | +| `-vvv` | Print all logs from all tests | +| `-f ` | Only run tests whose file path matches the regex filter | +| `-j ` | Output JUnit XML results to the specified file | +| `-c [files]` | Run with luacov code coverage | +| `--html` | Generate HTML coverage reports (use with `-c`) | + +### Filter Examples + +```bash +# Run all tests for a specific driver +python3 tools/run_driver_tests.py -vv -f "zwave-smoke-alarm" + +# Run a specific test file +python3 tools/run_driver_tests.py -vv -f "test_zwave_smoke_detector" + +# Run all zigbee switch tests +python3 tools/run_driver_tests.py -vv -f "zigbee-switch" + +# Run all virtual device tests +python3 tools/run_driver_tests.py -vv -f "virtual" +``` + +The filter is a regex applied to the full file path. The harness searches for files matching `drivers/*/*/src/test/test_*.lua`. + +### Python Requirements + +Install dependencies before running tests: + +```bash +pip install -r tools/requirements.txt +``` + +Required packages: `junit_xml`, `requests`, `PyYAML`, `regex`. + +### How Tests Execute + +The Python harness (`tools/run_driver_tests.py`): +1. Globs for all `test_*.lua` files under `drivers/*/src/test/` +2. Filters by the `-f` regex if provided +3. Changes directory to the driver's `src/` directory (two levels up from the test file) +4. Runs each test file with `lua ` +5. Parses stdout for `Running test`, `PASSED`, `FAILED`, and summary lines +6. Reports totals and exits with code 1 if any tests failed + +## Integration Test Framework + +The framework lives in `lua_libs/integration_test/` and is required as `integration_test` in test files. It provides: + +### Core Modules + +| Module | Purpose | +|--------|---------| +| `integration_test` (init.lua) | Main test runner, registration, mock device builder | +| `integration_test.utils` | Utility functions like `get_profile_definition()` | +| `integration_test.mock_device` | Build mock Zigbee, Z-Wave, Matter, or generic devices | +| `integration_test.zwave_test_utils` | Z-Wave specific helpers (e.g., `zwave_test_build_receive_command`) | +| `integration_test.zigbee_test_utils` | Zigbee specific helpers | +| `integration_test.mock_socket` | Mock socket layer with channel-based message routing | + +### Channels + +The test framework uses channels to simulate communication between the driver and the platform: + +- `zwave` - Z-Wave protocol messages +- `zigbee` - Zigbee protocol messages +- `matter` - Matter protocol messages +- `capability` - SmartThings capability events (commands from cloud, events to cloud) +- `device_lifecycle` - Device lifecycle events (init, added, removed, etc.) +- `driver_lifecycle` - Driver lifecycle events +- `timer` - Timer-related events + +Each channel supports two directions: +- `receive` - Messages sent TO the driver (incoming commands, device reports) +- `send` - Messages sent FROM the driver (capability events, protocol commands) + +## Writing Tests + +### Test File Structure + +Every test file follows this pattern: + +```lua +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +-- 1. Build mock device(s) +local mock_device = test.mock_device.build_test_generic_device({ + profile = t_utils.get_profile_definition("my-profile.yml"), +}) + +-- 2. Define test init function (runs before each test) +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- 3. Register tests (message tests or coroutine tests) + +-- 4. Run all registered tests +test.run_registered_tests() +``` + +### Building Mock Devices + +```lua +-- Generic device (no protocol) +local mock = test.mock_device.build_test_generic_device({ + profile = t_utils.get_profile_definition("profile-name.yml"), + preferences = { ["certifiedpreferences.somePref"] = true }, +}) + +-- Z-Wave device +local zw = require "st.zwave" +local mock = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("profile-name.yml"), + zwave_endpoints = { + { + command_classes = { + { value = zw.SENSOR_BINARY }, + { value = zw.NOTIFICATION }, + } + } + } +}) + +-- Zigbee device +local mock = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("profile-name.yml"), + zigbee_endpoints = { ... } +}) +``` + +### Message Tests (`register_message_test`) + +Message tests define an ordered sequence of receive/send message pairs. Each receive triggers the driver handler, and the subsequent sends are the expected outputs. + +```lua +test.register_message_test( + "Test description", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 -- optional version constraint + } +) +``` + +The manifest is an array of message entries. The framework groups them into blocks: each block starts with a `receive` followed by zero or more `send` entries. The receives are queued on the mock channel; the sends are set as expectations. The driver processes the receive and the framework asserts the expected sends occurred. + +### Coroutine Tests (`register_coroutine_test`) + +For more complex test logic (multiple interactions, state changes, conditional assertions, timer manipulation): + +```lua +test.register_coroutine_test( + "Test with complex logic", + function() + -- Queue a lifecycle event + test.socket.device_lifecycle():__queue_receive({ mock_device.id, "init" }) + test.socket.device_lifecycle():__queue_receive( + mock_device:generate_info_changed({ + preferences = { ["certifiedpreferences.somePref"] = false } + }) + ) + test.wait_for_events() + + -- Now send a capability command and expect a response + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.switch.switch.on()) + ) + end, + { + min_api_version = 17 + } +) +``` + +Key coroutine test APIs: +- `test.socket.:__queue_receive(msg)` - Queue a message for the driver to receive +- `test.socket.:__expect_send(msg)` - Set an expectation for a message the driver should send +- `test.wait_for_events()` - Yield to let the driver process queued messages and check expectations +- `test.mock_time.advance_time(seconds)` - Advance the mock clock + +### Real Example: Z-Wave Smoke Detector Test + +From `drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua`: + +```lua +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" + +local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) + +local sensor_endpoints = { + { + command_classes = { + { value = zw.SENSOR_BINARY }, + { value = zw.SENSOR_ALARM }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("smoke-battery-temperature-tamperalert-temperaturealarm.yml"), + zwave_endpoints = sensor_endpoints +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Sensor Binary report (smoke) should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + zw_test_utils.zwave_test_build_receive_command( + SensorBinary:Report({ + sensor_type = SensorBinary.sensor_type.SMOKE, + sensor_value = SensorBinary.sensor_value.DETECTED_AN_EVENT + }) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.smokeDetector.smoke.detected()) + } + }, + { min_api_version = 17 } +) + +test.run_registered_tests() +``` + +## Common Test Patterns + +### Testing Capability Commands (cloud -> device) + +Receive on `capability` channel, expect protocol message on `zwave`/`zigbee`/`matter`: + +```lua +{ + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } } +}, +{ + channel = "zwave", + direction = "send", + message = ... -- expected Z-Wave command +} +``` + +### Testing Device Reports (device -> cloud) + +Receive on protocol channel, expect capability event on `capability`: + +```lua +{ + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(...) } +}, +{ + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) +} +``` + +### Testing Lifecycle Events + +```lua +test.socket.device_lifecycle():__queue_receive({ mock_device.id, "added" }) +test.socket.device_lifecycle():__queue_receive({ mock_device.id, "init" }) +test.socket.device_lifecycle():__queue_receive({ mock_device.id, "doConfigure" }) +``` + +### Testing Preference Changes + +```lua +test.socket.device_lifecycle():__queue_receive( + mock_device:generate_info_changed({ + preferences = { ["certifiedpreferences.myPref"] = new_value } + }) +) +``` + +### Optional Test Parameters + +The `opts` table passed to `register_message_test` or `register_coroutine_test` supports: + +| Field | Description | +|-------|-------------| +| `min_api_version` | Skip test if API version is below this (commonly set to 17) | +| `max_api_version` | Skip test if API version is above this | +| `test_init` | Per-test init function (overrides the global `set_test_init_function`) | +| `expected_error` | String or array of Lua patterns for expected errors | +| `inner_block_ordering` | Set to `"relaxed"` to allow sends in any order within a block | diff --git a/.agents/skills/understanding-lua-libraries/SKILL.md b/.agents/skills/understanding-lua-libraries/SKILL.md new file mode 100644 index 0000000000..235f637b69 --- /dev/null +++ b/.agents/skills/understanding-lua-libraries/SKILL.md @@ -0,0 +1,295 @@ +--- +name: understanding-lua-libraries +description: Understanding the SmartThings Edge Driver Lua libraries - driver lifecycle, message dispatchers, default handlers, and protocol message objects +--- + +# SmartThings Edge Driver Lua Library Architecture + +## 1. Driver Initialization and Run Loop + +A driver is created by calling `Driver("name", template)` (or a protocol-specific variant like `ZigbeeDriver("name", template)`). The template is a Lua table containing handler tables and configuration. + +The base `Driver.init` (in `lua_libs/st/driver.lua`) does the following: +- Sets `out_driver.NAME` from the name argument +- Initializes handler tables: `capability_handlers`, `lifecycle_handlers`, `message_handlers` +- Opens communication channels via cosock sockets: `capability_channel`, `environment_channel`, `lifecycle_channel`, `driver_lifecycle_channel`, and optionally `discovery_channel` +- Initializes a datastore and device cache tables +- Calls `Driver.standardize_sub_drivers()` to normalize the `sub_drivers` list +- Builds the `lifecycle_dispatcher` and `capability_dispatcher` from handlers + sub_drivers +- Registers channel handlers so inbound messages get routed to the correct handler function + +The `driver:run()` call starts the cosock event loop, which runs forever processing messages from all registered channels. + +## 2. Message Dispatchers + +The dispatcher system (`lua_libs/st/dispatcher.lua`) is a hierarchical message routing tree. The base class `MessageDispatcher` provides: + +- **`default_handlers`** - handlers at this level of the hierarchy. +- **`child_dispatchers`** - sub-dispatchers (from sub_drivers) that may override defaults +- **`can_handle(driver, device, ...)`** - returns true if this dispatcher or a child can handle the message +- **`dispatch(driver, device, ...)`** - finds and executes the matching handler + +### Dispatch logic + +1. The dispatcher calls `can_handle` on each child dispatcher +2. If any children can handle: **only the children handle it** (parent defaults are NOT called) +3. If multiple children match: ALL matching children receive the message +4. If NO children match: parent defaults are used +5. This is recursive -- sub-drivers can have sub-drivers + +### Dispatcher types + +| Dispatcher | Class | Handles | +|------------|-------|---------| +| `capability_dispatcher` | `CapabilityCommandDispatcher` | Capability commands from the platform (on, off, setLevel, etc.) | +| `lifecycle_dispatcher` | `DeviceLifecycleDispatcher` | Device lifecycle events (added, init, removed, etc.) | +| `zigbee_message_dispatcher` | `ZigbeeMessageDispatcher` | Incoming Zigbee messages (attribute reports, cluster commands, ZDO) | +| `zwave_dispatcher` | `ZwaveDispatcher` | Incoming Z-Wave commands | +| `matter_dispatcher` | `MatterMessageDispatcher` | Incoming Matter interaction responses | +| `secret_data_dispatcher` | `SecretDataDispatcher` | Security/secret data events | + +Each protocol-specific driver (ZigbeeDriver, ZwaveDriver, MatterDriver) adds its own dispatcher on top of the base Driver's capability and lifecycle dispatchers. + +**Zigbee handler structure:** +```lua +zigbee_handlers = { + attr = { -- attribute reports / read responses + [ClusterID] = { + [AttributeID] = handler_function, + } + }, + global = { -- global ZCL commands + [ClusterID] = { + [CommandID] = handler_function, + } + }, + cluster = { -- cluster-specific commands + [ClusterID] = { + [CommandID] = handler_function, + } + }, + zdo = { -- ZDO commands + [ClusterID] = handler_function, + } +} +``` + +**Z-Wave handler structure:** +```lua +zwave_handlers = { + [cc.SWITCH_BINARY] = { -- command class + [SwitchBinary.REPORT] = handler_function, -- command ID + }, +} +``` + +**Matter handler structure:** +```lua +matter_handlers = { + attr = { + [ClusterID] = { + [AttributeID] = handler_function, + } + }, + cmd_response = { ... }, + event = { ... }, + fallback = handler_function, +} +``` + +**Capability handler structure:** +```lua +capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_on, + [capabilities.switch.commands.off.NAME] = handle_off, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level, + }, +} +``` + +## 3. Sub-Drivers Pattern + +Sub-drivers allow device-specific behavior overrides gated by a `can_handle` function. A sub-driver is a table with: +- `NAME` (string) +- `can_handle(opts, driver, device, ...) -> boolean` +- Protocol handlers (zigbee_handlers, zwave_handlers, matter_handlers) +- `capability_handlers`, `lifecycle_handlers` +- Optional nested `sub_drivers` + +In practice, sub-drivers are often organized as separate files under `src/sub_drivers/` for clarity, and required in the main driver template. + + +### Dispatch Logic + +1. The dispatcher calls `can_handle` on each child dispatcher +2. If any children can handle: **only the children handle it** (parent defaults are NOT called) +3. If multiple children match: ALL matching children receive the message +4. If NO children match: parent defaults are used +5. This is recursive -- sub-drivers can have sub-drivers + +### Lazy Loading + +Sub-drivers support lazy loading for memory optimization: +- `Driver.lazy_load_sub_driver(sub_driver)`: Strips handlers, keeps only `can_handle` and `NAME` +- `Driver.lazy_load_sub_driver_v2(require_path)`: Even more efficient; only requires `can_handle` and `sub_drivers` modules separately +- A sub-driver with no handlers defined is automatically treated as lazy-loadable + +New sub-drivers must be: +1. Listed in the parent's `sub_drivers.lua` (or the equivalent sub_drivers table) +2. Have a `can_handle.lua` that correctly identifies the target devices +3. Have an `init.lua` that returns the sub-driver table + +If any of these are missing, the sub-driver will not be loaded. + + +## 4. Lifecycle Events + +Device lifecycle events are dispatched through the `DeviceLifecycleDispatcher`. The key events: + +1. **`init`** -- Called for every device on driver startup (existing devices) and after `added` for new devices. Used for setting up component/endpoint mappings and device fields. +2. **`added`** -- Called only when a device is first paired. NOT called for existing devices when a driver is updated. After `added`, a synthetic `init` is automatically dispatched. +3. **`doConfigure`** -- Called when the device needs configuration (typically after pairing). +4. **`infoChanged`** -- Called when device metadata changes (e.g., preferences updated). Receives `args.old_st_store` for comparison. +5. **`removed`** -- Called when device is removed. +6. **`driverSwitched`** -- Called when device switches to this driver. + +Register lifecycle handlers in the template: +```lua +lifecycle_handlers = { + init = device_init, + added = device_added, + removed = device_removed, + doConfigure = device_do_configure, + infoChanged = info_changed_handler, +} +``` + +Handler signature: `function(driver, device, event, args)` + +**Default behaviors provided by the framework:** +- `driverSwitched`: Base Driver marks device as `NONFUNCTIONAL`. ZigbeeDriver overrides this to check capability matching and marks as `PROVISIONED` if all capabilities match. +- `doConfigure`: ZigbeeDriver defaults to `device_management.configure` which sends attribute reporting configuration. +- `added`: After a successful `added` callback, the framework automatically queues a synthetic `init` event. +- `doConfigure`: After success, the framework transitions the device to `PROVISIONED` state. +- Unhandled lifecycle events log a trace message and are otherwise ignored (fallback handler). + +**Critical timing knowledge for lifecycle events** + +1. **`init` on driver startup**. After hub restart the radio may not be ready and sending Zigbee/Z-Wave commands in `init` can fail. +2. **`added` is NOT called for existing devices** on driver update. Only called on first pair. Code that must run for existing devices should go in `init` (for non-radio operations) or use `driverSwitched`. +3. **`doConfigure` is called any time a device is added with the TYPED provisioning state** and is the right place for device-specific configuration commands. +4. **`infoChanged` receives `args.old_st_store`** for comparing old vs new preferences. Drivers should check if a preference actually changed before acting on it. + +## 5. Key Imports and Require Paths + +```lua +-- Base driver (for virtual/LAN devices) +local Driver = require "st.driver" + +-- Protocol-specific drivers +local ZigbeeDriver = require "st.zigbee" +local ZwaveDriver = require "st.zwave.driver" +local MatterDriver = require "st.matter.driver" + +-- Capabilities +local capabilities = require "st.capabilities" + +-- Zigbee defaults (pre-built handlers for common capabilities) +local defaults = require "st.zigbee.defaults" + +-- Zigbee clusters (for building commands/reading attributes) +local zcl_clusters = require "st.zigbee.zcl" + +-- Z-Wave command classes +local cc = require "st.zwave.CommandClass" +local SwitchBinary = require "st.zwave.CommandClass.SwitchBinary" + +-- Matter clusters +local clusters = require "st.matter.clusters" + +-- Utilities +local utils = require "st.utils" +local json = require "st.json" +local log = require "log" + +-- Coroutine runtime +local cosock = require "cosock" + +-- LAN utils +local socket = cosock.socket +local luncheon = require "luncheon" +local luxure = require "luxure" +local lustre = require "lustre" +``` + +### Zigbee driver example (from zigbee-switch) + +```lua +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" + +local template = { + supported_capabilities = { + capabilities.switch, + capabilities.switchLevel, + capabilities.colorControl, + capabilities.colorTemperature, + }, + sub_drivers = require("sub_drivers"), + lifecycle_handlers = { + init = device_init, + added = device_added, + }, +} + +-- Register default Zigbee handlers for all supported capabilities +defaults.register_for_default_handlers(template, + template.supported_capabilities, + {native_capability_cmds_enabled = true, native_capability_attrs_enabled = true} +) + +local driver = ZigbeeDriver("zigbee_switch", template) +driver:run() +``` + +This pattern - declare supported capabilities, register defaults, add overrides via sub_drivers and lifecycle_handlers, then construct and run - is the standard structure +for all protocol-based Edge drivers. + +## 6. Default Handlers and Protocol-Specific Default Functionality + +When a driver declares `supported_capabilities` in its template, the framework automatically registers default handlers for each capability. The registration uses `or`-merge +logic: **driver-defined handlers always take precedence over defaults.** If the driver already registered a handler for a given cluster/attribute/command slot, the default +is silently skipped. + +Registration happens in `st.{zigbee,zwave,matter}.defaults.init.lua` via `register_for_default_handlers(driver, capabilities, opts)`: +1. Iterates `supported_capabilities` +2. For each capability, requires the corresponding defaults module +3. Merges `zigbee_handlers`, `zwave_handlers`, or `matter_handlers` (only where driver hasn't defined one) +4. Also merges `attribute_configurations` (Zigbee), `get_refresh_commands` (Z-Wave), or `subscribed_attributes` (Matter) + +### Zigbee specific default functionality + +The default `doConfigure` handler (`device_management.configure`): +1. Sends a `refresh` command (reads all configured attributes) +2. Calls `device:configure()` which iterates all configured attributes and for each: + - Sends a ZDO Bind Request + - Sends a Configure Reporting command with the attribute's min/max interval, data type, and reportable change +3. Also handles IAS Zone enrollment if the device supports cluster `0x0500` + +### Z-Wave specific default functionality + +doConfigure calls `device:default_configure()` which calls `device:refresh()`. The default refresh iterates `get_refresh_commands` from all default capability modules and sends Get commands for each supported CC. +Refresh collects `get_refresh_commands` from all default modules, sends Get commands + +### Matter specific default functionality + +TODO + +## 7. Unit Test Framework + +Load the `testing-edge-drivers` skill for details on the built in unit test framework for to test Zigbee, Z-Wave, and Matter drivers. + diff --git a/.agents/skills/understanding-profiles/SKILL.md b/.agents/skills/understanding-profiles/SKILL.md new file mode 100644 index 0000000000..0f169057e5 --- /dev/null +++ b/.agents/skills/understanding-profiles/SKILL.md @@ -0,0 +1,369 @@ +--- +name: understanding-profiles +description: Understanding and defining SmartThings capabilities, device profiles, preferences, and embedded device configurations for Edge Drivers +--- + +# SmartThings Capabilities, Profiles, and Preferences + +## 1. What Are Capabilities? + +Capabilities are the fundamental abstraction in SmartThings. They define what a device can do and what state it can report. Each capability consists of: + +- **Attributes**: State/status values (e.g., `switch` has attribute `switch` with values `on`/`off`) +- **Commands**: Actions that control the device (e.g., `on()`, `off()`, `setLevel(level)`) + +A capability definition specifies data types, units, and constraints for its attributes and commands. + +### Data Types +| Type | Example | Description | +|------|---------|-------------| +| string | `"locked"` | May have enum or pattern constraints | +| integer | `5` | Whole number, may have min/max | +| number | `5.5` | Fractional values allowed | +| boolean | `true` | true or false | +| object | `{x: 12}` | Map of name-value pairs | +| array | `["heat","cool"]` | List of single type | + +### Common Capabilities +- `switch` - on/off control +- `switchLevel` - dimming (0-100) +- `temperatureMeasurement` - temperature reading +- `battery` - battery percentage +- `contactSensor` - open/closed +- `motionSensor` - active/inactive +- `lock` - locked/unlocked +- `thermostatMode`, `thermostatHeatingSetpoint`, `thermostatCoolingSetpoint` +- `colorTemperature`, `colorControl` +- `refresh` - request device state update +- `firmwareUpdate` - OTA firmware management +- `healthCheck` - device connectivity monitoring + +Full reference: https://developer.smartthings.com/docs/devices/capabilities/capabilities-reference + +## 2. Standard vs Custom Capabilities + +### Standard Capabilities +Standard capabilities live under the `smartthings` namespace but are referenced without a namespace prefix: +```yaml +- id: switch + version: 1 +- id: temperatureMeasurement + version: 1 +``` + +### Custom Capabilities +Custom capabilities use the format `namespace.capabilityName`: +```yaml +- id: perfectlife6617.customGarageDoor + version: 1 +``` + +A namespace is auto-generated per developer account (e.g., `perfectlife6617`). Custom capabilities are created via the SmartThings CLI: +``` +smartthings capabilities:create -i capability.json +``` + +Custom capabilities require a Capability Presentation to render properly in the app. + +## 3. Device Profile YAML Format + +Device profiles define which capabilities a device exposes, organized into components. They live in `profiles/` directories within driver packages. + +### Basic Profile Example (from `zwave-lock`) +```yaml +name: base-lock +components: +- id: main + capabilities: + - id: lock + version: 1 + - id: lockCodes + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +``` + +### Multi-Component Profile (from `zigbee-fan`) +```yaml +name: fan-light +components: + - id: main + label: Fan + capabilities: + - id: switch + version: 1 + - id: fanSpeed + version: 1 + config: + values: + - key: "fanSpeed.value" + range: [0, 3] + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan + - id: light + label: Light + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [0, 100] + - id: refresh + version: 1 + categories: + - name: Light +``` + +### Profile with Embedded Config and Preferences (from `zigbee-contact`) +```yaml +name: multi-sensor +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: threeAxis + version: 1 + - id: accelerationSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MultiFunctionalSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: certifiedpreferences.garageSensor + explicit: true +``` + +### Key Profile Rules +- Must have at least one component; the primary is always `id: main` +- Use multiple components when the same capability is needed more than once (e.g., multi-gang switch) +- Each component needs at least one capability +- `categories` determines the device icon in the app (e.g., `SmartLock`, `Fan`, `Light`, `Thermostat`, `MultiFunctionalSensor`) +- `version: 1` is always used (only version supported) + +## 4. Embedded Device Configurations + +Embedded device configs let you customize the SmartThings app UI directly in the profile YAML, without creating a separate Device Presentation. Only supported by Edge Drivers. + +### Range Constraint +```yaml +- id: colorTemperature + config: + values: + - key: "colorTemperature.value" + range: [2600, 6200] +``` + +### Enabled Values (filter enum options) +```yaml +- id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - heating + - cooling + - fan only + - idle +``` + +### Separate Attribute vs Command Values +```yaml +- id: thermostatMode + config: + values: + - key: thermostatMode.value + enabledValues: + - off + - heat + - eco + - key: setThermostatMode + enabledValues: + - off + - heat +``` + +### Enum Commands +```yaml +- id: alarm + config: + values: + - key: alarm.value + enabledValues: + - off + - siren + - key: "{{enumCommands}}" + enabledValues: + - off + - siren +``` + +When you package the driver, the platform auto-generates a Device Presentation from these configs. + +## 5. Preferences + +Preferences let users configure device behavior from Settings in the SmartThings app. + +### Two Types + +**Explicit (shared/reusable):** Defined externally, referenced by ID in the profile: +```yaml +preferences: + - preferenceId: tempOffset + explicit: true +``` + +Standard explicit preferences include: `tempOffset`, `humidityOffset`, `motionSensitivity`, `reportingInterval`, `reverse`, `presetPosition`, `username`, `password`. + +`tempOffset` and `humidityOffset` are automatically applied by the platform to attribute values - no driver code needed. + +**Embedded (inline in profile):** Defined directly in the profile YAML: +```yaml +preferences: + - title: "IP Address" + name: ipAddress + description: "IP address of the Pi-Hole" + required: true + preferenceType: string + definition: + minLength: 7 + maxLength: 15 + stringType: text + default: localhost +``` + +### Preference Types +| Type | Definition Fields | +|------|------------------| +| boolean | `default` | +| integer | `minimum`, `maximum`, `default` | +| number | `minimum`, `maximum`, `default` | +| string | `stringType` (text/paragraph/password), `minLength`, `maxLength`, `default` | +| enumeration | `options` (key-value map), `default` (must match a key) | + +### Accessing Preferences in Lua + +Query current value: +```lua +local offset = device.preferences.tempOffset +local level = command.args.level + device.preferences.levelOffset +``` + +Handle preference changes via `infoChanged` lifecycle: +```lua +local function device_info_changed(driver, device, event, args) + if args.old_st_store.preferences.sensitivityLevel ~= device.preferences.sensitivityLevel then + device:send() + end +end +``` + +For sleepy Z-Wave devices, use `device:set_update_preferences_fn(fn)` which fires on wakeup. + +## 6. config.yml + +The `config.yml` file is the driver package manifest. It lives at the root of each driver directory. + +```yaml +name: 'Zigbee Thermostat' +defaultProfile: 'thermostat-battery-powerSource' +packageKey: 'zigbee-thermostat' +permissions: + zigbee: {} +description: "SmartThings driver for Zigbee thermostat devices" +vendorSupportInformation: "https://support.smartthings.com" +``` + +### Fields +| Field | Description | +|-------|-------------| +| `name` | Human-readable driver name | +| `packageKey` | Unique package identifier | +| `permissions` | Protocol access: `zigbee: {}`, `zwave: {}`, `lan: {}`, `matter: {}` | +| `description` | Driver description | +| `defaultProfile` | Profile name used when no fingerprint match specifies one | +| `vendorSupportInformation` | Support URL | + +## 7. Fingerprints + +Fingerprints map physical devices to profiles. They live in `fingerprints.yml` at the driver root. + +### Zigbee Fingerprints +```yaml +zigbeeManufacturer: + - id: "LUMI/lumi.motion.ac02" + deviceLabel: Aqara Motion Sensor P1 + manufacturer: LUMI + model: lumi.motion.ac02 + deviceProfileName: motion-illuminance-battery-aqara + - id: "SmartThings/motionv5" + deviceLabel: Motion Sensor + manufacturer: SmartThings + model: motionv5 + deviceProfileName: motion-temp-battery + +zigbeeGeneric: + - id: kickstarter/motion/1 + deviceLabel: SmartThings Motion Sensor + zigbeeProfiles: + - 0xFC01 + deviceIdentifiers: + - 0x013A + deviceProfileName: smartsense-motion +``` + +### Key Fingerprint Fields +| Field | Description | +|-------|-------------| +| `id` | Unique identifier for the fingerprint | +| `deviceLabel` | Default label shown to users | +| `manufacturer` | Device manufacturer string | +| `model` | Device model string | +| `deviceProfileName` | Which profile from `profiles/` to use | +| `zigbeeProfiles` | (zigbeeGeneric) Zigbee profile IDs | +| `deviceIdentifiers` | (zigbeeGeneric) Zigbee device type IDs | + +Z-Wave fingerprints use `manufacturerId`, `productType`, and `productId` instead. + +## 8. Relationship: config.yml + Profiles + Fingerprints + +``` +driver/ +├── config.yml # Package manifest, declares defaultProfile +├── fingerprints.yml # Maps hardware → profile by deviceProfileName +├── profiles/ +│ ├── basic-device.yml # Profile A +│ └── advanced-device.yml # Profile B +└── src/ + └── init.lua # Driver logic +``` + +Flow: +1. A device joins the hub +2. The platform matches it against `fingerprints.yml` entries +3. The matched fingerprint's `deviceProfileName` selects which profile to use +4. If no fingerprint matches, `defaultProfile` from `config.yml` is used +5. The profile defines capabilities, components, categories, and preferences +6. Embedded `config` in the profile customizes the app UI +7. The driver's Lua code handles capability commands and emits attribute events diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..5f982e7f88 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +Check all that apply + +# Type of Change + +- [ ] WWST Certification Request + - If this is your first time contributing code: + - [ ] I have reviewed the README.md file + - [ ] I have reviewed the CODE_OF_CONDUCT.md file + - [ ] I have signed the CLA + - [ ] I plan on entering a WWST Certification Request or have entered a request through the WWST Certification console at developer.smartthings.com +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor + +# Checklist + +- [ ] I have performed a self-review of my code +- [ ] I have commented my code in hard-to-understand areas +- [ ] I have verified my changes by testing with a device or have communicated a plan for testing +- [ ] I am adding new behavior, such as adding a sub-driver, and have added and run new unit tests to cover the new behavior + +# Description of Change + + +# Summary of Completed Tests + + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..cf17ce46b3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,52 @@ + You are working in the SmartThings Edge Drivers repository. Drivers are written in **Lua 5.3** and + run on SmartThings hubs. They translate Zigbee, Z-Wave, Matter, and LAN protocol messages into + SmartThings capability commands and events. + + For full context, read `AGENTS.md` at the repository root. It covers driver structure, lifecycle, + profiles, and available skills for deeper domain knowledge. + + ## Standard Commands + + ```bash + # Run tests + python3 tools/run_driver_tests.py -vv -f + + # Lint + luacheck --config .github/workflows/.luacheckrc + + # Deploy + smartthings edge:drivers:package --hub= --channel= +``` + +## Rules + +Always: + + - Run tests before considering a change complete + - Run luacheck on modified Lua files + - Use existing standard capabilities before creating custom ones + - Follow existing driver structure patterns + +Ask before: + + - Modifying device profile YAML files (changes affect production devices) + - Adding new custom capabilities + - Changing config.yml permissions + +Never: + + - Commit hardcoded API keys or tokens + - Skip tests for driver changes + - Use Lua features beyond 5.3 + +## Skills + +Load these files for deeper knowledge when working in each area: + +| Task | Skill file | +|------|-----------| +| Driver lifecycle, dispatch, default handlers | .agents/skills/understanding-lua-libraries/SKILL.md | +| Profiles, capabilities, preferences, fingerprints | .agents/skills/understanding-profiles/SKILL.md | +| Writing and running tests | .agents/skills/testing-edge-drivers/SKILL.md | +| Luacheck / code style | .agents/skills/linting-and-style/SKILL.md | +| Environment setup, deploying, sharing via channels | .agents/skills/dev-workflow/SKILL.md | diff --git a/.github/scripts/check_duplicates.py b/.github/scripts/check_duplicates.py index a47cdb2fb3..e335f6083a 100644 --- a/.github/scripts/check_duplicates.py +++ b/.github/scripts/check_duplicates.py @@ -1,10 +1,10 @@ -import csv import os import yaml from pathlib import Path cwd = os.getcwd() duplicate_pairs = [] +deleted_profiles = [] def compare_component_capabilities_unordered(comp1, comp2): for cap1 in comp1["capabilities"]: @@ -79,6 +79,10 @@ def compare_components(prof1, prof2): if new_component.get("categories") != current_component.get("categories"): return False + # compare labels + if new_component.get("label") != current_component.get("label"): + return False + # check that there are the same number of capabilities and that the top capability matches if ((len(new_component["capabilities"]) == len(current_component["capabilities"])) and (new_component["capabilities"][0]["id"] == current_component["capabilities"][0]["id"])): @@ -96,44 +100,49 @@ def compare_components(prof1, prof2): return True -with open(str(Path.home()) + '/files.csv', 'r') as csvfile: - csvreader = csv.reader(csvfile) - changed_files = next(csvreader) - - for file in changed_files: - file_basename = os.path.basename(file) - file_directory = os.path.dirname(file) - - if '/profiles/' in file: - print('\nNEW PROFILE:\n%s is a profile! Comparing to other profiles...' % file) - - os.chdir(file_directory) - for current_profile in os.listdir("./"): - new_profile = file_basename - - # compare to YAML files that are not the same file - # Compare only .yml files and only files that have not already been found to be a duplicate - if current_profile != new_profile and Path(current_profile).suffix == ".yml" and (current_profile, new_profile) not in duplicate_pairs: - print("Comparing %s vs %s" % (new_profile, current_profile)) - with open(new_profile) as new_data, open(current_profile) as current_data: - new_profile_map = yaml.safe_load(new_data) - current_profile_map = yaml.safe_load(current_data) - - ''' Compare profiles. A duplicate is defined as follows: - - categories must be the same - - capabilities must be the same, with some ordering restrictions - - top capability must match, but subsequent ordering does not matter - - embedded configs must be the same, but certain values can be ordered differently (i.e. enabledValues) - - preferences must be the same - ''' - if(compare_preferences(new_profile_map, current_profile_map) == True and - compare_metadata(new_profile_map, current_profile_map) == True and - compare_components(new_profile_map, current_profile_map) == True): - print("%s and %s are duplicates!\n" % (new_profile, current_profile)) - duplicate_pairs.append((new_profile, current_profile)) - - # return to original directory - os.chdir(cwd) +changed_files = os.environ.get('ALL_CHANGED_FILES', '').split() + +for file in changed_files: + file_basename = os.path.basename(file) + file_directory = os.path.dirname(file) + + if '/profiles/' in file: + print('\nNEW PROFILE:\n%s is a profile! Comparing to other profiles...' % file) + + os.chdir(file_directory) + new_profile = file_basename + + # Skip deleted files and track them for warning + if not os.path.exists(new_profile): + print("Skipping %s - file was deleted" % new_profile) + deleted_profiles.append(file) + os.chdir(cwd) + continue + + for current_profile in os.listdir("./"): + # compare to YAML files that are not the same file + # Compare only .yml files and only files that have not already been found to be a duplicate + if current_profile != new_profile and Path(current_profile).suffix == ".yml" and (current_profile, new_profile) not in duplicate_pairs: + print("Comparing %s vs %s" % (new_profile, current_profile)) + with open(new_profile) as new_data, open(current_profile) as current_data: + new_profile_map = yaml.safe_load(new_data) + current_profile_map = yaml.safe_load(current_data) + + ''' Compare profiles. A duplicate is defined as follows: + - categories must be the same + - capabilities must be the same, with some ordering restrictions + - top capability must match, but subsequent ordering does not matter + - embedded configs must be the same, but certain values can be ordered differently (i.e. enabledValues) + - preferences must be the same + ''' + if(compare_preferences(new_profile_map, current_profile_map) == True and + compare_metadata(new_profile_map, current_profile_map) == True and + compare_components(new_profile_map, current_profile_map) == True): + print("%s and %s are duplicates!\n" % (new_profile, current_profile)) + duplicate_pairs.append((new_profile, current_profile)) + + # return to original directory + os.chdir(cwd) with open("profile-comment-body.md", "w") as f: if duplicate_pairs: @@ -141,7 +150,12 @@ def compare_components(prof1, prof2): for duplicate in duplicate_pairs: f.write("%s == %s\n" % (duplicate[0], duplicate [1])) else: - f.write("Duplicate profile check: Passed - no duplicate profiles detected.") + f.write("Duplicate profile check: Passed - no duplicate profiles detected.\n") + + if deleted_profiles: + f.write("\n:warning: **Deleted profile files detected:**\n") + for deleted in deleted_profiles: + f.write("- `%s`\n" % deleted) with open("profile-comment-body.md", "r") as f: print("\n" + f.read()) diff --git a/.github/scripts/check_profile_categories.py b/.github/scripts/check_profile_categories.py new file mode 100644 index 0000000000..31609cbe7e --- /dev/null +++ b/.github/scripts/check_profile_categories.py @@ -0,0 +1,82 @@ +import os +import sys +import yaml +from pathlib import Path + +cwd = os.getcwd() +missing_category_profiles = [] +deleted_profiles = [] + +changed_files = os.environ.get('ALL_CHANGED_FILES', '').split() + +for file in changed_files: + file_basename = os.path.basename(file) + file_directory = os.path.dirname(file) + + if '/profiles/' in file and file.endswith('.yml'): + print('\nCHECKING PROFILE:\n%s' % file) + + os.chdir(file_directory) + + if not os.path.exists(file_basename): + print("Skipping %s - file was deleted" % file_basename) + deleted_profiles.append(file) + os.chdir(cwd) + continue + + with open(file_basename) as fp: + try: + profile = yaml.safe_load(fp) + except yaml.YAMLError as e: + print("Error parsing %s: %s" % (file_basename, e)) + os.chdir(cwd) + continue + + if not profile or 'components' not in profile: + print("Skipping %s - no components found" % file_basename) + os.chdir(cwd) + continue + + # Find the main component and verify it has a categories field + main_component = next( + (c for c in profile['components'] if c.get('id') == 'main'), + None + ) + + if main_component is None: + print("Warning: %s has no 'main' component" % file_basename) + os.chdir(cwd) + continue + + if not main_component.get('categories'): + print("MISSING CATEGORY: %s" % file) + missing_category_profiles.append(file) + else: + print("OK: %s has categories: %s" % ( + file_basename, + [c['name'] for c in main_component['categories']] + )) + + os.chdir(cwd) + +with open("profile-categories-comment-body.md", "w") as f: + if missing_category_profiles: + f.write("Profile category check: :x: **Missing categories detected.**\n\n") + f.write("The following profiles are missing a `categories` field on the `main` component:\n\n") + for profile in missing_category_profiles: + f.write("- `%s`\n" % profile) + f.write("\nPlease add a `categories` entry to the `main` component. Example:\n") + f.write("```yaml\ncomponents:\n - id: main\n categories:\n - name: Switch\n capabilities:\n ...\n```\n") + else: + f.write("Profile category check: :white_check_mark: Passed - all profiles have a category defined.\n") + + if deleted_profiles: + f.write("\n:warning: **Deleted profile files detected:**\n") + for deleted in deleted_profiles: + f.write("- `%s`\n" % deleted) + +with open("profile-categories-comment-body.md", "r") as f: + print("\n" + f.read()) + +if missing_category_profiles: + sys.exit(1) diff --git a/.github/workflows/check-profile-categories-comment.yml b/.github/workflows/check-profile-categories-comment.yml new file mode 100644 index 0000000000..cf207144ef --- /dev/null +++ b/.github/workflows/check-profile-categories-comment.yml @@ -0,0 +1,34 @@ +name: Post profile categories comment +on: + workflow_run: + workflows: [Check profile categories] + types: + - completed + +jobs: + comment-on-pr: + runs-on: ubuntu-latest + steps: + - name: Download comment artifact + uses: dawidd6/action-download-artifact@v6 + with: + workflow: check-profile-categories.yml + run_id: ${{ github.event.workflow_run.id }} + + - run: echo "pr_number=$(cat pr_number/pr_number.txt)" >> $GITHUB_ENV + + - name: Find comment + uses: peter-evans/find-comment@v2 + id: fc + with: + body-includes: Profile category check + comment-author: 'github-actions[bot]' + issue-number: ${{ env.pr_number }} + + - name: Post comment + uses: peter-evans/create-or-update-comment@v2 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + body-file: 'profile_categories_comment/profile-categories-comment-body.md' + edit-mode: replace + issue-number: ${{ env.pr_number }} diff --git a/.github/workflows/check-profile-categories.yml b/.github/workflows/check-profile-categories.yml new file mode 100644 index 0000000000..6340175810 --- /dev/null +++ b/.github/workflows/check-profile-categories.yml @@ -0,0 +1,41 @@ +name: Check profile categories +on: + pull_request: + types: [opened, synchronize] + paths: + - 'drivers/**/profiles/*.yml' + +jobs: + check-profile-categories: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gather file changes + id: changed-files + uses: tj-actions/changed-files@v47 + + - name: Run python script + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + run: | + python ./.github/scripts/check_profile_categories.py + + - name: Upload profile categories comment artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: profile_categories_comment + path: | + profile-categories-comment-body.md + + - run: echo ${{ github.event.number }} > pr_number.txt + + - name: Upload PR info + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr_number + path: | + pr_number.txt diff --git a/.github/workflows/create-channel.yml b/.github/workflows/create-channel.yml index 367561c5f9..f3a287748c 100644 --- a/.github/workflows/create-channel.yml +++ b/.github/workflows/create-channel.yml @@ -14,7 +14,7 @@ jobs: head_ref: ${{ steps.head_ref.outputs.head_ref }} steps: - name: Download artifacts - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v6 with: workflow: upload-driver-packages.yml run_id: ${{ github.event.workflow_run.id }} @@ -48,6 +48,7 @@ jobs: url: 'https://api.smartthings.com/distchannels/' method: GET bearerToken: ${{ secrets.TOKEN }} + timeout: 10000 - name: Store channel name uses: actions/github-script@v5 with: @@ -89,7 +90,7 @@ jobs: driver: ${{ fromJSON(needs.download-artifacts.outputs.drivers) }} steps: - name: Download artifacts - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v6 with: workflow: upload-driver-packages.yml name: ${{ matrix.driver }} diff --git a/.github/workflows/delete-channel.yml b/.github/workflows/delete-channel.yml index 11395a69c5..1760c380eb 100644 --- a/.github/workflows/delete-channel.yml +++ b/.github/workflows/delete-channel.yml @@ -17,6 +17,7 @@ jobs: url: 'https://api.smartthings.com/distchannels/' method: GET bearerToken: ${{ secrets.TOKEN }} + timeout: 10000 - name: Store channel name uses: actions/github-script@v5 with: diff --git a/.github/workflows/duplicate-profiles-comment.yml b/.github/workflows/duplicate-profiles-comment.yml index 9d1ed8aea3..478b4cbd15 100644 --- a/.github/workflows/duplicate-profiles-comment.yml +++ b/.github/workflows/duplicate-profiles-comment.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download comment artifact - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v6 with: workflow: duplicate-profiles.yml run_id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/duplicate-profiles.yml b/.github/workflows/duplicate-profiles.yml index d3c59e3ec6..5f66269cc1 100644 --- a/.github/workflows/duplicate-profiles.yml +++ b/.github/workflows/duplicate-profiles.yml @@ -13,25 +13,18 @@ jobs: - name: Checkout uses: actions/checkout@v3 - # Creates file "$/files.csv", among others - - id: file_changes - name: Gather file changes - uses: trilom/file-changes-action@1.2.4 - with: - output: ',' - fileOutput: ',' - - # For verification - - name: Show files changed - run: | - cat $HOME/files.csv + - name: Gather file changes + id: changed-files + uses: tj-actions/changed-files@v47 - name: Run python script + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | python ./.github/scripts/check_duplicates.py - name: Upload duplicate profile comment artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: duplicate_profile_comment path: | @@ -40,7 +33,7 @@ jobs: - run: echo ${{ github.event.number }} > pr_number.txt - name: Upload PR info - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pr_number path: | diff --git a/.github/workflows/jenkins-driver-tests.yml b/.github/workflows/jenkins-driver-tests.yml new file mode 100644 index 0000000000..aff7f6f150 --- /dev/null +++ b/.github/workflows/jenkins-driver-tests.yml @@ -0,0 +1,46 @@ +name: Run Jenkins driver tests +on: + pull_request_target: + paths: + - 'drivers/**' + +jobs: + trigger-driver-test: + strategy: + matrix: + version: + [ 59, 60, 61, dev] + + runs-on: ubuntu-latest + steps: + + - name: Create Commit Status (Pending) + id: status + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd + with: + script: | + let response = await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.pull_request.head.sha, + state: 'pending', + description: 'Jenkins job triggered...', + context: 'Driver Tests (${{ matrix.version }})' + }); + core.setOutput('status_url', response.data.url); + - name: Trigger Jenkins Generic Webhook + env: + JENKINS_WEBHOOK_TOKEN: ${{ secrets.JENKINS_WEBHOOK_TOKEN }} + JENKINS_WEBHOOK_URL: ${{ secrets.JENKINS_WEBHOOK_URL }} + STATUS_URL: ${{ steps.status.outputs.status_url }} + run: | + set +x + curl -s -o /dev/null -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${JENKINS_WEBHOOK_TOKEN}" \ + -d "{\"status_url\": \"$STATUS_URL\", + \"version\": ${{ matrix.version }}, + \"commit\": ${{ github.event.pull_request.head.sha }}, + \"pr_id\": ${{ github.event.number }} }" \ + "${JENKINS_WEBHOOK_URL}" + set -x diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml index 02d4da0005..099ac90648 100644 --- a/.github/workflows/publish-test-results.yml +++ b/.github/workflows/publish-test-results.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download artifacts - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v6 with: workflow: run-tests.yml run_id: ${{ github.event.workflow_run.id }} @@ -22,9 +22,66 @@ jobs: event_file: event-file/event.json event_name: ${{ github.event.workflow_run.event }} files: "tests/*.xml" - - name: Publish coverage results + + # 5monkeys/cobertura-action has a bug where the `files` array that builds + # each per-XML markdown table is declared once outside the loop over reports + # and is never reset. When multiple XML files are passed via a glob, every + # subsequent driver's table accumulates all files from earlier drivers. + # + # Fix: determine which XML files exist at runtime, then fan out into a matrix + # job so each driver's XML is processed by its own isolated action invocation. + list-coverage-files: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.build-matrix.outputs.matrix }} + steps: + - name: Download coverage artifact + continue-on-error: true + uses: dawidd6/action-download-artifact@v6 + with: + workflow: run-tests.yml + run_id: ${{ github.event.workflow_run.id }} + name: coverage + path: coverage + - name: Build matrix of coverage files + id: build-matrix + run: | + files=$(find coverage -maxdepth 1 -name '*_coverage.xml' -printf '%f\n' 2>/dev/null | sort) + if [ -z "$files" ]; then + echo 'matrix=[]' >> $GITHUB_OUTPUT + else + json=$(echo "$files" | python3 -c 'import sys,json; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))') + echo "matrix=$json" >> $GITHUB_OUTPUT + fi + + publish-coverage: + needs: list-coverage-files + if: needs.list-coverage-files.outputs.matrix != '[]' && needs.list-coverage-files.outputs.matrix != '' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + coverage_file: ${{ fromJson(needs.list-coverage-files.outputs.matrix) }} + steps: + - name: Download coverage artifact + uses: dawidd6/action-download-artifact@v6 + with: + workflow: run-tests.yml + run_id: ${{ github.event.workflow_run.id }} + name: coverage + path: coverage + - name: Download pr_number artifact + uses: dawidd6/action-download-artifact@v6 + with: + workflow: run-tests.yml + run_id: ${{ github.event.workflow_run.id }} + name: pr_number + path: pr_number + - run: echo "pr_number=$(cat pr_number/pr_number.txt)" >> $GITHUB_ENV + - name: Publish coverage for ${{ matrix.coverage_file }} uses: 5monkeys/cobertura-action@master with: pull_request_number: ${{ env.pr_number }} - path: "coverage/*.xml" + path: coverage/${{ matrix.coverage_file }} minimum_coverage: 90 + report_name: ${{ matrix.coverage_file }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1811c76450..de88fc02d1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,6 +4,8 @@ on: types: [opened, synchronize, labeled, unlabeled] paths: - 'drivers/**' + - 'tools/run_driver_tests.py' + - 'tools/run_driver_tests_p.py' jobs: # Two separate jobs for finding the right artifact to run tests with @@ -39,51 +41,10 @@ jobs: id: cache_key run: echo "CACHE_KEY=${{ steps.lib-version.outputs.url }}-v1" >> $GITHUB_OUTPUT - get-dev-artifact: - runs-on: ubuntu-latest - outputs: - cache_key: ${{ steps.cache_key.outputs.CACHE_KEY }} - if: ${{ contains(join(github.event.pull_request.labels.*.name), 'release-') && github.event.pull_request.head.repo.fork != 'true' }} - steps: - - name: Get the version from the label - id: label-version - run: | - echo "${{ join(github.event.pull_request.labels.*.name) }}" | grep -oP "release-\d+.\d+" | xargs > out - echo "LIBRARY_VERSION=$(cat out)" >> $GITHUB_OUTPUT - mkdir /home/runner/work/lua_libs - - name: Find latest artifact - id: latest - env: - ARTIFACTORY_URL: ${{ format('https://smartthings.jfrog.io/artifactory/edge-driver-libs/{0}/', steps.label-version.outputs.LIBRARY_VERSION) }} - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: | - wget --user=$ARTIFACTORY_USERNAME --password=$ARTIFACTORY_PASSWORD $ARTIFACTORY_URL -q -O - | grep '.zip' | awk -F' ' '{print $3"-"$4"\t"$2}' | sort -t - -k3n -k2M -k1n -k4n | tail -1 | grep -o 'lua_libs_[a-z0-9_]*.zip' | head -1 > out - echo "ZIP_FILE=$(cat out)" >> $GITHUB_OUTPUT - - name: Try to retrieve cache - id: cached-libs - uses: actions/cache@v3 - with: - path: '/home/runner/work/lua_libs' - key: ${{ steps.latest.outputs.ZIP_FILE }}-v1 - - name: Download and unpack specified version - if: steps.cached-libs.outputs.cache-hit != 'true' - env: - ARTIFACTORY_URL: ${{ format('https://smartthings.jfrog.io/artifactory/edge-driver-libs/{0}/{1}', steps.label-version.outputs.LIBRARY_VERSION, steps.latest.outputs.ZIP_FILE) }} - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - working-directory: '/home/runner/work/lua_libs' - run: | - wget --user=$ARTIFACTORY_USERNAME --password=$ARTIFACTORY_PASSWORD $ARTIFACTORY_URL -O lua_libs.zip - unzip lua_libs.zip - - name: Set output - id: cache_key - run: echo "CACHE_KEY=${{ steps.latest.outputs.ZIP_FILE }}-v1" >> $GITHUB_OUTPUT - run-driver-tests: runs-on: ubuntu-latest needs: - [ get-latest-release-artifact, get-dev-artifact ] + [ get-latest-release-artifact ] if: ${{ always() && contains(needs.*.result, 'success') && !contains(needs.*.result, 'failure') }} steps: - name: Set cache key @@ -108,9 +69,11 @@ jobs: - name: Install lua rocks run: | wget https://luarocks.org/manifests/hisham/luacov-0.15.0-1.rockspec - wget https://luarocks.org/manifests/ssharma/luacov-cobertura-0.2-1.rockspec + wget https://raw.githubusercontent.com/britzl/luacov-cobertura/refs/tags/1.1.0/rockspec -O luacov-cobertura-1.1.0-0.rockspec + sed -i 's/master/1\.1\.0/g' luacov-cobertura-1.1.0-0.rockspec + sed -i 's/1\.0-0/1\.1\.0-0/g' luacov-cobertura-1.1.0-0.rockspec sudo luarocks install luacov-0.15.0-1.rockspec - sudo luarocks install luacov-cobertura-0.2-1.rockspec + sudo luarocks install luacov-cobertura-1.1.0-0.rockspec - name: Set LUA_PATH id: lua_path env: @@ -130,21 +93,43 @@ jobs: - run: echo ${{ steps.changed-drivers.outputs.all_modified_files }} - name: Install Python requirements run: pip install -r tools/requirements.txt + - name: Fetch capability definitions + continue-on-error: true + run: | + python3 tools/fetch_capability_definitions.py \ + --drivers-dir ${{ github.workspace }}/drivers \ + --output-dir ${{ github.workspace }}/capability_json \ + --failed-output-file ${{ github.workspace }}/capability_failures_comment.md + env: + CAPABILITY_PAT: ${{ secrets.CAPABILITY_DEFINITIONS_PAT }} + - name: Report capability download failures + if: hashFiles('capability_failures_comment.md') != '' && always() + uses: marocchino/sticky-pull-request-comment@v2 + with: + path: ${{ github.workspace }}/capability_failures_comment.md + header: capability-download-failures + - name: Clear capability download failures comment + if: hashFiles('capability_failures_comment.md') == '' && always() + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: capability-download-failures + delete: true - name: Run the tests id: run-tests run: python tools/run_driver_tests_p.py ${{ steps.changed-drivers.outputs.all_modified_files }} env: LUA_PATH: ${{ steps.lua_path.outputs.lua_path }} + ST_CAPABILITY_JSON_DIR: ${{ github.workspace }}/capability_json - name: Upload test artifact if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: tests path: | tools/test_output/*.xml - name: Upload coverage artifact if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage path: | @@ -154,13 +139,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Upload event file artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: event-file path: ${{ github.event_path }} - run: echo ${{ github.event.number }} > pr_number.txt - name: Upload pr number artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pr_number path: | diff --git a/.github/workflows/upload-driver-packages.yml b/.github/workflows/upload-driver-packages.yml index 2dba09053f..29dc2b89fb 100644 --- a/.github/workflows/upload-driver-packages.yml +++ b/.github/workflows/upload-driver-packages.yml @@ -40,7 +40,7 @@ jobs: run: | zip -v ${{env.package_key}}.zip config.yml fingerprints.yml search-parameters.y*ml $(find . -name "*.pem") $(find . -name "*.crt") $(find profiles -name "*.y*ml") $(find . -name "*.lua") -x "*test*" - name: Upload packaged driver artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ env.package_key }} path: | @@ -51,14 +51,14 @@ jobs: steps: - run: echo ${{ github.event.number }} > pr_number.txt - name: Upload pr number artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pr_number path: | pr_number.txt - run: echo ${{ github.head_ref }} > head_ref.txt - name: Upload head ref artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: head_ref path: | diff --git a/.gitignore b/.gitignore index ea01709e90..72da442d81 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ lua_libs-api_* *.out tools/test_output/* tools/coverage_output/* +tools/coverage_output_html/ +tools/__pycache__/ .DS_Store +.venv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..ecb9478f81 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# SmartThings Edge Drivers — Agent Instructions + +You are an expert Lua 5.3 engineer and SmartThings Edge Driver maintainer. This repository contains production Edge Drivers for the SmartThings platform, spanning Zigbee, Z-Wave, Matter, and LAN protocols. + +Lua drivers translate between device protocol messages and SmartThings capability commands/events to support hub connected devices on the platform. + +## Repository Structure + +``` +drivers/ # Edge Drivers organized by vendor (SmartThings/, Aqara/, etc.) + // + config.yml # Driver metadata, permissions, capabilities, preferences + profiles/ # Device profile YAML definitions + fingerprints.yml # Device identification fingerprints (optional, can be in Lua) + src/ + init.lua # Driver entry point + sub_drivers/ # Protocol/device-specific sub-drivers (optional) + test/ # Integration tests +lua_libs/ # SmartThings Lua runtime libraries (from latest GitHub release) +tools/ # Test runners, deploy scripts, utilities +.github/workflows/ # CI: tests, luacheck, packaging +``` + +## Standard Commands + +### Run Tests +```bash +python3 tools/run_driver_tests.py -vv -f +``` +The filter matches against driver directory/file names. Load the `testing-edge-drivers` skill for details. + +### Lint +```bash +luacheck --config .github/workflows/.luacheckrc +``` +Load the `linting-and-style` skill for configuration details and common fixes. + +### Deploy a Driver +```bash +smartthings edge:drivers:package --hub= --channel= +``` +Load the `dev-workflow` skill for channel setup and sharing instructions. + +## Driver Anatomy + +Drivers live under `drivers///`. The canonical layout is: + +``` +drivers/// + config.yml # Driver metadata: name, packageKey, permissions, description + fingerprints.yml # Device matching rules (Zigbee, Z-Wave, Matter only) + search-parameters.yml # SSDP/mDNS discovery hints (LAN drivers only) + profiles/ + .yml # One file per device profile + src/ + init.lua # Driver entry point; creates template and calls :run() + sub_drivers.lua # Optional: list of sub-driver require paths + / + init.lua # Sub-driver table: NAME, can_handle, handlers + can_handle.lua # Optional: separated device-matching function + fingerprints.lua # Optional: Lua-side fingerprint list for can_handle +``` + +Load the `understanding-lua-libraries` skill for detailed information on the driver framework. + +### Fingerprints (`fingerprints.yml`) + +Fingerprints tell the platform which driver to assign to a newly-joined device. +When a device is discovered, the hub reads its identifying properties and sends +them to the SmartThings cloud, which finds the best matching fingerprint and +installs the corresponding driver. + +Manufacturer-specific fingerprints always win over generic ones when both match. + +LAN drivers do **not** use `fingerprints.yml`. They define a `discovery` handler in the driver +template which is called when the hub forwards discovery requests to the driver. This discovery +handler is responsible for searching for the device on the network and creating the device. + +### Device Profiles (`profiles/*.yml`) + +A profile declares the SmartThings **capabilities** a device exposes, grouped into +**components**. The `main` component is the primary one. A fingerprint's +`deviceProfileName` value must exactly match the `name` field in a profile file. + +Load the `understanding-profiles` skill for details on profiles and how they +define devices on the platform. + +## Lua Libraries (`lua_libs/`) + +The `lua_libs/` directory at the repository root is setup by the developer and not committed +to the repository. It contains the SmartThings Edge SDK: the Lua framework, protocol libraries, +test utilities, and third-party dependencies. **This directory must be present for tests to run.** +Load the `dev-workflow` skill to help with initial setup. + +Load the `understanding-lua-libraries` skill for detailed information on the lua libraries. + +--- + +## Rules + +### ✅ ALWAYS +- Run tests before considering a change complete +- Run luacheck on modified Lua files +- Use existing capabilities from the SmartThings reference before creating custom ones +- Follow the existing driver structure patterns in this repo +- Use `require` paths relative to `src/` for driver code, and `lua_libs/` for library code + +### ⚠️ ASK FIRST +- Before modifying device profile YAML files (changes affect production devices) +- Before adding new custom capabilities +- Before changing `config.yml` permissions +- Before modifying shared library code in `lua_libs/` + +### 🚫 NEVER +- Commit hardcoded API keys, tokens, or hub UUIDs +- Modify files in `lua_libs/` (these come from upstream releases) +- Skip tests for driver changes +- Use Lua features beyond 5.3 (the hub runtime is Lua 5.3) + +## Available Skills + +Load these for deeper domain knowledge: + +| Skill | When to Use | Skill file | +|-------|-------------|------------| +| `understanding-profiles` | Defining or modifying capabilities, profiles, preferences, or device configurations | .agents/skills/understanding-profiles/SKILL.md | +| `understanding-lua-libraries` | Understanding the driver lifecycle, message dispatchers, default handlers, or protocol objects | .agents/skills/understanding-lua-libraries/SKILL.md | +| `testing-edge-drivers` | Running and writing driver tests using the integration test framework | .agents/skills/testing-edge-drivers/SKILL.md | +| `linting-and-style` | Running luacheck or fixing style issues | .agents/skills/linting-and-style/SKILL.md | +| `dev-workflow` | Setting up the dev environment, deploying drivers, or sharing via channels | .agents/skills/dev-workflow/SKILL.md | diff --git a/Jenkinsfile b/Jenkinsfile index 92d915bac5..752d3d358c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -24,17 +24,54 @@ def getChangedDrivers() { return drivers } +def get_region() { + def url = env.JENKINS_URL?.trim() + if (url?.endsWith('/')) { + url = url[0..-2] + } + + def region = url?.endsWith('.cn') ? 'cn' : 'global' + return region +} + +// Gate artifactory-credentials for prod nodes in cn due to a docker authentication error arising in that environment +def getDockerCredentialId() { + def nodeLabel = params.NODE_LABEL ?: 'production' + def region = get_region() + if (nodeLabel == 'production' && region == 'cn') { + return 'artifactory-credentials' + } + else { + return '' + } +} +// Gate RegistryUrl for prod nodes in cn due to a docker authentication error arising in that environment +def getRegistryUrl() { + def nodeLabel = params.NODE_LABEL ?: 'production' + def region = get_region() + if (nodeLabel == 'production' && region == 'cn') { + return 'https://registry.artifactoryedge.streleng.cn' + } else { + // Default + return '' + } +} + pipeline { agent { docker { image 'python:3.10' - label 'production' + label "${params.NODE_LABEL ?: 'production'}" + registryUrl getRegistryUrl() + registryCredentialsId getDockerCredentialId() args '--entrypoint= -u 0:0' } } environment { BRANCH = getEnvName() CHANGED_DRIVERS = getChangedDrivers() + ENVIRONMENT = "${env.NODE_LABEL.toUpperCase()}" + FAILURE_FILE = "failures.log" } stages { stage('requirements') { @@ -51,17 +88,15 @@ pipeline { } } stage('update') { - matrix { - axes { - axis { - name 'ENVIRONMENT' - values 'DEV', 'STAGING', 'ACCEPTANCE', 'PRODUCTION' - } - } - stages { - stage('environment_update') { - steps { - sh 'python3 tools/deploy.py' + stages { + stage('environment_update') { + steps { + sh 'python3 tools/deploy.py' + script { + if (fileExists(env.FAILURE_FILE)) { + currentBuild.description += readFile(env.FAILURE_FILE) + currentBuild.result = 'UNSTABLE' + } } } } @@ -69,4 +104,3 @@ pipeline { } } } - diff --git a/README.md b/README.md index 3eb6fb07cc..c87d2c66c2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ By submitting a pull request, you represent that you have the right to license your contribution to SmartThings and agree by submitting your patch that your contributions are licensed under the [Apache 2.0 license](LICENSE). Before submitting your pull request, please make sure you have tested your changes and that -they follow the project guidelines for [contributing code](https://developer.smartthings.com/docs/devices/hub-connected/certify-your-device#code-formatting-and-submission-criteria). +they follow the project guidelines for [contributing code](https://developer.smartthings.com/docs/devices/hub-connected/code-formatting-criteria). Before contributions can be merged, all contributors must agree to the [SmartThings Individual Contributor License diff --git a/drivers/ABB/insite-scu200/config.yml b/drivers/ABB/insite-scu200/config.yml new file mode 100644 index 0000000000..2cda5384d4 --- /dev/null +++ b/drivers/ABB/insite-scu200/config.yml @@ -0,0 +1,7 @@ +name: 'SCU200 InSite Energy Management System' +packageKey: 'ABB.SCU200' +description: "SmartThings driver for SCU200 InSite Energy Management System" +vendorSupportInformation: "https://support.smartthings.com" +permissions: + lan: {} + discovery: {} \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/auxiliary-contact.yml b/drivers/ABB/insite-scu200/profiles/auxiliary-contact.yml new file mode 100644 index 0000000000..92c4a2fcb6 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/auxiliary-contact.yml @@ -0,0 +1,34 @@ +name: abb.scu200.auxiliary-contact.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +deviceConfig: + dashboard: + states: + - component: main + capability: switch + version: 1 + actions: [] + detailView: + - component: main + capability: switch + version: 1 + visibleCondition: + capability: switch + version: 1 + component: main + value: switch.value + operator: ONE_OF + operand: '[""]' + automation: + conditions: + - component: main + capability: switch + version: 1 + actions: [] \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/bridge.yml b/drivers/ABB/insite-scu200/profiles/bridge.yml new file mode 100644 index 0000000000..d45ebc4943 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/bridge.yml @@ -0,0 +1,8 @@ +name: abb.scu200.bridge.v1 +components: +- id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: Bridges diff --git a/drivers/ABB/insite-scu200/profiles/current-sensor-consumption.yml b/drivers/ABB/insite-scu200/profiles/current-sensor-consumption.yml new file mode 100644 index 0000000000..5843dae90f --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/current-sensor-consumption.yml @@ -0,0 +1,16 @@ +name: abb.scu200.current-sensor-consumption.v1 +components: +- id: main + capabilities: + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/current-sensor-production.yml b/drivers/ABB/insite-scu200/profiles/current-sensor-production.yml new file mode 100644 index 0000000000..ec4e12aeae --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/current-sensor-production.yml @@ -0,0 +1,16 @@ +name: abb.scu200.current-sensor-production.v1 +components: +- id: main + capabilities: + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/energy-meter.yml b/drivers/ABB/insite-scu200/profiles/energy-meter.yml new file mode 100644 index 0000000000..37fab87dbe --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/energy-meter.yml @@ -0,0 +1,44 @@ +name: abb.scu200.energy-meter.v1 +components: +- id: main + capabilities: + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: consumptionMeter + label: "From Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: productionMeter + label: "To Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: surplus + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/gas-meter.yml b/drivers/ABB/insite-scu200/profiles/gas-meter.yml new file mode 100644 index 0000000000..cc3297eb05 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/gas-meter.yml @@ -0,0 +1,10 @@ +name: abb.scu200.gas-meter.v1 +components: +- id: main + capabilities: + - id: gasMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Others \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/output-module.yml b/drivers/ABB/insite-scu200/profiles/output-module.yml new file mode 100644 index 0000000000..d6e0395da8 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/output-module.yml @@ -0,0 +1,10 @@ +name: abb.scu200.output-module.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch diff --git a/drivers/ABB/insite-scu200/profiles/usb-energy-meter.yml b/drivers/ABB/insite-scu200/profiles/usb-energy-meter.yml new file mode 100644 index 0000000000..8eb09ce365 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/usb-energy-meter.yml @@ -0,0 +1,14 @@ +name: abb.scu200.usb-energy-meter.v1 +components: +- id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/water-meter.yml b/drivers/ABB/insite-scu200/profiles/water-meter.yml new file mode 100644 index 0000000000..bf5f50068d --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/water-meter.yml @@ -0,0 +1,10 @@ +name: abb.scu200.water-meter.v1 +components: +- id: main + capabilities: + - id: waterMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Others \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/search-parameters.yaml b/drivers/ABB/insite-scu200/search-parameters.yaml new file mode 100644 index 0000000000..5cfaf45c83 --- /dev/null +++ b/drivers/ABB/insite-scu200/search-parameters.yaml @@ -0,0 +1,2 @@ +ssdp: + - searchTerm: urn:ABB:device:SCU200:1 \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/abb/api.lua b/drivers/ABB/insite-scu200/src/abb/api.lua new file mode 100644 index 0000000000..6524d86567 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/abb/api.lua @@ -0,0 +1,145 @@ +local log = require("log") +local st_utils = require "st.utils" +local json = require "st.json" + +-- Local imports +local config = require("config") +local utils = require("utils") +local RestClient = require "lunchbox.rest" + +-- API for the ABB SCU200 Bridge +local api = {} +api.__index = api + +local SSL_CONFIG = { + mode = "client", + protocol = "any", + verify = "none", + options = "all" +} + +local ADDITIONAL_HEADERS = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", +} + +-- Method for getting the base URL +local function get_base_url(bridge_ip) + return "https://" .. bridge_ip .. ":" .. config.REST_API_PORT +end + +-- Method for processing the REST response +local function process_rest_response(response, err, partial) + if err ~= nil then + return response, err, nil + elseif response ~= nil then + local status, decoded_json = pcall(json.decode, response:get_body()) + + if status and response.status == 200 then + log.debug("process_rest_response(): Response = " .. response.status .. " " .. response:get_body()) + + return decoded_json, nil, response.status + elseif status then + log.error("process_rest_response(): Response error = " .. response.status) + + return nil, "response status is not 200 OK", response.status + else + log.error("process_rest_response(): Failed to decode data") + + return nil, "failed to decode data", nil + end + else + return nil, "no response or error received", nil + end +end + +-- Method for creating a retry function +local function retry_fn(retry_attempts) + local count = 0 + + return function() + count = count + 1 + return count < retry_attempts + end +end + +-- Method for performing a GET request +local function do_get(api_instance, path) + log.debug("do_get(): Sending GET request to " .. path) + + return process_rest_response(api_instance.client:get(path, api_instance.headers, retry_fn(5))) +end + +-- Method for performing a POST request +local function do_post(api_instance, path, payload) + log.debug("do_post(): Sending POST request to " .. path .. " with payload " .. json.encode(payload)) + + return process_rest_response(api_instance.client:post(path, payload, api_instance.headers, retry_fn(5))) +end + +-- Method for creating a labeled socket builder +function api.labeled_socket_builder(label) + local socket_builder = utils.labeled_socket_builder(label, SSL_CONFIG) + + return socket_builder +end + +-- Method for creating a new bridge manager +function api.new_bridge_manager(bridge_ip, bridge_dni) + local base_url = get_base_url(bridge_ip) + local socket_builder = api.labeled_socket_builder(bridge_dni) + + return setmetatable( + { + headers = st_utils.deep_copy(ADDITIONAL_HEADERS), + client = RestClient.new(base_url, socket_builder), + base_url = base_url + }, + api + ) +end + +-- Method for getting the thing infos +function api.get_thing_infos(bridge_ip, bridge_dni) + local socket_builder = api.labeled_socket_builder(bridge_dni .. " (thing infos)") + local response, error, status = process_rest_response(RestClient.one_shot_get(get_base_url(bridge_ip) .. "/devices", ADDITIONAL_HEADERS, socket_builder)) + + if not error and status == 200 then + return response + else + log.error("api.get_thing_infos(): Failed to get thing infos, error = " .. error) + return nil + end +end + +-- Method for getting the bridge info +function api.get_bridge_info(bridge_ip, bridge_dni) + local socket_builder = api.labeled_socket_builder(bridge_dni .. " (bridge info)") + local response, error, status = process_rest_response(RestClient.one_shot_get(get_base_url(bridge_ip) .. "/bridge", ADDITIONAL_HEADERS, socket_builder)) + + if not error and status == 200 then + return response + else + log.error("api.get_bridge_info(): Failed to get thing infos, error = " .. error) + return nil + end +end + +-- API methods +function api:get_devices() + return do_get(self, "/devices") +end + +function api:get_device_by_id(id) + return do_get(self, string.format("/devices/%s", id)) +end + +function api:post_device_by_id(id, payload) + return do_post(self, string.format("/devices/%s/control", id), payload) +end + +function api:get_sse_url() + return self.base_url .. "/events" +end + +return api \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/abb/device_manager.lua b/drivers/ABB/insite-scu200/src/abb/device_manager.lua new file mode 100644 index 0000000000..a9de6c0811 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/abb/device_manager.lua @@ -0,0 +1,115 @@ +local log = require("log") + +-- Local imports +local utils = require("utils") +local fields = require("fields") +local api = require("abb.api") +local device_refresher = require("abb.device_refresher") + +-- Device manager methods +local device_manager = {} + +-- Method for checking if connection is valid +function device_manager.is_valid_connection(driver, device, conn_info) + local dni = utils.get_dni_from_device(device) + + if not conn_info then + log.error("device_manager.is_valid_connection(): Failed to find conn_info, dni = " .. dni) + return false + end + + local bridge_ip = utils.get_device_ip_address(device) + local thing_infos = api.get_thing_infos(bridge_ip, dni) + + if thing_infos and thing_infos.devices then + return true + else + log.error("device_manager.is_valid_connection(): Failed to get thing infos, dni = " .. dni) + return false + end +end + +-- Method for getting bridge connection info +function device_manager.get_bridge_connection_info(driver, bridge_dni, bridge_ip) + local bridge_conn_info = api.new_bridge_manager(bridge_ip, bridge_dni) + + if bridge_conn_info == nil then + log.error("device_manager.get_bridge_connection_info(): No bridge connection info") + end + + return bridge_conn_info +end + +-- Method for handling JSON status +function device_manager.handle_device_json(driver, device, device_json) + local dni = utils.get_dni_from_device(device) + if dni == nil then + log.error("device_manager.handle_device_json(): dni is nil, the device has been probably deleted") + return + end + + if not device_json then + log.error("device_manager.handle_device_json(): device_json is nil, dni = " .. dni) + return + end + + log.debug("device_manager.handle_device_json(): dni: " .. dni .. " device_json = " .. utils.dump(device_json)) + + local status = device_json.status + if status ~= nil then + if status == "offline" then + log.info("device_manager.handle_device_json(): status is offline, dni = " .. dni) + + device:offline() + return + elseif status == "online" then + device:online() + end + end + + local values = device_json.values + if values == nil then + log.error("device_manager.handle_device_json(): values is nil, dni = " .. dni) + return + end + + device_refresher.refresh_device(driver, device, values) +end + +-- Method for refreshing device +function device_manager.refresh(driver, device) + local dni = utils.get_dni_from_device(device) + local communication_device = device:get_parent_device() or device + local conn_info = communication_device:get_field(fields.CONN_INFO) + + if not conn_info then + log.warn("device_manager.refresh(): Failed to find conn_info, dni = " .. dni) + return + end + + local response, err, status = conn_info:get_device_by_id(dni) + + if err or status ~= 200 then + status = status or "nil" + log.error("device_manager.refresh(): Failed to get device by id, dni = " .. dni .. ", err = " .. err .. ", status = " .. status) + + device:offline() + + return + end + + device_manager.handle_device_json(driver, device, response) +end + +-- Method for monitoring the connection of the bridge devices +function device_manager.bridge_monitor(driver, device, bridge_info) + local child_devices = device:get_child_list() + + for _, thing_device in ipairs(child_devices) do + device.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + device_manager.refresh(driver, thing_device) + end) + end +end + +return device_manager \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/abb/device_refresher.lua b/drivers/ABB/insite-scu200/src/abb/device_refresher.lua new file mode 100644 index 0000000000..628c5b4201 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/abb/device_refresher.lua @@ -0,0 +1,382 @@ +local log = require("log") +local caps = require('st.capabilities') + +-- Local imports +local utils = require("utils") +local config = require("config") +local fields = require('fields') + +-- Controller for refreshing device data +local device_refresher = {} + +local function refresh_current_sensor(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_current_sensor(): Refreshing data of Current Sensor, dni = " .. dni) + + -- Refresh Current Measurement + local current = values.current + + if current ~= nil then + log.trace("refresh_current_sensor(): Refreshing Current Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.currentMeasurement.current({value=current, unit="A"})) + end + + -- Refresh Active Power + local activePower = values.activePower + + if activePower ~= nil then + log.trace("refresh_current_sensor(): Refreshing Active Power, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerMeter.power({value=activePower, unit="W"})) + end + + -- Refresh Active Energy + local activeEnergy = values.activeEnergy + + if activeEnergy ~= nil then + -- Refresh Active Energy + log.trace("refresh_current_sensor(): Refreshing Active Energy, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.energyMeter.energy({value=activeEnergy, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy values + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy consumption/production periodically + local current_consumption_production_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta consumption/production energy + local delta_consumption_production_report = 0.0 + if current_consumption_production_report ~= nil then + delta_consumption_production_report = math.max((activeEnergy * 1000) - current_consumption_production_report.energy, 0.0) + end + + -- Refresh Power Energy Report + log.trace("refresh_current_sensor(): Refreshing Energy Report, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergy * 1000, deltaEnergy=delta_consumption_production_report})) + + -- Save date of the last energy report + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_current_sensor(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +local function refresh_energy_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_energy_meter(): Refreshing data of Energy Meter, dni = " .. dni) + + -- Refresh Voltage Measurement + local voltage = values.voltage + + if voltage ~= nil then + log.trace("refresh_energy_meter(): Refreshing Voltage Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.voltageMeasurement.voltage({value=voltage, unit="V"})) + end + + -- Refresh Current Measurement + local current = values.current + + if current ~= nil then + log.trace("refresh_energy_meter(): Refreshing Current Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.currentMeasurement.current({value=current, unit="A"})) + end + + -- Refresh Active Power + local activePower = values.activePowerTotal + + if activePower ~= nil then + log.trace("refresh_energy_meter(): Refreshing Active Power, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerMeter.power({value=activePower, unit="W"})) + end + + -- Refresh Active Energy Net, Import & Export Total + local activeEnergyNetTotal = values.activeEnergyNetTotal + local activeEnergyImportTotal = values.activeEnergyImportTotal + local activeEnergyExportTotal = values.activeEnergyExportTotal + + if activeEnergyNetTotal == nil then + if activeEnergyImportTotal ~= nil and activeEnergyExportTotal ~= nil then + -- If only Import and Export Total are available, calculate Net Total + activeEnergyNetTotal = activeEnergyImportTotal - activeEnergyExportTotal + end + end + + local activeEnergyNetPositive = math.max(activeEnergyNetTotal, 0.0) + local activeEnergyNetNegative = math.min(activeEnergyNetTotal, 0.0) * -1 -- Convert to positive value + + if activeEnergyNetTotal ~= nil and activeEnergyImportTotal ~= nil and activeEnergyExportTotal ~= nil then + -- Refresh Active Energy Net Positive + log.trace("refresh_energy_meter(): Refreshing Active Energy Net Positive, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.energyMeter.energy({value=activeEnergyNetPositive, unit="kWh"})) + + -- Refresh Active Energy Import Total + log.trace("refresh_energy_meter(): Refreshing Active Energy Import Total, dni = " .. dni) + device.profile.components["consumptionMeter"]:emit_event(caps.energyMeter.energy({value=activeEnergyImportTotal, unit="kWh"})) + + -- Refresh Active Energy Export Total + log.trace("refresh_energy_meter(): Refreshing Active Energy Export Total, dni = " .. dni) + device.profile.components["productionMeter"]:emit_event(caps.energyMeter.energy({value=activeEnergyExportTotal, unit="kWh"})) + + -- Refresh Active Energy Net Negative + log.trace("refresh_energy_meter(): Refreshing Active Energy Net Negative, dni = " .. dni) + device.profile.components["surplus"]:emit_event(caps.energyMeter.energy({value=activeEnergyNetNegative, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy net, consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy net, consumption and production periodically + local current_net_positive_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_consumption_report = device:get_latest_state("consumptionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_production_report = device:get_latest_state("productionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_net_negative_report = device:get_latest_state("surplus", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta net, consumption and production energy + local delta_net_positive_report = 0.0 + if current_net_positive_report ~= nil then + delta_net_positive_report = math.max((activeEnergyNetPositive * 1000) - current_net_positive_report.energy, 0.0) + end + + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((activeEnergyImportTotal * 1000) - current_consumption_report.energy, 0.0) + end + + local delta_production_energy = 0.0 + if current_production_report ~= nil then + delta_production_energy = math.max((activeEnergyExportTotal * 1000) - current_production_report.energy, 0.0) + end + + local delta_net_negative_report = 0.0 + if current_net_negative_report ~= nil then + delta_net_negative_report = math.max((activeEnergyNetNegative * 1000) - current_net_negative_report.energy, 0.0) + end + + -- Refresh Power Net Positive, Consumption, Production & Net Negative Report + log.trace("refresh_energy_meter(): Refreshing Power Net Positive, Consumption, Production & Net Negative Report, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyNetPositive * 1000, deltaEnergy=delta_net_positive_report})) + device.profile.components["consumptionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyImportTotal * 1000, deltaEnergy=delta_consumption_energy})) + device.profile.components["productionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyExportTotal * 1000, deltaEnergy=delta_production_energy})) + device.profile.components["surplus"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyNetNegative * 1000, deltaEnergy=delta_net_negative_report})) + + -- Save date of the last consumption + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_energy_meter(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +local function refresh_auxiliary_contact(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_auxiliary_contact(): Refreshing data of Auxiliary Contact, dni = " .. dni) + + -- Refresh Contact Sensor + local isClosed = values.isClosed + + if isClosed ~= nil then + log.trace("refresh_auxiliary_contact(): Refreshing Switch, dni = " .. dni) + + if isClosed == 1 then + isClosed = true + else + isClosed = false + end + + if isClosed then + device:emit_event(caps.switch.switch.on()) + else + device:emit_event(caps.switch.switch.off()) + end + end + + return true +end + +local function refresh_output_module(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_output_module(): Refreshing data of Output Module, dni = " .. dni) + + -- Refresh Switch + local isClosed = values.isClosed + log.trace("refresh_output_module(): Refreshing Switch, dni = " .. dni) + + if isClosed == 1 then + isClosed = true + else + isClosed = false + end + + if isClosed then + device:emit_event(caps.switch.switch.on()) + else + device:emit_event(caps.switch.switch.off()) + end + + return true +end + +local function refresh_water_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_water_meter(): Refreshing data of Water Meter, dni = " .. dni) + + local unit = values.unit + if unit == nil then + log.error("refresh_water_meter(): The unit of the water meter is not set, dni = " .. dni) + return false + end + + -- Refresh Water Meter: last hour + local lastHourFlow = values.lastHourFlow + + if lastHourFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last hour, dni = " .. dni) + device:emit_event(caps.waterMeter.lastHour({value=lastHourFlow, unit=unit})) + end + + -- Refresh Water Meter: last 24 hours + local lastTwentyFourHoursFlow = values.lastTwentyFourHoursFlow + + if lastTwentyFourHoursFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last 24 hours, dni = " .. dni) + device:emit_event(caps.waterMeter.lastTwentyFourHours({value=lastTwentyFourHoursFlow, unit=unit})) + end + + -- Refresh Water Meter: last 7 days + local lastSevenDaysFlow = values.lastSevenDaysFlow + + if lastSevenDaysFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last 7 days, dni = " .. dni) + device:emit_event(caps.waterMeter.lastSevenDays({value=lastSevenDaysFlow, unit=unit})) + end + + return true +end + +local function refresh_gas_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_gas_meter(): Refreshing data of Gas Meter, dni = " .. dni) + + local gasMeterVolumeUnit = values.gasMeterVolumeUnit + if gasMeterVolumeUnit == nil then + log.error("refresh_gas_meter(): The unit of the gas meter is not set, dni = " .. dni) + return false + end + + -- Correct the unit if necessary + if gasMeterVolumeUnit == "m3" then + gasMeterVolumeUnit = "m^3" + end + + -- Refresh Gas Meter + local gasMeterVolume = values.gasMeterVolume + + if gasMeterVolume ~= nil then + log.trace("refresh_gas_meter(): Refreshing Gas Meter, dni = " .. dni) + device:emit_event(caps.gasMeter.gasMeterVolume({value=gasMeterVolume, unit=gasMeterVolumeUnit})) + end + + return true +end + +local function refresh_usb_energy_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_usb_energy_meter(): Refreshing data of USB Energy Meter, dni = " .. dni) + + -- Refresh Active Power Import Total + local activePowerImportTotal = values.activePowerImportTotal + + if activePowerImportTotal ~= nil then + log.trace("refresh_usb_energy_meter(): Refreshing Active Power Import Total, dni = " .. dni) + device:emit_event(caps.powerMeter.power({value=activePowerImportTotal, unit="W"})) + end + + -- Refresh Active Energy Import Total + local activeEnergyImportTotal = values.activeEnergyImportTotal + + if activeEnergyImportTotal ~= nil then + log.trace("refresh_usb_energy_meter(): Refreshing Active Energy Import Total, dni = " .. dni) + device:emit_event(caps.energyMeter.energy({value=activeEnergyImportTotal, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy consumption periodically + local current_consumption_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta consumption energy + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((activeEnergyImportTotal * 1000) - current_consumption_report.energy, 0.0) + end + + -- Refresh Power Consumption Report + log.trace("refresh_usb_energy_meter(): Refreshing Power Consumption Report, dni = " .. dni) + device:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyImportTotal * 1000, deltaEnergy=delta_consumption_energy})) + + -- Save date of the last consumption + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_usb_energy_meter(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +function device_refresher.refresh_device(driver, device, values) + local dni, device_type = utils.get_dni_from_device(device) + log.info("device_refresher.refresh_device(): Refreshing data of device, dni = " .. dni) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + log.debug("device_refresher.refresh_device(): Cannot refresh bridge device, dni = " .. dni) + return + end + + log.debug("device_refresher.refresh_device(): Provided values: " .. utils.dump(values)) + + local refresh_methods = { + [utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE)] = refresh_current_sensor, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = refresh_energy_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = refresh_auxiliary_contact, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = refresh_output_module, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = refresh_energy_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = refresh_water_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = refresh_gas_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = refresh_usb_energy_meter + } + + local device_model = utils.get_device_model(device) + if device_model == nil then + log.error("device_refresher.refresh_device(): No device model found for device, dni = " .. dni) + return + end + + local refresh_method = refresh_methods[device_model] + if refresh_method == nil then + log.error("device_refresher.refresh_device(): No refresh method found for device, dni = " .. dni .. ", model = " .. device_model) + return + end + + return refresh_method(driver, device, values) +end + +return device_refresher \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/commands.lua b/drivers/ABB/insite-scu200/src/commands.lua new file mode 100644 index 0000000000..453d0f521f --- /dev/null +++ b/drivers/ABB/insite-scu200/src/commands.lua @@ -0,0 +1,111 @@ +local caps = require('st.capabilities') +local log = require('log') +local json = require('dkjson') + +-- Local imports +local utils = require('utils') +local fields = require('fields') +local config = require("config") +local device_manager = require('abb.device_manager') +local connection_monitor = require('connection_monitor') + +-- Commands handler for the bridge and thing devices +local commands = {} + +-- Method for posting the payload to the device +local function post_payload(device, payload) + local dni = utils.get_dni_from_device(device) + local communication_device = device:get_parent_device() or device + local conn_info = communication_device:get_field(fields.CONN_INFO) + + local _, err, status = conn_info:post_device_by_id(dni, payload) + if not err and status == 200 then + log.info("post_payload(): Success, dni = " .. dni) + + return true + else + status = status or "nil" + log.error("post_payload(): Error, err = " .. err .. ", status = " .. status .. ", dni = " .. dni) + + device:offline() + + return false + end +end + +-- Switch on command +function commands.switch_on(driver, device, cmd) + local dni, _ = utils.get_dni_from_device(device) + log.info("commands.switch_on(): Switching on capablity within dni = " .. dni) + + local device_model = utils.get_device_model(device) + + local payload = nil + local event = nil + if device_model == utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE) then + payload = json.encode({capability = cmd.capability, command = cmd.command}) + event = caps.switch.switch.on() + end + + if payload ~= nil and event ~= nil then + local bridge = device:get_parent_device() + + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + local success = post_payload(device, payload) + if success then + device:emit_event(event) + end + end) + end +end + +-- Switch off commands +function commands.switch_off(driver, device, cmd) + local dni, _ = utils.get_dni_from_device(device) + log.info("commands.switch_off(): Switching off capablity within dni = " .. dni) + + local device_model = utils.get_device_model(device) + + local payload = nil + local event = nil + if device_model == utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE) then + payload = json.encode({capability = cmd.capability, command = cmd.command}) + event = caps.switch.switch.off() + end + + if payload ~= nil and event ~= nil then + local bridge = device:get_parent_device() + + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + local success = post_payload(device, payload) + if success then + device:emit_event(event) + end + end) + end +end + +-- Refresh command +function commands.refresh(driver, device, cmd) + local dni, device_type = utils.get_dni_from_device(device) + log.info("commands.refresh(): Refresh capability within dni = " .. dni) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + connection_monitor.check_and_update_connection(driver, device) + local child_devices = device:get_child_list() + + for _, thing_device in ipairs(child_devices) do + device_manager.refresh(driver, thing_device) + end + elseif device_type == fields.DEVICE_TYPE_THING then + local bridge = device:get_parent_device() + + if bridge.thread ~= nil then + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + device_manager.refresh(driver, device) + end) + end + end +end + +return commands \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/config.lua b/drivers/ABB/insite-scu200/src/config.lua new file mode 100644 index 0000000000..8470708d9e --- /dev/null +++ b/drivers/ABB/insite-scu200/src/config.lua @@ -0,0 +1,72 @@ +local config = {} + +-- Device Config +config.DEVICE_TYPE = "LAN" + +config.MANUFACTURER = "ABB" + +config.BRIDGE_PROFILE = "abb.scu200.bridge.v1" +config.BRIDGE_TYPE = "SCU200" +config.BRIDGE_VERSION = "1" + +config.BRIDGE_URN = "urn:" .. config.MANUFACTURER .. ":device:" .. config.BRIDGE_TYPE .. ":" .. config.BRIDGE_VERSION + +config.BRIDGE_CONN_MONITOR_INTERVAL = 300 -- 5 minutes + +-- Edge Child Config +config.EDGE_CHILD_TYPE = "EDGE_CHILD" + +config.EDGE_CHILD_CURRENT_SENSOR_TYPE = "CurrentSensor" +config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE = "EnergyMeterModule" +config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE = "AuxiliaryContact" +config.EDGE_CHILD_OUTPUT_MODULE_TYPE = "OutputModule" +config.EDGE_CHILD_ENERGY_METER_TYPE = "EnergyMeter" +config.EDGE_CHILD_WATER_METER_TYPE = "WaterMeter" +config.EDGE_CHILD_GAS_METER_TYPE = "GasMeter" +config.EDGE_CHILD_USB_ENERGY_METER_TYPE = "USBEnergyMeter" + +config.EDGE_CHILD_CURRENT_SENSOR_VERSION = 1 +config.EDGE_CHILD_ENERGY_METER_MODULE_VERSION = 1 +config.EDGE_CHILD_AUXILIARY_CONTACT_VERSION = 1 +config.EDGE_CHILD_OUTPUT_MODULE_VERSION = 1 +config.EDGE_CHILD_ENERGY_METER_VERSION = 1 +config.EDGE_CHILD_WATER_METER_VERSION = 1 +config.EDGE_CHILD_GAS_METER_VERSION = 1 +config.EDGE_CHILD_USB_ENERGY_METER_VERSION = 1 + +config.EDGE_CHILD_CURRENT_SENSOR_CONSUMPTION_PROFILE = "abb.scu200.current-sensor-consumption.v1" +config.EDGE_CHILD_CURRENT_SENSOR_PRODUCTION_PROFILE = "abb.scu200.current-sensor-production.v1" +config.EDGE_CHILD_AUXILIARY_CONTACT_PROFILE = "abb.scu200.auxiliary-contact.v1" +config.EDGE_CHILD_OUTPUT_MODULE_PROFILE = "abb.scu200.output-module.v1" +config.EDGE_CHILD_ENERGY_METER_PROFILE = "abb.scu200.energy-meter.v1" +config.EDGE_CHILD_WATER_METER_PROFILE = "abb.scu200.water-meter.v1" +config.EDGE_CHILD_GAS_METER_PROFILE = "abb.scu200.gas-meter.v1" +config.EDGE_CHILD_USB_ENERGY_METER_PROFILE = "abb.scu200.usb-energy-meter.v1" + +config.EDGE_CHILD_CURRENT_SENSOR_REFRESH_PERIOD = 30 +config.EDGE_CHILD_ENERGY_METER_MODULE_REFRESH_PERIOD = 30 +config.EDGE_CHILD_AUXILIARY_CONTACT_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_OUTPUT_MODULE_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_ENERGY_METER_REFRESH_PERIOD = 30 +config.EDGE_CHILD_WATER_METER_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_GAS_METER_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_USB_ENERGY_METER_REFRESH_PERIOD = 30 + +config.EDGE_CHILD_ENERGY_REPORT_INTERVAL = 900 -- 15 minutes + +-- REST API Config +config.REST_API_PORT = 1025 + +-- SSDP Config +config.MC_ADDRESS = "239.255.255.250" +config.MC_PORT = 1900 +config.MC_TIMEOUT = 5 +config.MSEARCH = table.concat({ + "M-SEARCH * HTTP/1.1", + "HOST: 239.255.255.250:1900", + "MAN: \"ssdp:discover\"", + "MX: 5", + "ST: " .. config.BRIDGE_URN +}, "\r\n") + +return config diff --git a/drivers/ABB/insite-scu200/src/connection_monitor.lua b/drivers/ABB/insite-scu200/src/connection_monitor.lua new file mode 100644 index 0000000000..bdeb2b9621 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/connection_monitor.lua @@ -0,0 +1,71 @@ +local log = require("log") + +-- Local imports +local fields = require("fields") +local utils = require("utils") +local discovery = require("discovery") +local eventsource_handler = require("eventsource_handler") +local device_manager = require("abb.device_manager") + +-- Connection monitor for the SCU200 Bridge +local connection_monitor = {} + +function connection_monitor.update_connection(driver, device, bridge_ip) + local bridge_dni = utils.get_dni_from_device(device) + log.info("connection_monitor.update_connection(): Update connection for bridge device: " .. bridge_dni) + + local conn_info = device_manager.get_bridge_connection_info(driver, bridge_dni, bridge_ip) + + if device_manager.is_valid_connection(driver, device, conn_info) then + device:set_field(fields.CONN_INFO, conn_info) + eventsource_handler.create_sse(driver, device) + end +end + +local function find_new_connection(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("find_new_connection(): Find new connection for dni = " .. dni) + + local found_devices = discovery.find_devices() + + if found_devices ~= nil then + local found_device = found_devices[dni] + + if found_device then + log.info("find_new_connection(): Found new connection for dni = " .. dni) + + local ip = found_device.ip + + device:set_field(fields.BRIDGE_IPV4, ip, {persist = true}) + connection_monitor.update_connection(driver, device, ip) + end + end +end + +function connection_monitor.check_and_update_connection(driver, device) + local dni = utils.get_dni_from_device(device) + local conn_info = device:get_field(fields.CONN_INFO) + + if not device_manager.is_valid_connection(driver, device, conn_info) then + log.error("connection_monitor.check_and_update_connection(): Disconnected from device. Try to find new connection for dni = " .. dni) + + find_new_connection(driver, device) + end +end + +-- Method for monitoring the connection of the bridge devices +function connection_monitor.monitor_connections(driver) + local device_list = driver:get_devices() + + for _, device in ipairs(device_list) do + if device:get_field(fields.DEVICE_TYPE) == fields.DEVICE_TYPE_BRIDGE then + local dni = utils.get_dni_from_device(device) + log.info("connection_monitor.monitor_connections(): Monitoring connection for bridge device: " .. dni) + + connection_monitor.check_and_update_connection(driver, device) + device_manager.bridge_monitor(driver, device) + end + end +end + +return connection_monitor \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/discovery.lua b/drivers/ABB/insite-scu200/src/discovery.lua new file mode 100644 index 0000000000..3958c48026 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/discovery.lua @@ -0,0 +1,315 @@ +local log = require "log" +local socket = require('socket') +local cosock = require "cosock" + +-- Local imports +local api = require("abb.api") +local config = require("config") +local utils = require("utils") +local fields = require("fields") + +-- Discovery service run within SmartThings app +local discovery = {} + +local joined_bridge = {} +local joined_thing = {} + +-- Method for setting the device fields +function discovery.set_device_fields(driver, device) + local dni = utils.get_dni_from_device(device) + + if joined_bridge[dni] ~= nil then + log.info("discovery.set_device_field(): Setting device field for bridge: " .. dni) + local bridge_cache_value = driver.datastore.bridge_discovery_cache[dni] + + device:set_field(fields.BRIDGE_IPV4, bridge_cache_value.ip, {persist = true}) + device:set_field(fields.DEVICE_TYPE, fields.DEVICE_TYPE_BRIDGE, {persist = true}) + elseif joined_thing[dni] ~= nil then + log.info("discovery.set_device_field(): Setting device field for thing: " .. dni) + local thing_cache_value = driver.datastore.thing_discovery_cache[dni] + + device:set_field(fields.PARENT_BRIDGE_DNI, thing_cache_value.parent_bridge_dni, {persist = true}) + device:set_field(fields.THING_INFO, thing_cache_value.thing_info, {persist = true}) + device:set_field(fields.DEVICE_TYPE, fields.DEVICE_TYPE_THING, {persist = true}) + else + log.warn("discovery.set_device_field(): Could not set device field for unknown device: " .. dni) + end +end + +-- Method for updating the bridge discovery cache +local function update_bridge_discovery_cache(driver, dni, device) + log.info("update_bridge_discovery_cache(): Updating bridge discovery cache: " .. dni) + + driver.datastore.bridge_discovery_cache[dni] = { + ip = device["ip"] + } +end + +-- Method for updating the thing discovery cache +local function update_thing_discovery_cache(driver, thing_dni, parent_bridge_dni, thing_info) + log.info("update_thing_discovery_cache(): Updating thing discovery cache: " .. thing_dni) + + driver.datastore.thing_discovery_cache[thing_dni] = { + parent_bridge_dni = parent_bridge_dni, + thing_info = thing_info, + } +end + +-- Method for trying to add a new bridge +local function try_add_bridge(driver, dni, device) + log.info("try_add_bridge(): Trying to add bridge: " .. dni) + + local bridge_info = api.get_bridge_info(device["ip"], dni) + if bridge_info == nil then + log.error("try_add_bridge(): Failed to get bridge info for bridge: " .. dni) + return false + end + + update_bridge_discovery_cache(driver, dni, device) + + local metadata = { + type = config.DEVICE_TYPE, + device_network_id = dni, + label = bridge_info.name, + profile = config.BRIDGE_PROFILE, + manufacturer = config.MANUFACTURER, + model = config.BRIDGE_TYPE, + vendor_provided_label = config.BRIDGE_TYPE + } + + local success, err = driver:try_create_device(metadata) + + if success then + log.debug("try_add_bridge(): Bridge created: " .. dni) + + return true + else + log.error("try_add_bridge(): Failed to create bridge: " .. dni) + log.debug("try_add_bridge(): Error: " .. err) + + return false + end +end + +-- Method for trying to add a new thing +local function try_add_thing(driver, parent_device, thing_dni, thing_info) + local parent_device_dni = utils.get_dni_from_device(parent_device) + log.info("try_add_thing(): Trying to add thing: " .. thing_dni .. " of type: " .. thing_info.type .. " on bridge: " .. parent_device_dni) + + update_thing_discovery_cache(driver, thing_dni, parent_device_dni, thing_info) + + if thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE) or thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE) then + log.warn("try_add_thing(): Not supported thing type: " .. thing_info.type) + return false + elseif thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE) and thing_info.properties.isExport then + log.warn("try_add_thing(): Current sensor with production data is not supported") + return false + end + + local profile_ref = utils.get_thing_profile_ref(thing_info) + if profile_ref == nil then + log.error("try_add_thing(): Failed to get profile reference for thing: " .. thing_dni) + return false + end + + local parent_device_id = utils.get_device_id_from_device(parent_device) + + local metadata = { + type = config.EDGE_CHILD_TYPE, + label = thing_info.name, + vendor_provided_label = thing_info.name, + profile = profile_ref, + manufacturer = config.MANUFACTURER, + model = thing_info.type, + parent_device_id = parent_device_id, + parent_assigned_child_key = thing_info.uuid, + } + + local success, err = driver:try_create_device(metadata) + + if success then + log.debug("try_add_thing(): Thing created: " .. thing_dni) + + return true + else + log.error("try_add_thing(): Failed to create thing: " .. thing_dni) + log.debug("try_add_thing(): Error: " .. err) + + return false + end +end + +-- SSDP Response parser +local function parse_ssdp(data) + local res = {} + + res.status = data:sub(0, data:find('\r\n')) + + for line in data:gmatch("[^\r\n]+") do + local _, _, header, value = string.find(line, "([%w-]+):%s*([%a+-:_ /=?]*)") + + if header ~= nil and value ~= nil then + res[header:lower()] = value + end + end + + return res +end + +-- Method for finding devices +function discovery.find_devices() + log.info("discovery.find_devices(): Finding devices") + + -- Initialize UDP socket + local upnp = cosock.socket.udp() + + upnp:setsockname('*', 0) + upnp:setoption("broadcast", true) + upnp:settimeout(config.MC_TIMEOUT) + + -- Broadcast M-SEARCH request + log.info("discovery.find_devices(): Scanning network...") + + upnp:sendto(config.MSEARCH, config.MC_ADDRESS, config.MC_PORT) + + -- Listen for responses + local devices = {} + local start_time = socket.gettime() + + while (socket.gettime() - start_time) < config.MC_TIMEOUT do + local res = upnp:receivefrom() + + if res ~= nil then + local device = parse_ssdp(res) + local dni = string.match(device["usn"], "^uuid:([a-zA-Z0-9-]+)::" .. config.BRIDGE_URN .. "$") + + if dni ~= nil then + local _, _, device_ip = string.find(device["location"], "https?://(%d+%.%d+%.%d+%.%d+):?%d*/?.*") + device["ip"] = device_ip + + devices[dni] = device + end + end + end + + -- Print found devices + if next(devices) then + for dni, device in pairs(devices) do + log.debug("discovery.find_devices(): Device found: " .. utils.dump(device)) + end + else + log.debug("discovery.find_devices(): No devices found") + end + + -- Close the UDP socket + upnp:close() + + log.debug("discovery.find_devices(): Stop scanning network") + + if devices ~= nil then + return devices + end + + return nil +end + +-- Start the discovery of bridges +local function discover_bridges(driver) + log.info("discover_bridges(): Discovering bridges") + + -- Get the known devices + local known_devices = {} + + for _, device in pairs(driver:get_devices()) do + local dni, device_type = utils.get_dni_from_device(device) + known_devices[dni] = device + + log.debug("discover_bridges(): Known devices: " .. dni .. " with type: " .. device_type) + end + + -- Find new devices + local found_devices = discovery.find_devices() + + if found_devices ~= nil then + for dni, device in pairs(found_devices) do + if not known_devices or not known_devices[dni] then + log.info("discover_bridges(): Found new bridge: " .. dni) + + if not joined_bridge[dni] then + if try_add_bridge(driver, dni, device) then + joined_bridge[dni] = true + end + else + log.debug("discover_bridges(): Bridge already joined: " .. dni) + end + else + log.debug("discover_bridges(): Bridge already added: " .. dni) + end + end + end +end + +-- Start the discovery of things +local function discover_things(driver) + log.info("discover_things(): Discovering things") + + -- Get the known devices + local known_devices = {} + + for _, device in pairs(driver:get_devices()) do + local dni, device_type = utils.get_dni_from_device(device) + known_devices[dni] = device + + log.debug("discover_things(): Known devices: " .. dni .. " with type: " .. device_type) + end + + -- Found new devices + for bridge_dni, bridge_cache_value in pairs(driver.datastore.bridge_discovery_cache) do + local bridge_ip = bridge_cache_value.ip + log.info("discover_things(): Fetching things from bridge: " .. bridge_dni .. " at IP: " .. bridge_ip) + + if known_devices[bridge_dni] ~= nil and known_devices[bridge_dni]:get_field(fields.CONN_INFO) ~= nil then + local thing_infos = api.get_thing_infos(bridge_ip, bridge_dni) + + if thing_infos and thing_infos.devices ~= nil then + for _, thing_info in pairs(thing_infos.devices) do + if thing_info ~= nil then + local thing_dni = thing_info.uuid + + log.info("discover_things(): Found thing: " .. thing_dni .. " on bridge: " .. bridge_dni) + + if thing_dni ~= nil then + if not known_devices[thing_dni] then + if try_add_thing(driver, known_devices[bridge_dni], thing_dni, thing_info) then + joined_thing[thing_dni] = true + end + elseif not joined_thing[thing_dni] then + log.debug("discover_things(): Thing already known: " .. thing_dni) + else + log.debug("discover_things(): Thing already joined: " .. thing_dni) + end + end + end + + cosock.socket.sleep(0.2) + end + end + end + end +end + +-- Main function to start the discovery service +function discovery.start(driver, _, should_continue) + log.info("discovery.start(): Starting discovery") + + while should_continue() do + discover_bridges(driver) + discover_things(driver) + + cosock.socket.sleep(0.2) + end + + log.info("discovery.start(): Ending discovery") +end + +return discovery \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/eventsource_handler.lua b/drivers/ABB/insite-scu200/src/eventsource_handler.lua new file mode 100644 index 0000000000..82e7131b87 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/eventsource_handler.lua @@ -0,0 +1,128 @@ +local log = require('log') +local json = require('dkjson') + +-- Local imports +local EventSource = require "lunchbox.sse.eventsource" +local device_manager = require("abb.device_manager") +local device_refresher = require("abb.device_refresher") +local fields = require "fields" +local utils = require "utils" + +local eventsource_handler = {} + +-- Method for handling an incoming SSE event +function eventsource_handler.handle_sse_event(driver, bridge, msg) + log.debug("eventsource_handler.handle_sse_event(): Handling SSE event (TYPE: " .. msg.type .. ", DATA: " .. msg.data .. ")") + + if msg.type == "valueChanged" then + local data = json.decode(msg.data) + + if data ~= nil and next(data) ~= nil then + -- Find the device + local device = nil + + local child_devices = bridge:get_child_list() + for _, thing_device in ipairs(child_devices) do + local dni = utils.get_dni_from_device(thing_device) + + if dni == data.uuid then + device = thing_device + break + end + end + + if device == nil then + log.warn("eventsource_handler.handle_sse_event(): Failed to find the device with dni: " .. data.uuid) + return + end + + -- Prepare the values + local values = {} + + values[data.attribute.name] = data.attribute.value + + -- Refresh the device + if device_refresher.refresh_device(driver, device, values) then + -- Define online status + device:online() + else + log.error("eventsource_handler.handle_sse_event(): Failed to update the device's values") + + -- Set device as offline + device:offline() + end + else + log.error("eventsource_handler.handle_sse_event(): Failed to decode JSON data: " .. msg.data) + end + elseif msg.type == "noDevices" then + log.info("eventsource_handler.handle_sse_event(): No devices to monitor found") + + eventsource_handler.close_sse(driver, bridge) + elseif msg.type == "refreshConnection" then + log.info("eventsource_handler.handle_sse_event(): Refreshing connection") + + eventsource_handler.close_sse(driver, bridge) + eventsource_handler.create_sse(driver, bridge) + else + log.warn("eventsource_handler.handle_sse_event(): Unknown SSE event type: " .. msg.type) + end +end + +-- Method for creating SSE +function eventsource_handler.create_sse(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("eventsource_handler.create_sse(): Creating SSE for dni: " .. dni) + + local conn_info = device:get_field(fields.CONN_INFO) + + if not device_manager.is_valid_connection(driver, device, conn_info) then + log.error("eventsource_handler.create_sse(): Invalid connection for dni: " .. dni) + return + end + + local sse_url = conn_info:get_sse_url() + if not sse_url then + log.error("eventsource_handler.create_sse(): Failed to get sse_url for dni: " .. dni) + return + end + + log.trace("eventsource_handler.create_sse(): Creating SSE EventSource for " .. dni .. " with sse_url: " .. sse_url) + local eventsource = EventSource.new(sse_url, {}, nil, nil) + + eventsource.onmessage = function(msg) + if msg then + eventsource_handler.handle_sse_event(driver, device, msg) + end + end + + eventsource.onerror = function() + log.error("eventsource_handler.create_sse(): Error in the eventsource for dni: " .. dni) + device:offline() + end + + eventsource.onopen = function(msg) + log.info("eventsource_handler.create_sse(): Eventsource has been opened for dni: " .. dni) + device:online() + end + + local old_eventsource = device:get_field(fields.EVENT_SOURCE) + if old_eventsource then + log.info("eventsource_handler.create_sse(): Eventsource has been closed for dni: " .. dni) + old_eventsource:close() + end + device:set_field(fields.EVENT_SOURCE, eventsource) +end + +-- Method for closing SSE +function eventsource_handler.close_sse(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("eventsource_handler.close_sse(): Closing SSE for dni: " .. dni) + + local eventsource = device:get_field(fields.EVENT_SOURCE) + if eventsource then + log.info("eventsource_handler.close_sse(): Closing eventsource for device: " .. dni) + eventsource:close() + end +end + +return eventsource_handler diff --git a/drivers/ABB/insite-scu200/src/fields.lua b/drivers/ABB/insite-scu200/src/fields.lua new file mode 100644 index 0000000000..7c56019cc1 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/fields.lua @@ -0,0 +1,16 @@ +-- Table of constants used to index in to device store fields +local fields = { + BRIDGE_IPV4 = "bridge_ipv4", + THING_INFO = "thing_info", + CONN_INFO = "conn_info", + PARENT_BRIDGE_DNI = "parent_bridge_dni", + EVENT_SOURCE = "eventsource", + DEVICE_TYPE = "devcie_type", + LAST_ENERGY_REPORT = "last_energy_report", + _INIT = "init", + + DEVICE_TYPE_BRIDGE = "bridge", + DEVICE_TYPE_THING = "thing" +} + +return fields \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/init.lua b/drivers/ABB/insite-scu200/src/init.lua new file mode 100644 index 0000000000..df33d18b1c --- /dev/null +++ b/drivers/ABB/insite-scu200/src/init.lua @@ -0,0 +1,45 @@ +local log = require('log') +local Driver = require('st.driver') +local caps = require('st.capabilities') + +-- Local imports +local discovery = require('discovery') +local commands = require('commands') +local config = require('config') +local lifecycles = require('lifecycles') +local connection_monitor = require('connection_monitor') + +-- Driver definition +local driver = Driver("ABB.SCU200", { + discovery = discovery.start, + lifecycle_handlers = lifecycles, + capability_handlers = { + -- Refresh command handler + [caps.refresh.ID] = { + [caps.refresh.commands.refresh.NAME] = commands.refresh + }, + [caps.switch.ID] = { + [caps.switch.commands.on.NAME] = commands.switch_on, + [caps.switch.commands.off.NAME] = commands.switch_off + } + } +}) + +-- Prepare datastores for bridge and thing discovery caches +if driver.datastore.bridge_discovery_cache == nil then + driver.datastore.bridge_discovery_cache = {} +end + +if driver.datastore.thing_discovery_cache == nil then + driver.datastore.thing_discovery_cache = {} +end + +-- Connection monitoring thread +driver:call_on_schedule(config.BRIDGE_CONN_MONITOR_INTERVAL, connection_monitor.monitor_connections, "SCU200 Bridge connection monitoring thread") + +-- Initialize driver +log.info("Starting driver") + +driver:run() + +log.warn("Exiting driver") \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lifecycles.lua b/drivers/ABB/insite-scu200/src/lifecycles.lua new file mode 100644 index 0000000000..83cdfa0eb7 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lifecycles.lua @@ -0,0 +1,87 @@ +local log = require('log') + +-- Local imports +local fields = require('fields') +local utils = require('utils') +local discovery = require('discovery') +local commands = require('commands') +local connection_monitor = require('connection_monitor') +local eventsource_handler = require("eventsource_handler") + +-- Lifecycles handlers for the driver +local lifecycles = {} + +-- Lifecycle handler for a device which has been initialized +function lifecycles.init(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + + -- Verify if the device has already been initialized + if device:get_field(fields._INIT) then + log.info("lifecycles.init(): Device already initialized: " .. dni .. " of type: " .. device_type) + return + end + + log.info("lifecycles.init(): Initializing device: " .. dni .. " of type: " .. device_type) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + if driver.datastore.bridge_discovery_cache[dni] then + log.debug("lifecycles.init(): Setting unsaved bridge fields") + discovery.set_device_fields(driver, device) + end + + local bridge_ip = device:get_field(fields.BRIDGE_IPV4) + + connection_monitor.update_connection(driver, device, bridge_ip) + elseif device_type == fields.DEVICE_TYPE_THING then + if driver.datastore.thing_discovery_cache[dni] then + log.debug("lifecycles.init(): Setting unsaved thing fields") + discovery.set_device_fields(driver, device) + end + + -- Refresh the device manually + commands.refresh(driver, device, nil) + + -- Refresh schedule + local refresh_period = utils.get_thing_refresh_period(device) + + device.thread:call_on_schedule( + refresh_period, + function () + return commands.refresh(driver, device, nil) + end, + "Refresh schedule") + end + + -- Set the device as initialized + device:set_field(fields._INIT, true, {persist = false}) +end + +-- Lifecycle handler for a device which has been added +function lifecycles.added(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + log.info("lifecycles.added(): Adding device: " .. dni .. " of type: " .. device_type) + + -- Force the initialization due to cases where the device is not initialized after being added + lifecycles.init(driver, device) +end + +-- Lifecycle handler for a device which has been removed +function lifecycles.removed(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + log.info("lifecycles.removed(): Removing device: " .. dni .. " of type: " .. device_type) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + log.debug("lifecycles.removed(): Closing SSE for device: " .. dni) + + eventsource_handler.close_sse(driver, device) + elseif device_type == fields.DEVICE_TYPE_THING then + log.debug("lifecycles.removed(): Removing schedules for device: " .. dni) + + -- Remove the schedules to avoid unnecessary CPU processing + for timer in pairs(device.thread.timers) do + device.thread:cancel_timer(timer) + end + end +end + +return lifecycles \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/init.lua b/drivers/ABB/insite-scu200/src/lunchbox/init.lua new file mode 100644 index 0000000000..d454f39e68 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/init.lua @@ -0,0 +1,4 @@ +local RestClient = require "lunchbox.rest" +local EventSource = require "lunchbox.sse.eventsource" + +return {RestClient = RestClient, EventSource = EventSource} \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/rest.lua b/drivers/ABB/insite-scu200/src/lunchbox/rest.lua new file mode 100644 index 0000000000..b7c03525a6 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/rest.lua @@ -0,0 +1,427 @@ +---@class ChunkedResponse : Response +---@field package _received_body boolean +---@field package _parsed_headers boolean +---@field public new fun(status_code: number, socket: table?): ChunkedResponse +---@field public fill_body fun(self: ChunkedResponse): string? +---@field public append_body fun(self: ChunkedResponse, next_chunk_body: string): ChunkedResponse + +local socket = require "cosock.socket" +local utils = require "utils" +local lb_utils = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" --[[@as ChunkedResponse]] +local json = require('dkjson') + +local api_version = require("version").api + +local RestCallStates = { + SEND = "Send", + RECEIVE = "Receive", + RETRY = "Retry", + RECONNECT = "Reconnect", + COMPLETE = "Complete", +} + +local function connect(client) + local port = 80 + local use_ssl = false + + if client.base_url.scheme == "https" then + port = 443 + use_ssl = true + end + + if client.base_url.port ~= port then port = client.base_url.port end + local sock, err = client.socket_builder(client.base_url.host, port, use_ssl) + + if sock == nil then + client.socket = nil + return false, err + end + + client.socket = sock + return true +end + +local function reconnect(client) + if client.socket ~= nil then + client.socket:close() + client.socket = nil + end + return connect(client) +end + +---comment +---@param client RestClient +---@param request Request +---@return integer? bytes_sent +---@return string? err_msg +---@return integer idx +local function send_request(client, request) + if client.socket == nil then + return nil, "no socket available", 0 + end + local payload = request:serialize() + + local bytes, err, idx = nil, nil, 0 + + repeat bytes, err, idx = client.socket:send(payload, idx + 1, #payload) until (bytes == #payload) + or (err ~= nil) + + return bytes, err, idx +end + +local function parse_chunked_response(original_response, sock) + local ChunkedTransferStates = { + EXPECTING_CHUNK_LENGTH = "ExpectingChunkLength", + EXPECTING_BODY_CHUNK = "ExpectingBodyChunk", + } + + local full_response = Response.new(original_response.status, nil) --[[@as ChunkedResponse]] + + for header in original_response.headers:iter() do full_response.headers:append_chunk(header) end + + local original_body, err = original_response:get_body() + if type(original_body) ~= "string" or err ~= nil then + return original_body, (err or "unexpected nil in error position") + end + local next_chunk_bytes = tonumber(original_body, 16) + local next_chunk_body = "" + local bytes_read = 0; + + local state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + + repeat + local pat = nil + local next_recv, next_err, partial = nil, nil, nil + + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + pat = next_chunk_bytes + else + pat = "*l" + end + + next_recv, next_err, partial = sock:receive(pat) + + if next_err ~= nil then + if string.lower(next_err) == "closed" then + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + next_chunk_bytes = 0 + else + return nil, next_err + end + else + return nil, ("unexpected error reading chunked transfer: " .. next_err) + end + end + + if next_recv ~= nil and #next_recv >= 1 then + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + bytes_read = bytes_read + #next_recv + next_chunk_body = next_chunk_body .. next_recv + + if bytes_read >= next_chunk_bytes then + full_response = full_response:append_body(next_chunk_body) + next_chunk_body = "" + bytes_read = 0 + + state = ChunkedTransferStates.EXPECTING_CHUNK_LENGTH + end + elseif state == ChunkedTransferStates.EXPECTING_CHUNK_LENGTH then + next_chunk_bytes = tonumber(next_recv, 16) + + state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + end + end + until next_chunk_bytes == 0 + + local _ = sock:receive("*l") -- clear the trailing CRLF + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function recv_additional_response(original_response, sock) + local full_response = Response.new(original_response.status, nil) + local headers = original_response:get_headers() + local content_length_str = headers:get_one("Content-Length") + local content_length = nil + local bytes_read = 0 + if content_length_str then + content_length = math.tointeger(content_length_str) + end + + local next_recv, next_err, partial + + repeat + next_recv, next_err, partial = sock:receive(content_length - bytes_read) + + if next_recv ~= nil and #next_recv >= 1 then + full_response:append_body(next_recv) + bytes_read = bytes_read + #next_recv + end + + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + bytes_read = bytes_read + #partial + end + until next_err == "closed" or bytes_read >= content_length + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function handle_response(sock) + if api_version >= 9 then + local response, err = Response.tcp_source(sock) + if err or (not response) then return response, (err or "unknown error") end + return response, response:fill_body() + end + -- called select right before passing in so we receive immediately + local initial_recv, initial_err, partial = Response.source(function() return sock:receive('*l') end) + + local full_response = nil + + if initial_recv ~= nil then + local headers = initial_recv:get_headers() + + if headers:get_one("Content-Length") then + full_response = recv_additional_response(initial_recv, sock) + elseif headers and headers:get_one("Transfer-Encoding") == "chunked" then + local response, err = parse_chunked_response(initial_recv, sock) + if err ~= nil then + return nil, err + end + full_response = response + else + full_response = initial_recv + end + + return full_response + else + return nil, initial_err, partial + end +end + +local function execute_request(client, request, retry_fn) + if not client._active then + return nil, "Called `execute request` on a terminated REST Client", nil + end + + if client.socket == nil then + local success, err = connect(client) + if not success then return nil, err, nil end + end + + local should_retry = retry_fn + + if type(should_retry) ~= "function" then + should_retry = function() return false end + end + + -- send output + local _bytes_sent, send_err, _idx = nil, nil, 0 + -- recv output + local response, recv_err, partial = nil, nil, nil + -- return values + local ret, err = nil, nil + + local backoff = utils.backoff_builder(60, 1, 0.1) + local current_state = RestCallStates.SEND + + repeat + local retry = should_retry() + if current_state == RestCallStates.SEND then + backoff = utils.backoff_builder(60, 1, 0.1) + _bytes_sent, send_err, _idx = send_request(client, request) + + if not send_err then + current_state = RestCallStates.RECEIVE + elseif retry then + if string.lower(send_err) == "closed" or string.lower(send_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = send_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECEIVE then + response, recv_err, partial = handle_response(client.socket) + + if not recv_err then + ret = response + err = nil + current_state = RestCallStates.COMPLETE + elseif retry then + if string.lower(recv_err) == "closed" or string.lower(recv_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = recv_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECONNECT then + local success, reconn_err = reconnect(client) + if success then + current_state = RestCallStates.RETRY + elseif not retry then + ret = nil + err = reconn_err + current_state = RestCallStates.COMPLETE + else + socket.sleep(backoff()) + end + elseif current_state == RestCallStates.RETRY then + bytes_sent, send_err, _idx = nil, nil, 0 + response, recv_err, partial = nil, nil, nil + current_state = RestCallStates.SEND + socket.sleep(backoff()) + end + until current_state == RestCallStates.COMPLETE + + return ret, err, partial +end + +---@class RestClient +--- +---@field base_url table `net.url` URL table +---@field socket table `cosock` TCP socket +local RestClient = {} +RestClient.__index = RestClient + +function RestClient.one_shot_get(full_url, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + + local query_params = "" + if url_table.query ~= nil then + query_params = "?" + + for param, value in pairs(url_table.query) do + query_params = query_params .. param .. "=" .. value .. "&" + end + + query_params = query_params:sub(1, -2) + end + + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:get(url_table.path .. query_params, additional_headers) + + client:shutdown() + + return ret, err +end + +function RestClient.one_shot_post(full_url, body, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + + local query_params = "" + if url_table.query ~= nil then + query_params = "?" + + for param, value in pairs(url_table.query) do + query_params = query_params .. param .. "=" .. value .. "&" + end + + query_params = query_params:sub(1, -2) + end + + if type(body) == "table" then + body = json.encode(body) + end + + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:post(url_table.path .. query_params, body, additional_headers) + + client:shutdown() + + return ret, err +end + +function RestClient:close_socket() + if self.socket ~= nil and self._active then + self.socket:close() + self.socket = nil + end +end + +function RestClient:shutdown() + self:close_socket() + self._active = false +end + +function RestClient:update_base_url(new_url) + if self.socket ~= nil then + self.socket:close() + self.socket = nil + end + + self.base_url = lb_utils.force_url_table(new_url) +end + +function RestClient:get(path, additional_headers, retry_fn) + local request = Request.new("GET", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + return execute_request(self, request, retry_fn) +end + +function RestClient:post(path, body_string, additional_headers, retry_fn) + local request = Request.new("POST", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient:put(path, body_string, additional_headers, retry_fn) + local request = Request.new("PUT", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient.new(base_url, sock_builder) + base_url = lb_utils.force_url_table(base_url) + + if type(sock_builder) ~= "function" then sock_builder = utils.labeled_socket_builder() end + + return + setmetatable({base_url = base_url, socket_builder = sock_builder, socket = nil, _active = true}, RestClient) +end + +return RestClient \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/sse/eventsource.lua b/drivers/ABB/insite-scu200/src/lunchbox/sse/eventsource.lua new file mode 100644 index 0000000000..d9a5930df2 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/sse/eventsource.lua @@ -0,0 +1,523 @@ +local cosock = require "cosock" +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" +local json = require "dkjson" + +local log = require "log" +local util = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" + +--- A pure Lua implementation of the EventSource interface. +--- The EventSource interface represents the client end of an HTTP(S) +--- connection that receives an event stream following the Server-Sent events +--- specification. +--- +--- MDN Documentation for EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource +--- HTML Spec: https://html.spec.whatwg.org/multipage/server-sent-events.html +--- +--- @class EventSource +--- @field public url table A `net.url` table representing the URL for the connection +--- @field public ready_state number Enumeration of the ready states outlined in the spec. +--- @field public onopen function in-line callback for on-open events +--- @field public onmessage function in-line callback for on-message events +--- @field public onerror function in-line callback for on-error events; error callbacks will fire +--- @field package _reconnect boolean flag that says whether or not the client should attempt to reconnect on close. +--- @field package _reconnect_time_millis number The amount of time to wait between reconnects, in millis. Can be sent by the server. +--- @field package _sock_builder function|nil optional. If this function exists, it will be called to create a new TCP socket on connection. +--- @field package _sock table? the TCP socket for the connection +--- @field package _needs_more boolean flag to track whether or not we're still expecting mroe on this source before we dispatch +--- @field package _last_field string the last field the parsing path saw, in case it needs to append more to its value +--- @field package _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. +--- @field package _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. +--- @field package _listeners table event listeners attached using the add_event_listener API instead of the inline callbacks. +local EventSource = {} +EventSource.__index = EventSource + +--- The Ready States that an EventSource can be in. We use base 0 to match the specification. +EventSource.ReadyStates = util.read_only { + CONNECTING = 0, -- The connection has not yet been established + OPEN = 1, -- The connection is open + CLOSED = 2 -- The connection has closed +} + +--- The event types supported by this source, patterned after their values in JavaScript. +EventSource.EventTypes = util.read_only { + ON_OPEN = "open", + ON_MESSAGE = "message", + ON_ERROR = "error", +} + +--- Helper function that creates the initial Request to start the stream. +--- @function create_request +--- @local +--- @param url_table table a net.url table +--- @param extra_headers table a set of key/value pairs (strings) to capture any extra HTTP headers needed +--- @param body table a set of key/value pairs (strings) to be sent as the body of the initial POST request. +local function create_request(url_table, extra_headers, body) + local request = Request.new("POST", url_table.path, nil) + :add_header("user-agent", "smartthings-lua-edge-driver") + :add_header("host", string.format("%s", url_table.host)) + :add_header("connection", "keep-alive") + :add_header("accept", "text/event-stream") + :add_header("content-type", "application/json") + + if type(extra_headers) == "table" then + for k, v in pairs(extra_headers) do + request = request:add_header(k, v) + end + end + + local encoded_body = json.encode(body) + request = request:append_body(encoded_body) + + return request +end + +--- Helper function to send the request and kick off the stream. +--- @function send_stream_start_request +--- @local +--- @param payload string the entire string buffer to send +--- @param sock table the TCP socket to send it over +local function send_stream_start_request(payload, sock) + local bytes, err, idx = nil, nil, 0 + + repeat + bytes, err, idx = sock:send(payload, idx + 1, #payload) + until (bytes == #payload) or (err ~= nil) + + if err then + log.error_with({ hub_logs = true }, "send error: " .. err) + end + + return bytes, err, idx +end + +--- Helper function to create an table representing an event from the source's parse buffers. +--- @function make_event +--- @local +--- @param source EventSource +local function make_event(source) + local event_type = nil + + if #source._parse_buffers["event"] > 0 then + event_type = source._parse_buffers["event"] + end + + return { + type = event_type or "message", + data = source._parse_buffers["data"], + origin = source.url.scheme .. "://" .. source.url.host, + lastEventId = source._parse_buffers["id"] + } +end + +--- SSE spec for dispatching an event: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage +--- @function dispatch_event +--- @local +--- @param source EventSource +local function dispatch_event(source) + local data_buffer = source._parse_buffers["data"] + local is_blank_line = data_buffer ~= nil and + (#data_buffer == 0) or + data_buffer == "\n" or + data_buffer == "\r" or + data_buffer == "\r\n" + if data_buffer ~= nil and not is_blank_line then + local event = util.read_only(make_event(source)) + + if type(source.onmessage) == "function" then + source.onmessage(event) + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_MESSAGE]) do + if type(listener) == "function" then + listener(event) + end + end + end + + source._parse_buffers["event"] = "" + source._parse_buffers["data"] = "" +end + +local valid_fields = util.read_only { + ["event"] = true, + ["data"] = true, + ["id"] = true, + ["retry"] = true +} + +-- An event stream "line" can end in more than one way; from the spec: +-- Lines must be separated by either +-- a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, +-- a single U+000A LINE FEED (LF) character, +-- or a single U+000D CARRIAGE RETURN (CR) character. +-- +-- util.iter_string_lines won't suffice here because: +-- a.) it assumes \n, and +-- b.) it doesn't differentiate between a "line" that ends without a newline and one that does. +-- +-- h/t to github.com/FreeMasen for the suggestions on the efficient implementation of this +local function find_line_endings(chunk) + local r_idx, n_idx = string.find(chunk, "[\r\n]+") + if r_idx == nil or r_idx == n_idx then + -- 1 character or no match + return r_idx, n_idx + end + local slice = string.sub(chunk, r_idx, n_idx) + if slice == "\r\n" then + return r_idx, n_idx + end + -- invalid multi character match, return first character only + return r_idx, r_idx +end + +local function event_lines(chunk) + local remaining = chunk + local line_end, rn_end + local remainder_sent = false + return function() + line_end, rn_end = find_line_endings(remaining) + if not line_end then + if remainder_sent or (not remaining) or #remaining == 0 then + return nil + else + remainder_sent = true + return remaining, false + end + end + local next_line = string.sub(remaining, 1, line_end - 1) + remaining = string.sub(remaining, rn_end + 1) + return next_line, true + end +end +--- SSE spec for interpreting an event stream: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface +--- @function parse +--- @local +--- @param source EventSource +--- @param recv string the received payload from the last socket receive +local function sse_parse_chunk(source, recv) + for line, complete in event_lines(recv) do + if not source._needs_more and (#line == 0 or (not line:match("([%w%p]+)"))) then -- empty/blank lines indicate dispatch + dispatch_event(source) + elseif source._needs_more then + local append = line + if source._last_field == "data" and complete then append = append .. "\n" end + if complete then source._needs_more = false end + source._parse_buffers[source._last_field] = source._parse_buffers[source._last_field] .. append + else + if line:sub(1, 1) ~= ":" then -- ignore any complete lines that start w/ a colon + local matches = line:gmatch("(%w*)(:*)(.*)") -- colon after field is optional, in that case it's a field w/ no value + + for field, _colon, value in matches do + value = value:gsub("^[^%g]", "", 1) -- trim a single leading space character + + if valid_fields[field] then + source._last_field = field + if field == "retry" then + local new_time = tonumber(value, 10) + if type(new_time) == "number" then + source._reconnect_time_millis = new_time + end + elseif field == "data" then + local append = (value or "") + -- if complete then append = append .. "\n" end + source._parse_buffers[field] = source._parse_buffers[field] .. append + elseif field == "id" then + -- skip ID's if they contain the NULL character + if not string.find(value, '\0') then + source._parse_buffers[field] = value + end + else + source._parse_buffers[field] = value + end + end + source._needs_more = source._needs_more or (not complete) + end + end + end + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CONNECTING state. +--- @function connecting_action +--- @local +--- @param source EventSource +local function connecting_action(source) + if not source._sock then + if type(source._sock_builder) == "function" then + source._sock = source._sock_builder() + else + source._sock, err = socket.tcp() + if err ~= nil then return nil, err end + + _, err = source._sock:settimeout(60) + if err ~= nil then return nil, err end + + _, err = source._sock:connect(source.url.host, source.url.port) + if err ~= nil then return nil, err end + + _, err = source._sock:setoption("keepalive", true) + if err ~= nil then return nil, err end + + if source.url.scheme == "https" then + source._sock, err = ssl.wrap(source._sock, { + mode = "client", + protocol = "any", + verify = "none", + options = "all" + }) + if err ~= nil then return nil, err end + + _, err = source._sock:dohandshake() + if err ~= nil then return nil, err end + end + end + end + + local request = create_request(source.url, source._extra_headers, source._body) + + local last_event_id = source._parse_buffers["id"] + + if last_event_id ~= nil and #last_event_id > 0 then + request = request:add_header("Last-Event-ID", last_event_id) + end + + local _, err, _ = send_stream_start_request(request:serialize(), source._sock) + + if err ~= nil then + return nil, err + end + + local response + response, err = Response.tcp_source(source._sock) + + if not response or err ~= nil then + return nil, err or "nil response from Response.tcp_source" + end + + if response.status ~= 200 then + return nil, "Server responded with status other than 200 OK", { response.status, response.status_msg } + end + + local headers, err = response:get_headers() + if err ~= nil then + return nil, err + end + local content_type = string.lower((headers and headers:get_one('content-type') or "none")) + if not content_type:find("text/event-stream", 1, true) then + local err_msg = "Expected content type of text/event-stream in response headers, received: " .. content_type + return nil, err_msg + end + + source.ready_state = EventSource.ReadyStates.OPEN + + if type(source.onopen) == "function" then + source.onopen() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_OPEN]) do + if type(listener) == "function" then + listener() + end + end +end +--- Helper function that captures the cyclic logic of the EventSource while in the OPEN state. +--- @function open_action +--- @local +--- @param source EventSource +local function open_action(source) + local recv, err, partial = source._sock:receive('*l') + + if err then + --- connection is fine but there was nothing + --- to be read from the other end so we just + --- early return. + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + + -- the number of bytes to read per the chunked encoding spec + local recv_as_num = tonumber(recv, 16) + + if recv_as_num ~= nil and recv_as_num == 0 then + return -- the stream has ended + end + + if recv_as_num ~= nil then + recv, err, partial = source._sock:receive(recv_as_num) + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + + local _, err, partial = source._sock:receive('*l') -- clear the final line + + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + sse_parse_chunk(source, recv) + else + local recv_dbg = recv or "" + if #recv_dbg == 0 then + recv_dbg = "" + end + recv_dbg = recv_dbg:gsub("\r\n", ""):gsub("\n", ""):gsub("\r", "") + log.error_with({ hub_logs = true }, string.format("Received %s while expecting a chunked encoding payload length (hex number)\n", recv_dbg)) + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CLOSED state. +--- @function closed_action +--- @local +--- @param source EventSource +local function closed_action(source) + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + + if source._reconnect then + if type(source.onerror) == "function" then + source.onerror() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_ERROR]) do + if type(listener) == "function" then + listener() + end + end + + local sleep_time_secs = source._reconnect_time_millis / 1000.0 + socket.sleep(sleep_time_secs) + + source.ready_state = EventSource.ReadyStates.CONNECTING + end +end + +local state_actions = { + [EventSource.ReadyStates.CONNECTING] = connecting_action, + [EventSource.ReadyStates.OPEN] = open_action, + [EventSource.ReadyStates.CLOSED] = closed_action +} + +--- Create a new EventSource. The only required parameter is the URL, which can +--- be a string or a net.url table. The string form will be converted to a net.url table. +--- +--- @param url string|table a string or a net.url table representing the complete URL (minimally a scheme/host/path, port optional) for the event stream. +--- @param extra_headers table|nil an optional table of key-value pairs (strings) to be added to the initial POST request +--- @param body table|nil an optional table of key-value pairs (strings) to be sent as the body of the initial POST request +--- @param sock_builder function|nil an optional function to be used to create the TCP socket for the stream. If nil, a set of defaults will be used to create a new TCP socket. +--- @return EventSource a new EventSource +function EventSource.new(url, extra_headers, body, sock_builder) + local url_table = util.force_url_table(url) + + if not url_table.port then + if url_table.scheme == "http" then + url_table.port = 80 + elseif url_table.scheme == "https" then + url_table.port = 443 + end + end + + local sock = nil + + if type(sock_builder) == "function" then + sock = sock_builder() + end + + local source = setmetatable({ + url = url_table, + ready_state = EventSource.ReadyStates.CONNECTING, + onopen = nil, + onmessage = nil, + onerror = nil, + _needs_more = false, + _last_field = nil, + _reconnect = true, + _reconnect_time_millis = 15 * 1000, + _sock_builder = sock_builder, + _sock = sock, + _extra_headers = extra_headers, + _body = body, + _parse_buffers = { + ["data"] = "", + ["id"] = "", + ["event"] = "", + }, + _listeners = { + [EventSource.EventTypes.ON_OPEN] = {}, + [EventSource.EventTypes.ON_MESSAGE] = {}, + [EventSource.EventTypes.ON_ERROR] = {} + }, + }, EventSource) + + cosock.spawn(function() + local st_utils = require "st.utils" + while true do + if source.ready_state == EventSource.ReadyStates.CLOSED and not source._reconnect then + return + end + local _, action_err, partial = state_actions[source.ready_state](source) + if action_err ~= nil then + if action_err ~= "timeout" or action_err ~= "wantread" then + log.error_with({ hub_logs = true }, "Event Source Coroutine State Machine error: " .. action_err) + if partial ~= nil and #partial > 0 then + log.error_with({ hub_logs = true }, st_utils.stringify_table(partial, "\tReceived Partial", true)) + end + source.ready_state = EventSource.ReadyStates.CLOSED + end + end + end + end) + + return source +end + +--- Close the event source, signalling that a reconnect is not desired +function EventSource:close() + self._reconnect = false + if self._sock ~= nil then + self._sock:close() + end + self._sock = nil + self.ready_state = EventSource.ReadyStates.CLOSED +end + +--- Add a callback to the event source +---@param listener_type string One of "message", "open", or "error" +---@param listener function the callback to be called in case of an event. Open and Error events have no payload. The message event will have a single argument, a table. +function EventSource:add_event_listener(listener_type, listener) + local list = self._listeners[listener_type] + + if list then + table.insert(list, listener) + end +end + +return EventSource \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/util.lua b/drivers/ABB/insite-scu200/src/lunchbox/util.lua new file mode 100644 index 0000000000..6eb94407eb --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/util.lua @@ -0,0 +1,46 @@ +local net_url = require "net.url" + +local util = {} + +util.force_url_table = function(url) + if type(url) ~= "table" then url = net_url.parse(url) end + + if not url.port then + if url.scheme == "http" then + url.port = 80 + elseif url.scheme == "https" then + url.port = 443 + end + end + + return url +end + +util.read_only = function(tbl) + if type(tbl) == "table" then + local proxy = {} + local mt = { -- create metatable + __index = tbl, + __newindex = function(t, k, v) error("attempt to update a read-only table", 2) end, + } + setmetatable(proxy, mt) + return proxy + else + return tbl + end +end + +util.iter_string_lines = function(str) + if str:sub(-1) ~= "\n" then str = str .. "\n" end + + return str:gmatch("(.-)\n") +end + +util.copy_data = function(tbl) + local ret = {} + for k, v in pairs(tbl) do ret[k] = v end + + return ret +end + +return util \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/utils.lua b/drivers/ABB/insite-scu200/src/utils.lua new file mode 100644 index 0000000000..139b5503c5 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/utils.lua @@ -0,0 +1,219 @@ +local log = require("log") +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" + +-- Local imports +local config = require("config") +local fields = require("fields") + +-- Utility functions for the SmartThings edge driver +local utils = {} + +-- Get the device id from the device +function utils.get_dni_from_device(device) + if device.parent_assigned_child_key then + local thing_dni = device.parent_assigned_child_key + + return thing_dni, fields.DEVICE_TYPE_THING + else + local bridge_dni = device.device_network_id + + return bridge_dni, fields.DEVICE_TYPE_BRIDGE + end +end + +-- Get the device ID +function utils.get_device_id_from_device(device) + return device.st_store.id +end + +-- Get the device model +function utils.get_device_model(device) + local thing_info = device:get_field(fields.THING_INFO) + + if thing_info == nil then + return nil + end + + return thing_info.type +end + +-- Get the device IP address +function utils.get_device_ip_address(device) + local _, device_type = utils.get_dni_from_device(device) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + return device:get_field(fields.BRIDGE_IPV4) + else + local bridge = device:get_parent_device() + + return bridge:get_field(fields.BRIDGE_IPV4) + end +end + +-- Method for getting edge child device version by type +function utils.get_edge_child_device_version(edge_child_device_type) + local edge_child_device_versions = { + [config.EDGE_CHILD_CURRENT_SENSOR_TYPE] = config.EDGE_CHILD_CURRENT_SENSOR_VERSION, + [config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE] = config.EDGE_CHILD_ENERGY_METER_MODULE_VERSION, + [config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE] = config.EDGE_CHILD_AUXILIARY_CONTACT_VERSION, + [config.EDGE_CHILD_OUTPUT_MODULE_TYPE] = config.EDGE_CHILD_OUTPUT_MODULE_VERSION, + [config.EDGE_CHILD_ENERGY_METER_TYPE] = config.EDGE_CHILD_ENERGY_METER_VERSION, + [config.EDGE_CHILD_WATER_METER_TYPE] = config.EDGE_CHILD_WATER_METER_VERSION, + [config.EDGE_CHILD_GAS_METER_TYPE] = config.EDGE_CHILD_GAS_METER_VERSION, + [config.EDGE_CHILD_USB_ENERGY_METER_TYPE] = config.EDGE_CHILD_USB_ENERGY_METER_VERSION + } + + return edge_child_device_versions[edge_child_device_type] +end + +-- Method for getting the thing exact type +function utils.get_thing_exact_type(edge_child_device_type) + local device_version = utils.get_edge_child_device_version(edge_child_device_type) + + if device_version == nil then + return nil + end + + return config.MANUFACTURER .. "_" .. config.BRIDGE_TYPE .. "_" .. edge_child_device_type .. "_" .. device_version +end + +-- Method for getting the thing profile reference +function utils.get_thing_profile_ref(thing_info) + if thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE) then + if thing_info.properties.isExport then + return config.EDGE_CHILD_CURRENT_SENSOR_PRODUCTION_PROFILE + else + return config.EDGE_CHILD_CURRENT_SENSOR_CONSUMPTION_PROFILE + end + end + + local thing_profiles = { + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = config.EDGE_CHILD_ENERGY_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = config.EDGE_CHILD_AUXILIARY_CONTACT_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = config.EDGE_CHILD_OUTPUT_MODULE_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = config.EDGE_CHILD_ENERGY_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = config.EDGE_CHILD_WATER_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = config.EDGE_CHILD_GAS_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = config.EDGE_CHILD_USB_ENERGY_METER_PROFILE + } + + return thing_profiles[thing_info.type] +end + +-- Method for getting the thing refresh period +function utils.get_thing_refresh_period(device) + local device_model = utils.get_device_model(device) + + local thing_refresh_periods = { + [utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE)] = config.EDGE_CHILD_CURRENT_SENSOR_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = config.EDGE_CHILD_ENERGY_METER_MODULE_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = config.EDGE_CHILD_AUXILIARY_CONTACT_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = config.EDGE_CHILD_OUTPUT_MODULE_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = config.EDGE_CHILD_ENERGY_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = config.EDGE_CHILD_WATER_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = config.EDGE_CHILD_GAS_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = config.EDGE_CHILD_USB_ENERGY_METER_REFRESH_PERIOD + } + + return thing_refresh_periods[device_model] +end + +-- Method for dumping a table to string +function utils.dump(o) + if type(o) == "table" then + local s = '{' + + for k,v in pairs(o) do + if type(k) ~= "number" then k = '"'..k..'"' end + s = s .. ' ['..k..'] = ' .. utils.dump(v) .. ',' + end + + return s .. '} ' + else + return tostring(o) + end +end + +-- Method for building a exponential backoff time value generator +function utils.backoff_builder(max, inc, rand) + local count = 0 + inc = inc or 1 + + return function() + local randval = 0 + if rand then + randval = math.random() * rand * 2 - rand + end + + local base = inc * (2 ^ count - 1) + count = count + 1 + + -- ensure base backoff (not including random factor) is less than max + if max then base = math.min(base, max) end + + -- ensure total backoff is >= 0 + return math.max(base + randval, 0) + end +end + +-- Method for creating a labeled socket +function utils.labeled_socket_builder(label, ssl_config) + label = (label or "") + if #label > 0 then + label = label .. " " + end + + if not ssl_config then + ssl_config = { mode = "client", protocol = "any", verify = "none", options = "all" } + end + + local function make_socket(host, port, wrap_ssl) + log.info("utils.labeled_socket_builder(): Creating TCP socket for REST Connection: " .. label) + local _ = nil + local sock, err = socket.tcp() + + if err ~= nil or (not sock) then + return nil, (err or "unknown error creating TCP socket") + end + + log.debug("utils.labeled_socket_builder(): Setting TCP socket timeout for REST Connection: " .. label) + _, err = sock:settimeout(60) + if err ~= nil then + return nil, "settimeout error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Connecting TCP socket for REST Connection: " .. label) + _, err = sock:connect(host, port) + if err ~= nil then + return nil, "Connect error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Set Keepalive for TCP socket for REST Connection: " .. label) + _, err = sock:setoption("keepalive", true) + if err ~= nil then + return nil, "Setoption error: " .. err + end + + if wrap_ssl then + log.debug("utils.labeled_socket_builder(): Creating SSL wrapper for REST Connection: " .. label) + sock, err = ssl.wrap(sock, ssl_config) + if err ~= nil then + return nil, "SSL wrap error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Performing SSL handshake for REST Connection: " .. label) + _, err = sock:dohandshake() + if err ~= nil then + return nil, "Error with SSL handshake: " .. err + end + end + + log.info("utils.labeled_socket_builder(): Successfully created TCP connection: " .. label) + return sock, err + end + + return make_socket +end + +return utils \ No newline at end of file diff --git a/drivers/Aqara/aqara-bath-heater/config.yml b/drivers/Aqara/aqara-bath-heater/config.yml new file mode 100644 index 0000000000..4465c61440 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/config.yml @@ -0,0 +1,6 @@ +name: Aqara Bath Heater +packageKey: aqara-bath-heater +permissions: + zigbee: {} +description: "SmartThings driver for Aqara Bath Heater devices" +vendorSupportInformation: "https://www.aqara.com/en/support/" diff --git a/drivers/Aqara/aqara-bath-heater/fingerprints.yml b/drivers/Aqara/aqara-bath-heater/fingerprints.yml new file mode 100644 index 0000000000..ecef588f02 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/fingerprints.yml @@ -0,0 +1,6 @@ +zigbeeManufacturer: + - id: "Aqara/lumi.bhf_light.acn001" + deviceLabel: Aqara Bath Heater T1 + manufacturer: Aqara + model: lumi.bhf_light.acn001 + deviceProfileName: "aqara-bath-heater" diff --git a/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml new file mode 100644 index 0000000000..2acda35491 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml @@ -0,0 +1,36 @@ +name: aqara-bath-heater +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: colorTemperature + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: fanOscillationMode + version: 1 + - id: fanMode + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: + - preferenceId: stse.nightLightMode + explicit: true + - preferenceId: stse.nightLightStartTime + explicit: true + - preferenceId: stse.nightLightEndTime + explicit: true + - preferenceId: stse.muteBeep + 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/aqara_cluster.lua b/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua new file mode 100644 index 0000000000..90fd6d0655 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local M = {} + +M.CLUSTER_ID = 0xFCC0 +M.MFG_CODE = 0x115F -- Lumi/Aqara manufacturer code + +M.ATTR_AC_CODE = 0x024F +M.ATTR_THERMOSTAT_CTRL_SW = 0x02BE +M.ATTR_DND_BEEP = 0x0256 +M.ATTR_DND_TIME = 0x0257 +M.ATTR_NIGHT_LIGHT = 0x0518 + +return M diff --git a/drivers/Aqara/aqara-bath-heater/src/init.lua b/drivers/Aqara/aqara-bath-heater/src/init.lua new file mode 100644 index 0000000000..62007b537c --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/init.lua @@ -0,0 +1,538 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" +local cluster_base = require "st.zigbee.cluster_base" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" + +local aqara = require "aqara_cluster" + +local OnOff = zcl_clusters.OnOff +local Level = zcl_clusters.Level +local ColorControl = zcl_clusters.ColorControl + +-- Aqara manufacturer-specific preference keys +local nightLightMode = "stse.nightLightMode" +local nightLightEndTime = "stse.nightLightEndTime" +local nightLightStartTime = "stse.nightLightStartTime" +local muteBeep = "stse.muteBeep" +local thermostatCtrl = "stse.thermostatCtrl" + +-- AC code field values (see send_ac_code for the bit layout) +local PWR = { OFF = 0x0, ON = 0x1 } +local MODE = { HEAT = 0x0, DRYAIR = 0x3, COOL = 0x4, FANONLY = 0x5, INVALID = 0xF } +local FAN_LOW = 0x0 +local FAN_MID = 0x1 +local FAN_HIGH = 0x2 +local FAN_INVALID = 0xF +local SWING_ON = 0x0 +local SWING_OFF = 0x1 + +-- SmartThings fanMode capability values +local SPEED = { + LOW = "low", + MEDIUM = "medium", + HIGH = "high", +} +local MODE_TO_FAN = { [SPEED.LOW] = FAN_LOW, [SPEED.MEDIUM] = FAN_MID, [SPEED.HIGH] = FAN_HIGH } +local FAN_TO_MODE = { [FAN_LOW] = SPEED.LOW, [FAN_MID] = SPEED.MEDIUM, [FAN_HIGH] = SPEED.HIGH } + +-- SmartThings fanOscillationMode capability values +local OSC = { + SWING = "swing", + FIXED = "fixed", +} +local ST_FAN_TO_SWING = { + [OSC.SWING] = SWING_ON, + [OSC.FIXED] = SWING_OFF, +} + +-- SmartThings thermostatMode capability values +local ST_MODE = { + OFF = "off", + HEAT = "heat", + DRYAIR = "dryair", + COOL = "cool", + FANONLY = "fanonly", +} + +-- SmartThings thermostatMode -> AC parameters +local ST_TO_AC = { + [ST_MODE.OFF] = { pwr = PWR.OFF, mode = MODE.INVALID, fan = FAN_INVALID }, + [ST_MODE.HEAT] = { pwr = PWR.ON, mode = MODE.HEAT, fan = FAN_MID }, + [ST_MODE.DRYAIR] = { pwr = PWR.ON, mode = MODE.DRYAIR, fan = FAN_MID }, + [ST_MODE.COOL] = { pwr = PWR.ON, mode = MODE.COOL, fan = FAN_MID }, + [ST_MODE.FANONLY] = { pwr = PWR.ON, mode = MODE.FANONLY, fan = FAN_MID }, +} + +-- AC mode bits -> SmartThings thermostatMode +local AC_MODE_TO_ST = { + [0x0] = ST_MODE.HEAT, + [0x3] = ST_MODE.DRYAIR, + [0x4] = ST_MODE.COOL, + [0x5] = ST_MODE.FANONLY, +} + +local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end + +-- Encode and send the 64-bit AC control code as an Aqara manufacturer attribute. +-- A nibble of 0xF means "no change"; the default 0xFFFFFFFFFFFFFFFF leaves +-- every field untouched so callers only need to set what they want to change. +-- pwr : bits31-28 0=off 1=on +-- mode : bits27-24 0=heat 3=dryair 4=cool 5=fanonly +-- fan : bits23-20 0=low 1=mid 2=high 3=auto +-- swing : bits17-16 0=swing 1=fixed +-- setpoint : bits63-48 Celsius x 100 +local function send_ac_code(device, params) + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + + if params.setpoint ~= nil then + local sp_raw = math.floor(clamp(params.setpoint, 16, 45) * 100) & 0xFFFF + hi32 = (sp_raw << 16) | 0xFFFF + end + + if params.pwr ~= nil then + lo32 = (lo32 & 0x0FFFFFFF) | ((params.pwr & 0xF) << 28) + end + + if params.mode ~= nil then + lo32 = (lo32 & 0xF0FFFFFF) | ((params.mode & 0xF) << 24) + end + + if params.fan ~= nil then + lo32 = (lo32 & 0xFF0FFFFF) | ((params.fan & 0xF) << 20) + end + + if params.swing ~= nil then + lo32 = (lo32 & 0xFFFCFFFF) | ((params.swing & 0x3) << 16) + end + + local bytes = string.char( + (hi32 >> 24) & 0xFF, + (hi32 >> 16) & 0xFF, + (hi32 >> 8) & 0xFF, + hi32 & 0xFF, + (lo32 >> 24) & 0xFF, + (lo32 >> 16) & 0xFF, + (lo32 >> 8) & 0xFF, + lo32 & 0xFF + ) + + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_AC_CODE, aqara.MFG_CODE, + data_types.Uint64, bytes + )) +end + +-- Per-mode state persistence: remember the last swing/fan used in each +-- thermostat mode so they can be restored when the user returns to it. +-- Setpoint is shared across modes and read directly from the capability state. +local FIELD = { + SWING = "swing", + FAN_MODE = "fan_mode", +} + +-- Fields tracked per mode (modes not listed here have no per-mode state). +local MODE_FIELDS = { + [ST_MODE.HEAT] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.COOL] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.DRYAIR] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.FANONLY] = { FIELD.FAN_MODE }, +} + +-- Initial values when no saved state exists yet for a mode. +local MODE_DEFAULTS = { + [ST_MODE.HEAT] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.COOL] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.DRYAIR] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.FANONLY] = { fan_mode = SPEED.MEDIUM }, +} + +local function save_mode_state(device, mode, field, value) + device:set_field("mode_state." .. mode .. "." .. field, value, { persist = true }) +end + +local function load_mode_state(device, mode, field) + return device:get_field("mode_state." .. mode .. "." .. field) +end + +local function save_current_mode_field(device, field, value) + local mode = device:get_field("thermostat_mode") or ST_MODE.OFF + local fields = MODE_FIELDS[mode] + if fields then + for _, f in ipairs(fields) do + if f == field then + save_mode_state(device, mode, field, value) + return + end + end + end +end + +-- Capture the current capability state for each field tracked by the given +-- mode. Used right before switching modes so any in-flight changes (e.g., a +-- setpoint set via the UI but not yet confirmed by the device) are preserved. +local function snapshot_mode_state(device, mode) + local fields = MODE_FIELDS[mode] + if not fields then return end + + for _, field in ipairs(fields) do + local v + if field == FIELD.SWING then + v = device:get_latest_state("main", + capabilities.fanOscillationMode.ID, + capabilities.fanOscillationMode.fanOscillationMode.NAME) + elseif field == FIELD.FAN_MODE then + v = device:get_latest_state("main", + capabilities.fanMode.ID, + capabilities.fanMode.fanMode.NAME) + end + if v ~= nil then + save_mode_state(device, mode, field, v) + end + end +end + +-- Re-emit saved values for the entered mode and push them to the AC in a +-- single batched code, falling back to MODE_DEFAULTS on first use. +local function restore_mode_state(device, st_mode) + local fields = MODE_FIELDS[st_mode] + if not fields then return end + + local mode_defaults = MODE_DEFAULTS[st_mode] or {} + local swing, fan = nil, nil + + for _, field in ipairs(fields) do + if field == FIELD.SWING then + local v = load_mode_state(device, st_mode, FIELD.SWING) or mode_defaults.swing + if v ~= nil then + swing = ST_FAN_TO_SWING[v] + device:set_field("fan_mode", v) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(v)) + end + elseif field == FIELD.FAN_MODE then + local v = load_mode_state(device, st_mode, FIELD.FAN_MODE) or mode_defaults.fan_mode + if v ~= nil then + fan = MODE_TO_FAN[v] + device:set_field("fan_mode_ac", fan) + device:emit_event(capabilities.fanMode.fanMode(v)) + end + end + end + + if swing ~= nil or fan ~= nil then + send_ac_code(device, { swing = swing, fan = fan }) + end +end + + +-- Capability handlers +local function handle_thermostat_mode(driver, device, cmd) + local st_mode = cmd.args.mode + local ac = ST_TO_AC[st_mode] + if not ac then return end + + local prev_mode = device:get_field("thermostat_mode") + if prev_mode and prev_mode ~= st_mode then + snapshot_mode_state(device, prev_mode) + end + + local pwr = (st_mode == ST_MODE.OFF) and PWR.OFF or PWR.ON + -- Setpoint is shared across modes; on entry to HEAT, push the last value + -- the user set so the device matches what the UI is currently showing. + local setpoint = nil + if st_mode == ST_MODE.HEAT then + local state = device:get_latest_state("main", + capabilities.thermostatHeatingSetpoint.ID, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME) + if state ~= nil then + setpoint = clamp(state, 16, 45) + end + end + + send_ac_code(device, { pwr = pwr, mode = ac.mode, setpoint = setpoint }) + device:set_field("thermostat_mode", st_mode) + device:emit_event(capabilities.thermostatMode.thermostatMode(st_mode)) + restore_mode_state(device, st_mode) + + if st_mode ~= ST_MODE.OFF then + device:set_field("pending_on_mode", st_mode) + else + device:set_field("pending_on_mode", nil) + end +end + +local function handle_heating_setpoint(driver, device, cmd) + local temp_c = clamp(cmd.args.setpoint, 16, 45) + device:set_field("heating_setpoint", temp_c) + + local cur = device:get_field("thermostat_mode") or ST_MODE.OFF + if cur == ST_MODE.HEAT then + send_ac_code(device, { setpoint = temp_c }) + end +end + +local function handle_fan_oscillation_mode(driver, device, cmd) + local st_fan = cmd.args.fanOscillationMode + local swing = ST_FAN_TO_SWING[st_fan] or SWING_ON + + device:set_field("fan_mode", st_fan) + send_ac_code(device, { swing = swing }) +end + +local function handle_fan_mode(driver, device, cmd) + local fan_mode = cmd.args.fanMode + local fan = MODE_TO_FAN[fan_mode] or FAN_MID + device:set_field("fan_mode_ac", fan) + send_ac_code(device, { fan = fan }) +end + +local function handle_refresh(driver, device) + device:send(OnOff.attributes.OnOff:read(device)) + device:send(Level.attributes.CurrentLevel:read(device)) + device:send(ColorControl.attributes.ColorTemperatureMireds:read(device)) +end + +-- Zigbee attribute handlers +-- Decode the AC code reported by the device, emit matching capability events, +-- and persist the per-mode state so values are restored when the user returns +-- to that mode. +local function ac_code_attr_handler(driver, device, value, zb_rx) + local raw = value.value + local hi32, lo32 + + -- The attribute is a Uint64 but may arrive either as a raw integer or as + -- the 8-byte big-endian payload depending on the runtime path. + if type(raw) == "string" then + local b = { string.byte(raw, 1, 8) } + hi32 = ((b[1] or 0) << 24) | ((b[2] or 0) << 16) | ((b[3] or 0) << 8) | (b[4] or 0) + lo32 = ((b[5] or 0) << 24) | ((b[6] or 0) << 16) | ((b[7] or 0) << 8) | (b[8] or 0) + else + hi32 = (raw >> 32) & 0xFFFFFFFF + lo32 = raw & 0xFFFFFFFF + end + + local pwr = (lo32 >> 28) & 0xF + local mode = (lo32 >> 24) & 0xF + local fan_set = (lo32 >> 20) & 0xF + local b15_8 = (lo32 >> 8) & 0xFF + local b7_0 = lo32 & 0xFF + local bits7_2 = (b7_0 >> 2) & 0x3F + + -- The setpoint nibbles are only trustworthy when the surrounding sentinel + -- bytes match this pattern; otherwise the device is reporting "no change". + local hi_valid = (b15_8 >= 0xFE) and (bits7_2 == 63) + local setpoint_raw = (hi32 >> 16) & 0xFFFF + + if hi_valid and setpoint_raw ~= 0xFFFF then + local sp = setpoint_raw / 100.0 + device:set_field("heating_setpoint", sp) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint( + { value = sp, unit = "C" } + )) + end + + -- fan speed (bits23-20): 0=low, 1=mid, 2=high; 3=auto and 0xF are ignored. + if fan_set <= 2 then + local fan_mode = FAN_TO_MODE[fan_set] or SPEED.MEDIUM + device:set_field("fan_mode_ac", fan_set) + save_current_mode_field(device, FIELD.FAN_MODE, fan_mode) + device:emit_event(capabilities.fanMode.fanMode(fan_mode)) + end + + -- swing mode (bits17-16): 0=swing, 1=fixed; other values are ignored. + local swing_bit = (lo32 >> 16) & 0x3 + if swing_bit == 0 then + device:set_field("fan_mode", OSC.SWING) + save_current_mode_field(device, FIELD.SWING, OSC.SWING) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.SWING)) + elseif swing_bit == 1 then + device:set_field("fan_mode", OSC.FIXED) + save_current_mode_field(device, FIELD.SWING, OSC.FIXED) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.FIXED)) + end + + -- 0xF in the pwr nibble is the "no change" sentinel; mode bits are unreliable. + if pwr == 0xF then return end + + local st_mode + if pwr == 0x0 then + st_mode = ST_MODE.OFF + else + st_mode = AC_MODE_TO_ST[mode] or ST_MODE.HEAT + end + + -- Suppress a transient "off" report that arrives between a mode change + -- request and the device confirming the new mode. + local pending = device:get_field("pending_on_mode") + if st_mode ~= ST_MODE.OFF then + device:set_field("pending_on_mode", nil) + else + if pending ~= nil then return end + end + + local current = device:get_field("thermostat_mode") + if current ~= st_mode then + device:set_field("thermostat_mode", st_mode) + device:emit_event(capabilities.thermostatMode.thermostatMode(st_mode)) + end +end + +local SUPPORTED_THERMOSTAT_MODES = { + capabilities.thermostatMode.thermostatMode.off.NAME, + capabilities.thermostatMode.thermostatMode.heat.NAME, + capabilities.thermostatMode.thermostatMode.dryair.NAME, + capabilities.thermostatMode.thermostatMode.cool.NAME, + capabilities.thermostatMode.thermostatMode.fanonly.NAME +} + +local SUPPORTED_FAN_MODES = { + capabilities.fanOscillationMode.fanOscillationMode.swing.NAME, + capabilities.fanOscillationMode.fanOscillationMode.fixed.NAME +} + +local SUPPORTED_SPEED_MODES = { SPEED.LOW, SPEED.MEDIUM, SPEED.HIGH } + +-- Lifecycle handlers +local function device_init(driver, device) + device:emit_event(capabilities.thermostatMode.supportedThermostatModes( + SUPPORTED_THERMOSTAT_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.fanOscillationMode.supportedFanOscillationModes( + SUPPORTED_FAN_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.fanMode.supportedFanModes( + SUPPORTED_SPEED_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpointRange( + { value = { minimum = 16, maximum = 45, step = 1 }, unit = "C" } + )) + handle_refresh(driver, device) +end + +local function device_added(driver, device) + if device:get_latest_state("main", capabilities.thermostatHeatingSetpoint.ID, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME) == nil then + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint( + { value = 25, unit = "C" } + )) + send_ac_code(device, { setpoint = 25 }) + end + if device:get_latest_state("main", capabilities.fanMode.ID, + capabilities.fanMode.fanMode.NAME) == nil then + device:emit_event(capabilities.fanMode.fanMode(SPEED.MEDIUM)) + end + if device:get_latest_state("main", capabilities.fanOscillationMode.ID, + 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) + local start_time = (tonumber(new[nightLightStartTime]) * 60) & 0xFFF + local end_time = (tonumber(new[nightLightEndTime]) * 60) & 0xFFF + local on_val = (end_time << 12) | start_time + local val = new[nightLightMode] and on_val or (on_val + 1) + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_NIGHT_LIGHT, + aqara.MFG_CODE, data_types.Uint32, val)) +end + +local function info_changed(driver, device, event, args) + if args.old_st_store.preferences == nil then return end + + local old = args.old_st_store.preferences + local new = device.preferences + + -- Night-light: re-send when the on/off toggle flips, or when the schedule + -- changes while the feature is enabled. + local mode_changed = old[nightLightMode] ~= new[nightLightMode] + local time_changed = + old[nightLightEndTime] ~= new[nightLightEndTime] or + old[nightLightStartTime] ~= new[nightLightStartTime] + if mode_changed then + send_night_light(device, new) + elseif time_changed and new[nightLightMode] == true then + send_night_light(device, new) + end + + -- Mute beep ("do not disturb"). On first init we always push the value so + -- the device matches the preference even if it was changed before pairing. + if old[muteBeep] ~= new[muteBeep] or device:get_field("inited") == nil then + local val = new[muteBeep] and 1 or 0 + device:set_field("inited", true) + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_DND_BEEP, + aqara.MFG_CODE, data_types.Uint8, val)) + -- When un-muted, configure the DND window to span 24h (00:18 - 00:18). + if val == 0 then + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_DND_TIME, + aqara.MFG_CODE, data_types.Uint32, 0x00120012)) + end + end + + -- Constant-temperature thermostat control switch. + if old[thermostatCtrl] ~= new[thermostatCtrl] then + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_THERMOSTAT_CTRL_SW, + aqara.MFG_CODE, data_types.Uint8, new[thermostatCtrl] and 1 or 0)) + end +end + +local aqara_bathroom_heater_driver_template = { + supported_capabilities = { + capabilities.switch, + capabilities.switchLevel, + capabilities.colorTemperature, + capabilities.thermostatMode, + capabilities.thermostatHeatingSetpoint, + capabilities.fanOscillationMode, + capabilities.fanMode + }, + + capability_handlers = { + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = handle_thermostat_mode, + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = handle_heating_setpoint, + }, + [capabilities.fanOscillationMode.ID] = { + [capabilities.fanOscillationMode.commands.setFanOscillationMode.NAME] = handle_fan_oscillation_mode, + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = handle_fan_mode, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = handle_refresh, + }, + }, + + zigbee_handlers = { + attr = { + [aqara.CLUSTER_ID] = { + [aqara.ATTR_AC_CODE] = ac_code_attr_handler, + }, + }, + }, + health_check = false, + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed, + }, +} + +defaults.register_for_default_handlers( + aqara_bathroom_heater_driver_template, + aqara_bathroom_heater_driver_template.supported_capabilities, + { native_capability_cmds_enabled = true, native_capability_attrs_enabled = true } +) + +local aqara_bathroom_heater_driver = ZigbeeDriver("aqara-bathroom-heater-t1", aqara_bathroom_heater_driver_template) +aqara_bathroom_heater_driver:run() 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 new file mode 100644 index 0000000000..8dfaa66974 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua @@ -0,0 +1,854 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Consolidated test cases for the Aqara Bath Heater T1 SmartThings Edge driver. +-- +-- IMPORTANT: The test framework fires an "init" lifecycle event before every +-- test (the driver must complete its startup sequence before the test body +-- can run). Because `device_init` emits multiple capability events +-- (supported*, range) and issues three Zigbee attribute reads, `test_init` +-- pre-registers those expectations BEFORE calling `add_test_device(...)`, so +-- each individual test body can ignore the init emissions and focus only on +-- its own test-specific expectations. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local clusters = require "st.zigbee.zcl.clusters" + +local OnOff = clusters.OnOff +local Level = clusters.Level +local ColorControl = clusters.ColorControl + +local AQARA_CLUSTER_ID = 0xFCC0 +local AQARA_MFG_CODE = 0x115F +local ATTR_AC_CODE = 0x024F +local ATTR_THERMOSTAT_CTRL_SW = 0x02BE +local ATTR_DND_BEEP = 0x0256 +local ATTR_DND_TIME = 0x0257 +local ATTR_NIGHT_LIGHT = 0x0518 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("aqara-bath-heater.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Aqara", + model = "lumi.bhf_light.acn001", + server_clusters = { 0x0006, 0x0008, 0x0300, 0xFCC0 } + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "dryair", "cool", "fanonly" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.supportedFanOscillationModes( + { "swing", "fixed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.supportedFanModes( + { "low", "medium", "high" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange( + { value = { minimum = 16, maximum = 45, step = 1 }, unit = "C" }))) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- ---------------------------------------------------------------------------- +-- Helpers +-- ---------------------------------------------------------------------------- + +-- Build the 8-byte big-endian raw payload for the Aqara AC-code Uint64 attr. +local function ac_code_bytes(hi32, lo32) + return string.char( + (hi32 >> 24) & 0xFF, + (hi32 >> 16) & 0xFF, + (hi32 >> 8) & 0xFF, + hi32 & 0xFF, + (lo32 >> 24) & 0xFF, + (lo32 >> 16) & 0xFF, + (lo32 >> 8) & 0xFF, + lo32 & 0xFF + ) +end + +local function expect_ac_code_send(hi32, lo32) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_AC_CODE, AQARA_MFG_CODE, + data_types.Uint64, ac_code_bytes(hi32, lo32)) }) +end + +local function build_ac_code_report(hi32, lo32) + return zigbee_test_utils.build_attribute_report(mock_device, AQARA_CLUSTER_ID, + { { ATTR_AC_CODE, data_types.Uint64.ID, ac_code_bytes(hi32, lo32) } }) +end + +-- ============================================================================ +-- 1. CAPABILITY COMMAND HANDLERS +-- ============================================================================ +-- +-- switch / switchLevel / colorTemperature are handled by the SmartThings +-- default zigbee handlers (registered via defaults.register_for_default_handlers), +-- so their behavior is covered by the framework's own tests and is not +-- re-tested here. + +-- thermostatMode.setThermostatMode -------------------------------------------- + +test.register_coroutine_test( + "Capability thermostatMode 'off' should send AC off code and emit event", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "off" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x0 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0xF << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("off"))) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'heat' should send AC code and restore defaults", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "heat" } + } }) + + -- No prior heatingSetpoint latest state, so the first AC code carries + -- only pwr=1 and mode=0 (no setpoint nibble set). + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + + 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.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'cool' should send AC code (mode=4)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "cool" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x4 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("cool"))) + 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.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'dryair' should send AC code (mode=3)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "dryair" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x3 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("dryair"))) + 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.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'fanonly' should send AC code (mode=5) and restore only fan", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "fanonly" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x5 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("fanonly"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = (0xFFFFFFFF & 0xFF0FFFFF) | (0x1 << 20) + expect_ac_code_send(r_hi, r_lo) + end +) + +-- NOTE: setThermostatMode("unsupported_mode") would hit the driver's +-- `if not ac then return end` guard, but the capability framework validates +-- `mode` against its enum and rejects unknown values before the handler runs. + +-- thermostatHeatingSetpoint.setHeatingSetpoint -------------------------------- + +test.register_coroutine_test( + "Capability heatingSetpoint 28 in non-heat mode produces no observable output", + function() + -- Outside heat mode the handler only stashes the value in a field; the + -- capability event will fire later via the AC-code attribute report from + -- the device, not from this command path. + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 28 } + } }) + end +) + +-- NOTE: setHeatingSetpoint(10) / (60) would exercise the clamp(..., 16, 45), +-- but the profile constrains the setpoint to [16, 45] so framework validation +-- rejects those values. The same clamp is exercised via restore_mode_state +-- ("setThermostatMode heat should restore saved setpoint/swing/fan from +-- mode_state") below. + +test.register_coroutine_test( + "Capability heatingSetpoint in heat mode should send AC code with setpoint", + function() + mock_device:set_field("thermostat_mode", "heat") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 30 } + } }) + + local hi32 = ((3000 & 0xFFFF) << 16) | (0xFFFFFFFF & 0xFFFF) + local lo32 = 0xFFFFFFFF + expect_ac_code_send(hi32, lo32) + end +) + +-- fanOscillationMode.setFanOscillationMode ------------------------------------ + +test.register_coroutine_test( + "Capability fanOscillationMode 'swing' sends AC code with swing bits=0", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "swing" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(hi32, lo32) + end +) + +test.register_coroutine_test( + "Capability fanOscillationMode 'fixed' sends AC code with swing bits=1", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "fixed" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0xFFFCFFFF) | (0x1 << 16) + expect_ac_code_send(hi32, lo32) + end +) + +-- fanMode.setFanMode ---------------------------------------------------------- + +test.register_coroutine_test( + "Capability fanMode 'low' sends AC code with fan bits=0", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "low" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x0 << 20)) + end +) + +test.register_coroutine_test( + "Capability fanMode 'medium' sends AC code with fan bits=1", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "medium" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x1 << 20)) + end +) + +test.register_coroutine_test( + "Capability fanMode 'high' sends AC code with fan bits=2", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "high" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x2 << 20)) + end +) + +-- NOTE: setFanMode("auto") would exercise the `MODE_TO_FAN[fan_mode] or +-- FAN_MID` fallback, but the profile's enabledValues restricts fanMode to +-- {"low","medium","high"}, so "auto" is rejected by framework validation. + +-- refresh --------------------------------------------------------------------- + +test.register_coroutine_test( + "Capability refresh should read OnOff, Level and ColorTemperature", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end +) + +-- ============================================================================ +-- 2. ZIGBEE ATTRIBUTE HANDLERS +-- ============================================================================ +-- +-- OnOff / CurrentLevel / ColorTemperatureMireds attribute reports are handled +-- by the SmartThings default zigbee handlers and emit their corresponding +-- capability events automatically, so they are not re-tested here. + +-- Aqara AC code (0xFCC0 / 0x024F) --------------------------------------------- + +test.register_coroutine_test( + "AC code report: heat + medium fan + swing + setpoint 25.00 should emit all events", + function() + local hi32 = (2500 << 16) | 0xFEFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + 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.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: pwr=0 (off) should emit thermostatMode 'off'", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x0 << 28) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x1 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("off"))) + end +) + +test.register_coroutine_test( + "AC code report: pwr=0xF (invalid) should skip thermostatMode update", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0xFF0FFFFF) | (0x0 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("low"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + end +) + +test.register_coroutine_test( + "AC code report: fan=2 (high) and mode=4 (cool)", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x4 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x2 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("high"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("cool"))) + end +) + +test.register_coroutine_test( + "AC code report: unknown mode bits should fall back to 'heat'", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x7 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: fanonly mode (5)", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x5 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("fanonly"))) + end +) + +test.register_coroutine_test( + "AC code report: pending_on_mode + incoming off should NOT overwrite mode", + function() + mock_device:set_field("pending_on_mode", "heat") + + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x0 << 28) -- pwr=0 (off) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) -- fan=1 (medium) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) -- swing bits=00 (swing) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + -- thermostatMode NOT emitted: pwr=0 → st_mode="off", but pending_on_mode="heat" + -- causes early return from the handler. + end +) + +test.register_coroutine_test( + "AC code report: setpoint 0xFFFF (invalid marker) should skip setpoint emit", + function() + local hi32 = (0xFFFF << 16) | 0xFEFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: invalid frame (b15_8 < 0xFE) should skip setpoint emit", + function() + -- hi32 carries a valid-looking setpoint raw (2800 = 28.00°C); the "invalid" + -- frame marker lives in lo32 bits 15-8 (b15_8). Clearing them to 0x00 + -- makes hi_valid = false, so the setpoint emit MUST be suppressed even + -- though setpoint_raw != 0xFFFF. + local hi32 = (2800 << 16) | 0x0000 + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) -- pwr=1 (on) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) -- mode=0 (heat) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) -- fan=1 (medium) + lo32 = lo32 & 0xFFFF00FF -- b15_8 = 0x00 → frame invalid + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + -- Setpoint NOT emitted because hi_valid=false. + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +-- ============================================================================ +-- 3. LIFECYCLE HANDLERS +-- ============================================================================ + +test.register_coroutine_test( + "Lifecycle init should emit supported* / range events and read attributes", + function() + -- This test only verifies the emissions fired by the automatic init; + -- those expectations are already registered in test_init(). + end +) + +test.register_coroutine_test( + "Lifecycle added should emit defaults (setpoint=25, fan=medium, swing=swing) + AC code", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25, unit = "C" }))) + + local hi32 = ((2500 & 0xFFFF) << 16) | (0xFFFFFFFF & 0xFFFF) + expect_ac_code_send(hi32, 0xFFFFFFFF) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + 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 +) + +-- info_changed helpers -------------------------------------------------------- + +local function expected_night_light_value(start_h, end_h, enabled) + local start_time = (start_h * 60) & 0xFFF + local end_time = (end_h * 60) & 0xFFF + local on_val = (end_time << 12) | start_time + return enabled and on_val or (on_val + 1) +end + +test.register_coroutine_test( + "infoChanged nightLightMode on (first init) should send night-light + DND-beep + DND-time", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9, + ["stse.muteBeep"] = false + } + })) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 0) }) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_TIME, AQARA_MFG_CODE, + data_types.Uint32, 0x00120012) }) + end +) + +test.register_coroutine_test( + "infoChanged nightLightMode off should send night-light disabled value", + function() + mock_device:set_field("inited", true) + + -- The profile default is nightLightMode=false, so passing `false` again + -- would produce old==new and the handler's `mode_changed` branch would + -- not fire. First flip it ON (consuming the resulting "enabled" write), + -- then flip it OFF — which is the transition this test actually covers. + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = false, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, false)) }) + end +) + +test.register_coroutine_test( + "infoChanged night-light time changed while enabled should resend night-light", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 22, + ["stse.nightLightEndTime"] = 8 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(22, 8, true)) }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + end +) + +test.register_coroutine_test( + "infoChanged muteBeep toggled on should write DND-beep=1", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.muteBeep"] = true + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 1) }) + end +) + +test.register_coroutine_test( + "infoChanged thermostatCtrl toggled off should write 0", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.thermostatCtrl"] = false, + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_THERMOSTAT_CTRL_SW, AQARA_MFG_CODE, + data_types.Uint8, 0) }) + end +) + +test.register_coroutine_test( + "infoChanged when not yet initialized should trigger initialization", + function() + -- mock_device:set_field("inited", "") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.muteBeep"] = true + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 1) }) + end +) + +-- ============================================================================ +-- 4. EDGE CASES / BRANCH COVERAGE +-- ============================================================================ + +test.register_coroutine_test( + "setHeatingSetpoint while mode is 'off' produces no observable output", + function() + mock_device:set_field("thermostat_mode", "off") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 22 } + } }) + end +) + +test.register_coroutine_test( + "setFanOscillationMode while mode is 'fanonly' still sends AC code", + function() + mock_device:set_field("thermostat_mode", "fanonly") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "fixed" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFFFCFFFF) | (0x1 << 16)) + end +) + +test.register_coroutine_test( + "setThermostatMode heat should restore saved swing/fan from mode_state", + function() + mock_device:set_field("mode_state.heat.swing", "fixed") + mock_device:set_field("mode_state.heat.fan_mode", "high") + + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "heat" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("high"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x2 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x1 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +-- NOTE: The `if args.old_st_store.preferences == nil then return end` early +-- return in info_changed is defensive code that only fires on a brand-new +-- device. It cannot be reliably reproduced in the SmartThings integration +-- test framework because the mock_device is always built from the profile's +-- preference section (st_store.preferences is always a table), and manually +-- constructing a lifecycle event with preferences=nil is normalized away by +-- the lifecycle dispatcher before the handler sees it. + +test.run_registered_tests() diff --git a/drivers/Aqara/aqara-cube/src/init.lua b/drivers/Aqara/aqara-cube/src/init.lua index cde24f8f23..81078a59a9 100644 --- a/drivers/Aqara/aqara-cube/src/init.lua +++ b/drivers/Aqara/aqara-cube/src/init.lua @@ -125,7 +125,8 @@ local aqara_cube_t1_pro_handler = { lifecycle_handlers = { init = device_init, added = device_added - } + }, + health_check = false, } local aqara_cube_t1_pro_driver = ZigbeeDriver("aqara_cube_t1_pro", aqara_cube_t1_pro_handler) diff --git a/drivers/Aqara/aqara-feeder/src/init.lua b/drivers/Aqara/aqara-feeder/src/init.lua index fba75a42c2..7ab9d64ef8 100644 --- a/drivers/Aqara/aqara-feeder/src/init.lua +++ b/drivers/Aqara/aqara-feeder/src/init.lua @@ -184,6 +184,7 @@ local aqara_pet_feeder_handler = { infoChanged = device_info_changed, doConfigure = device_configure }, + health_check = false, can_handle = function(opts, driver, device, ...) return device:get_model() == "aqara.feeder.acn001" end diff --git a/drivers/Aqara/aqara-lock/capabilities/antiLockStatus.yaml b/drivers/Aqara/aqara-lock/capabilities/antiLockStatus.yaml new file mode 100644 index 0000000000..87909bcd99 --- /dev/null +++ b/drivers/Aqara/aqara-lock/capabilities/antiLockStatus.yaml @@ -0,0 +1,22 @@ +id: stse.antiLockStatus +version: 1 +status: proposed +name: Anti lock status +ephemeral: false +attributes: + antiLockStatus: + schema: + type: object + properties: + value: + title: Anti-Lock status + type: string + enum: + - "enabled" + - "disabled" + - "unknown" + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} \ No newline at end of file diff --git a/drivers/Aqara/aqara-lock/capabilities/lockAlarm.yaml b/drivers/Aqara/aqara-lock/capabilities/lockAlarm.yaml new file mode 100644 index 0000000000..fb26396a32 --- /dev/null +++ b/drivers/Aqara/aqara-lock/capabilities/lockAlarm.yaml @@ -0,0 +1,27 @@ +id: lockAlarm +version: 1 +status: proposed +name: Lock Alarm +ephemeral: false +attributes: + alarm: + schema: + type: object + properties: + value: + type: string + enum: + - clear + - lockFactoryReset + - damaged + - forcedOpeningAttempt + - unableToLockTheDoor + - notClosedForALongTime + - highTemperature + - attemptsExceeded + - physicalImpact + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} diff --git a/drivers/Aqara/aqara-lock/fingerprints.yml b/drivers/Aqara/aqara-lock/fingerprints.yml index c07633921d..feec61e22f 100644 --- a/drivers/Aqara/aqara-lock/fingerprints.yml +++ b/drivers/Aqara/aqara-lock/fingerprints.yml @@ -4,3 +4,13 @@ zigbeeManufacturer: manufacturer: Lumi model: aqara.lock.akr011 deviceProfileName: aqara-lock-battery + - id: "lumi/aqara.lock.akr001" + deviceLabel: "Aqara Doorlock L100" + manufacturer: lumi + model: aqara.lock.akr001 + deviceProfileName: aqara-lock-battery + - id: "Lumi/aqara.lock.akr001" + deviceLabel: "Aqara Doorlock L100" + manufacturer: Lumi + model: aqara.lock.akr001 + deviceProfileName: aqara-lock-battery diff --git a/drivers/Aqara/aqara-lock/profiles/aqara-lock-battery.yml b/drivers/Aqara/aqara-lock/profiles/aqara-lock-battery.yml index 562cf024e6..0fbe7a718b 100644 --- a/drivers/Aqara/aqara-lock/profiles/aqara-lock-battery.yml +++ b/drivers/Aqara/aqara-lock/profiles/aqara-lock-battery.yml @@ -6,7 +6,9 @@ components: version: 1 - id: battery version: 1 - - id: tamperAlert + - id: batteryLevel + version: 1 + - id: lockAlarm version: 1 - id: remoteControlStatus version: 1 @@ -14,6 +16,8 @@ components: version: 1 - id: stse.lockCredentialInfo version: 1 + - id: stse.antiLockStatus + version: 1 categories: - name: SmartLock metadata: diff --git a/drivers/Aqara/aqara-lock/src/credential_utils.lua b/drivers/Aqara/aqara-lock/src/credential_utils.lua index 5725d5f55a..df0e904886 100644 --- a/drivers/Aqara/aqara-lock/src/credential_utils.lua +++ b/drivers/Aqara/aqara-lock/src/credential_utils.lua @@ -6,6 +6,31 @@ local lockCredentialInfo = capabilities["stse.lockCredentialInfo"] local credential_utils = {} local HOST_COUNT = "__host_count" +local PERSIST_DATA = "__persist_area" + +credential_utils.eventResource = function(table) + local credentialResource = {} + for key, value in pairs(table) do + credentialResource[key] = value + end + return credentialResource +end + +credential_utils.backup_data = function(device) -- Back up data the persistent + local credentialInfoTable = utils.deep_copy(device:get_latest_state("main", lockCredentialInfo.ID, + lockCredentialInfo.credentialInfo.NAME, {})) + device:set_field(PERSIST_DATA, credentialInfoTable, { persist = true }) +end + +credential_utils.sync = function(driver, device) + local table = device:get_field(PERSIST_DATA) or nil + if table ~= nil then + device:emit_event(lockCredentialInfo.credentialInfo(credential_utils.eventResource(table), + { visibility = { displayed = false } })) + else + credential_utils.backup_data(device) + end +end credential_utils.save_data = function(driver) driver.datastore:save() @@ -28,6 +53,7 @@ credential_utils.update_remote_control_status = function(driver, device, added) end device:set_field(HOST_COUNT, host_cnt, { persist = true }) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -38,6 +64,7 @@ credential_utils.sync_all_credential_info = function(driver, device, command) end end device:emit_event(lockCredentialInfo.credentialInfo(command.args.credentialInfo, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -73,6 +100,7 @@ credential_utils.upsert_credential_info = function(driver, device, command) end device:emit_event(lockCredentialInfo.credentialInfo(credentialInfoTable, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -95,6 +123,7 @@ credential_utils.delete_user = function(driver, device, command) end device:emit_event(lockCredentialInfo.credentialInfo(credentialInfoTable, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -116,6 +145,7 @@ credential_utils.delete_credential = function(driver, device, command) end device:emit_event(lockCredentialInfo.credentialInfo(credentialInfoTable, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end diff --git a/drivers/Aqara/aqara-lock/src/init.lua b/drivers/Aqara/aqara-lock/src/init.lua index 5d1ee6882e..9afe6bb77e 100644 --- a/drivers/Aqara/aqara-lock/src/init.lua +++ b/drivers/Aqara/aqara-lock/src/init.lua @@ -9,19 +9,26 @@ local utils = require "st.utils" local remoteControlStatus = capabilities.remoteControlStatus local lockCredentialInfo = capabilities["stse.lockCredentialInfo"] +local antiLockStatus = capabilities["stse.antiLockStatus"] local Battery = capabilities.battery local Lock = capabilities.lock -local TamperAlert = capabilities.tamperAlert +local LockAlarm = capabilities.lockAlarm local PRI_CLU = 0xFCC0 local PRI_ATTR = 0xFFF3 local MFG_CODE = 0x115F -local serial_num = 0 -local seq_num = 0 - local SHARED_KEY = "__shared_key" local CLOUD_PUBLIC_KEY = "__cloud_public_key" +local SUPPORTED_ALARM_VALUES = { "damaged", "forcedOpeningAttempt", "unableToLockTheDoor", "notClosedForALongTime", + "highTemperature", "attemptsExceeded" } +local SERIAL_NUM_TX = "serial_num_tx" +local SERIAL_NUM_RX = "serial_num_rx" +local SEQ_NUM = "seq_num" +local THRESHOLD_BATTERY = { + ["aqara.lock.akr011"] = { low = 47, dryout = 28 }, -- K100 + ["aqara.lock.akr001"] = { low = 47, dryout = 31 } -- L100 +} local function my_secret_data_handler(driver, device, secret_info) if secret_info.secret_kind ~= "aqara" then return end @@ -50,10 +57,44 @@ local function remoteControlShow(device) end end +local function comp_supported_alarm_values(last_alarm_values) + if not last_alarm_values then return false end + if #last_alarm_values ~= #SUPPORTED_ALARM_VALUES then return false end + for k, v in pairs(last_alarm_values) do + if SUPPORTED_ALARM_VALUES[k] ~= v then return false end + end + return true +end + +local function device_init(self, device) + device:set_field(SERIAL_NUM_RX, 0) + device:set_field(SERIAL_NUM_TX, 1) + device:set_field(SEQ_NUM, 0) + local last_alarm_values = device:get_latest_state("main", LockAlarm.ID, LockAlarm.supportedAlarmValues.NAME) or {} + if not comp_supported_alarm_values(last_alarm_values) then + device:emit_event( + LockAlarm.supportedAlarmValues(SUPPORTED_ALARM_VALUES, { visibility = { displayed = false } }) + ) + end + device:emit_event(capabilities.lock.supportedUnlockDirections({ "fromInside", "fromOutside" }, + { visibility = { displayed = false } })) + device:emit_event(capabilities.battery.type("AA")) + device:emit_event(capabilities.batteryLevel.type("AA")) + local battery_quantity = 8 + if device:get_model() == "aqara.lock.akr001" then + battery_quantity = 6 + end + device:emit_event(capabilities.battery.quantity(battery_quantity)) + device:emit_event(capabilities.batteryLevel.quantity(battery_quantity)) + credential_utils.sync(self, device) +end + local function device_added(self, device) remoteControlShow(device) device:emit_event(Battery.battery(100)) - device:emit_event(TamperAlert.tamper.clear({ visibility = { displayed = false } })) + device:emit_event(capabilities.batteryLevel.battery("normal")) + device:emit_event(LockAlarm.alarm.clear({ visibility = { displayed = false } })) + device:emit_event(antiLockStatus.antiLockStatus("unknown", { visibility = { displayed = false } })) device:emit_event(Lock.lock.locked()) credential_utils.save_data(self) end @@ -66,64 +107,116 @@ local function toHex(value, length) return utils.serialize_int(value, length, false, false) end +local METHOD = { + LOCKED = "locked", + MANUAL = "manual", + FINGERPRINT = "fingerprint", + KEYPAD = "keypad", + RFID = "rfid", + RF447 = "rf447", + BLUETOOTH = "bluetooth", + COMMAND = "command", + NO_USE = "" +} + local function event_lock_handler(driver, device, evt_name, evt_value) - if evt_value == 1 then + if evt_value == 0x1 then device:emit_event(Lock.lock(evt_name)) - device:emit_event(TamperAlert.tamper.clear()) + device:emit_event(LockAlarm.alarm.clear({ visibility = { displayed = false } })) remoteControlShow(device) end end -local function event_unlock_handler(driver, device, evt_name, evt_value) - local id, label - id, label = credential_utils.find_userLabel(driver, device, evt_value) - device:emit_event(Lock.lock.unlocked({ data = { method = evt_name, codeId = id, codeName = label } })) +local function event_unlock_indoor_handler(driver, device, evt_name, evt_value) + device:emit_event(Lock.lock.unlocked({ data = { method = evt_name, codeId = nil, codeName = nil, unlockDirection = "fromInside" } })) + device:emit_event(remoteControlStatus.remoteControlEnabled('false', { visibility = { displayed = false } })) + device:emit_event(LockAlarm.alarm.clear({ visibility = { displayed = false } })) +end + +local function event_unlock_outdoor_handler(driver, device, evt_name, evt_value) + local id, label = credential_utils.find_userLabel(driver, device, evt_value) + device:emit_event(Lock.lock.unlocked({ data = { method = evt_name, codeId = id, codeName = label, unlockDirection = "fromOutside" } })) device:emit_event(remoteControlStatus.remoteControlEnabled('false', { visibility = { displayed = false } })) - device:emit_event(TamperAlert.tamper.clear()) + device:emit_event(LockAlarm.alarm.clear({ visibility = { displayed = false } })) +end + +local function event_unlock_rf447_handler(driver, device, evt_name, evt_value) + device:emit_event(Lock.lock.unlocked({ data = { method = evt_name, codeId = nil, codeName = nil, unlockDirection = nil } })) + device:emit_event(remoteControlStatus.remoteControlEnabled('false', { visibility = { displayed = false } })) + device:emit_event(LockAlarm.alarm.clear({ visibility = { displayed = false } })) +end + +local function event_unlock_remote_handler(driver, device, evt_name, evt_value) + device:emit_event(Lock.lock.unlocked({ data = { method = evt_name, codeId = nil, codeName = nil, unlockDirection = nil } })) + device:emit_event(remoteControlStatus.remoteControlEnabled('false', { visibility = { displayed = false } })) + device:emit_event(LockAlarm.alarm.clear({ visibility = { displayed = false } })) +end + +local function event_unlock_otp_handler(driver, device, evt_name, evt_value) + device:emit_event(Lock.lock.unlocked({ data = { method = evt_name, codeId = "OTP_STANDALONE", codeName = nil, unlockDirection = "fromOutside" } })) + device:emit_event(remoteControlStatus.remoteControlEnabled('false', { visibility = { displayed = false } })) + device:emit_event(LockAlarm.alarm.clear({ visibility = { displayed = false } })) end local function event_door_handler(driver, device, evt_name, evt_value) - if evt_value == 2 then - device:emit_event(Lock.lock(evt_name)) - device:emit_event(TamperAlert.tamper.clear()) - elseif evt_value == 4 then - device:emit_event(TamperAlert.tamper.detected()) + if evt_value == 0x2 then + device:emit_event(LockAlarm.alarm.notClosedForALongTime()) + elseif evt_value == 0x4 then + device:emit_event(LockAlarm.alarm.forcedOpeningAttempt()) end end +local function calc_battery_level(model, level) + local batteryLevel = "normal" + if level < THRESHOLD_BATTERY[model].dryout then + batteryLevel = "critical" + elseif level < THRESHOLD_BATTERY[model].low then + batteryLevel = "warning" + end + return batteryLevel +end local function event_battery_handler(driver, device, evt_name, evt_value) device:emit_event(Battery.battery(evt_value)) + device:emit_event(capabilities.batteryLevel.battery(calc_battery_level(device:get_model(), evt_value))) end -local function event_tamper_alert_handler(driver, device, evt_name, evt_value) - device:emit_event(TamperAlert.tamper.detected()) +local function event_abnormal_status_handler(driver, device, evt_name, evt_value) + if evt_value == 0xC0DE1006 then + device:emit_event(LockAlarm.alarm.highTemperature()) + elseif evt_value == 0xC0DE000A then + device:emit_event(LockAlarm.alarm.attemptsExceeded({ state_change = true })) + end end -local METHOD = { - LOCKED = "locked", - NOT_FULLY_LOCKED = "not fully locked", - MANUAL = "manual", - FINGERPRINT = "fingerprint", - KEYPAD = "keypad", - RFID = "rfid", - BLUETOOTH = "bluetooth", - COMMAND = "command", - NO_USE = "" -} +local function event_anti_lock_handler(driver, device, evt_name, evt_value) + local evt = "disabled" + if evt_value == 0x1 then evt = "enabled" end + device:emit_event(antiLockStatus.antiLockStatus(evt)) +end +local function event_lock_status_handler(driver, device, evt_name, evt_value) + if evt_value == 0x1 then + device:emit_event(LockAlarm.alarm.unableToLockTheDoor()) + elseif evt_value == 0xA then + device:emit_event(LockAlarm.alarm.damaged()) + end +end local resource_id = { ["13.31.85"] = { event_name = METHOD.LOCKED, event_handler = event_lock_handler }, - ["13.17.85"] = { event_name = METHOD.NOT_FULLY_LOCKED, event_handler = event_door_handler }, - ["13.48.85"] = { event_name = METHOD.MANUAL, event_handler = event_unlock_handler }, - ["13.51.85"] = { event_name = METHOD.MANUAL, event_handler = event_unlock_handler }, - ["13.42.85"] = { event_name = METHOD.FINGERPRINT, event_handler = event_unlock_handler }, - ["13.43.85"] = { event_name = METHOD.KEYPAD, event_handler = event_unlock_handler }, - ["13.44.85"] = { event_name = METHOD.RFID, event_handler = event_unlock_handler }, - ["13.45.85"] = { event_name = METHOD.BLUETOOTH, event_handler = event_unlock_handler }, - ["13.90.85"] = { event_name = METHOD.COMMAND, event_handler = event_unlock_handler }, - ["13.46.85"] = { event_name = METHOD.KEYPAD, event_handler = event_unlock_handler }, + ["13.48.85"] = { event_name = METHOD.MANUAL, event_handler = event_unlock_indoor_handler }, + ["13.51.85"] = { event_name = METHOD.MANUAL, event_handler = event_unlock_indoor_handler }, + ["13.42.85"] = { event_name = METHOD.FINGERPRINT, event_handler = event_unlock_outdoor_handler }, + ["13.43.85"] = { event_name = METHOD.KEYPAD, event_handler = event_unlock_outdoor_handler }, + ["13.44.85"] = { event_name = METHOD.RFID, event_handler = event_unlock_outdoor_handler }, + ["13.151.85"] = { event_name = METHOD.RF447, event_handler = event_unlock_rf447_handler }, + ["13.45.85"] = { event_name = METHOD.BLUETOOTH, event_handler = event_unlock_remote_handler }, + ["13.90.85"] = { event_name = METHOD.COMMAND, event_handler = event_unlock_remote_handler }, + ["13.46.85"] = { event_name = METHOD.KEYPAD, event_handler = event_unlock_otp_handler }, + ["13.17.85"] = { event_name = METHOD.NO_USE, event_handler = event_door_handler }, ["13.56.85"] = { event_name = METHOD.NO_USE, event_handler = event_battery_handler }, - ["13.32.85"] = { event_name = METHOD.NO_USE, event_handler = event_tamper_alert_handler } + ["13.32.85"] = { event_name = METHOD.NO_USE, event_handler = event_abnormal_status_handler }, + ["13.33.85"] = { event_name = METHOD.NO_USE, event_handler = event_anti_lock_handler }, + ["13.88.85"] = { event_name = METHOD.NO_USE, event_handler = event_lock_status_handler } } local function request_generate_shared_key(device) @@ -147,6 +240,8 @@ local function lock_state_handler(driver, device, value, zb_rx) if res then print(res) end + device:set_field(SERIAL_NUM_RX, 0) + device:set_field(SERIAL_NUM_TX, 1) elseif shared_key == nil then request_generate_shared_key(device) elseif command == "\x93" then @@ -157,17 +252,17 @@ local function lock_state_handler(driver, device, value, zb_rx) local text = string.sub(msg, 5, string.len(msg)) local payload = string.sub(text, 4, string.len(text)) local func_id = toValue(payload, 1, 1) .. "." .. toValue(payload, 2, 1) .. "." .. toValue(payload, 3, 2) - serial_num = toValue(msg, 3, 2) - seq_num = string.byte(text, 3) + local serial_num = toValue(msg, 3, 2) + local last_serial_num = device:get_field(SERIAL_NUM_RX) or 0 - if resource_id[func_id] then - resource_id[func_id].event_handler(driver, device, resource_id[func_id].event_name, - toValue(payload, 6, string.byte(payload, 5))) - end - - if serial_num >= 0xFFFF then - device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRI_CLU, PRI_ATTR, MFG_CODE, data_types.OctetString, "\x2B")) + if serial_num > last_serial_num then + device:set_field(SERIAL_NUM_RX, serial_num) + if resource_id[func_id] then + resource_id[func_id].event_handler(driver, device, resource_id[func_id].event_name, + toValue(payload, 6, string.byte(payload, 5))) + end + else + request_generate_shared_key(device) end end end @@ -177,23 +272,35 @@ local function send_msg(device, funcA, funcB, funcC, op_code, length, value) if shared_key == nil then request_generate_shared_key(device) else + local seq_num = device:get_field(SEQ_NUM) or 0 + local serial_num = device:get_field(SERIAL_NUM_TX) or 1 + local payload = toHex(funcA, 1) .. toHex(funcB, 1) .. toHex(funcC, 2) .. toHex(length, 1) .. toHex(value, length) - local text = "\x00" .. toHex(op_code, 1) .. toHex(seq_num+1, 1) .. payload - local raw_data = "\x5B" .. toHex(string.len(text), 1) .. toHex(serial_num+1, 2) .. text + local text = "\x00" .. toHex(op_code, 1) .. toHex(seq_num, 1) .. payload + local raw_data = "\x5B" .. toHex(string.len(text), 1) .. toHex(serial_num, 2) .. text for i = 1, 4 - (string.len(raw_data) % 4) do raw_data = raw_data .. "\x00" end local opts = { cipher = "aes256-ecb", padding = false } local raw_key = base64.decode(shared_key) + if raw_key ~= nil then local result = security.encrypt_bytes(raw_data, raw_key, opts) if result ~= nil then local msg = "\x93" .. result device:send(cluster_base.write_manufacturer_specific_attribute(device, PRI_CLU, PRI_ATTR, MFG_CODE, data_types.OctetString, msg)) - seq_num = seq_num + 1 - serial_num = serial_num + 1 + if seq_num == 0xFF then + device:set_field(SEQ_NUM, 0) + else + device:set_field(SEQ_NUM, seq_num + 1) + end + if serial_num == 0xFFFF then + request_generate_shared_key(device) + else + device:set_field(SERIAL_NUM_TX, serial_num + 1) + end end end end @@ -207,6 +314,7 @@ local aqara_locks_handler = { NAME = "Aqara Doorlock K100", supported_capabilities = { Lock, + LockAlarm, Battery, lockCredentialInfo, capabilities.refresh, @@ -230,11 +338,13 @@ local aqara_locks_handler = { } }, lifecycle_handlers = { + init = device_init, added = device_added }, secret_data_handlers = { [security.SECRET_KIND_AQARA] = my_secret_data_handler - } + }, + health_check = false, } local aqara_locks_driver = ZigbeeDriver("aqara_locks_k100", aqara_locks_handler) diff --git a/drivers/Aqara/aqara-lock/src/test/test_aqara_lock.lua b/drivers/Aqara/aqara-lock/src/test/test_aqara_lock.lua index 9cee9fc0c0..8fcec06b5f 100644 --- a/drivers/Aqara/aqara-lock/src/test/test_aqara_lock.lua +++ b/drivers/Aqara/aqara-lock/src/test/test_aqara_lock.lua @@ -6,11 +6,15 @@ local data_types = require "st.zigbee.data_types" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local remoteControlStatus = capabilities.remoteControlStatus +local antiLockStatus = capabilities["stse.antiLockStatus"] +test.add_package_capability("antiLockStatus.yaml") local lockCredentialInfo = capabilities["stse.lockCredentialInfo"] test.add_package_capability("lockCredentialInfo.yaml") +local lockAlarm = capabilities["lockAlarm"] +test.add_package_capability("lockAlarm.yaml") local Battery = capabilities.battery +local BatteryLevel = capabilities.batteryLevel local Lock = capabilities.lock -local TamperAlert = capabilities.tamperAlert local PRI_CLU = 0xFCC0 local PRI_ATTR = 0xFFF3 @@ -35,6 +39,16 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + local SUPPORTED_ALARM_VALUES = { "damaged", "forcedOpeningAttempt", "unableToLockTheDoor", "notClosedForALongTime", + "highTemperature", "attemptsExceeded" } + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockAlarm.supportedAlarmValues(SUPPORTED_ALARM_VALUES, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + Lock.supportedUnlockDirections({"fromInside", "fromOutside"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.type("AA"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.type("AA"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.quantity(8))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.quantity(8))) test.mock_device.add_test_device(mock_device) end test.set_test_init_function(test_init) @@ -48,8 +62,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", remoteControlStatus.remoteControlEnabled('false', { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.battery("normal"))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", - TamperAlert.tamper("clear", { visibility = { displayed = false } }))) + lockAlarm.alarm.clear({ visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + antiLockStatus.antiLockStatus('unknown', { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", Lock.lock("locked"))) end ) @@ -63,8 +80,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", remoteControlStatus.remoteControlEnabled('true', { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.battery("normal"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockAlarm.alarm.clear({ visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", - TamperAlert.tamper("clear", { visibility = { displayed = false } }))) + antiLockStatus.antiLockStatus('unknown', { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", Lock.lock("locked"))) end ) diff --git a/drivers/Aqara/aqara-lock/src/test/test_aqara_lock_L100.lua b/drivers/Aqara/aqara-lock/src/test/test_aqara_lock_L100.lua new file mode 100644 index 0000000000..3801183490 --- /dev/null +++ b/drivers/Aqara/aqara-lock/src/test/test_aqara_lock_L100.lua @@ -0,0 +1,77 @@ +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local remoteControlStatus = capabilities.remoteControlStatus +local antiLockStatus = capabilities["stse.antiLockStatus"] +test.add_package_capability("antiLockStatus.yaml") +local lockCredentialInfo = capabilities["stse.lockCredentialInfo"] +test.add_package_capability("lockCredentialInfo.yaml") +local lockAlarm = capabilities["lockAlarm"] +test.add_package_capability("lockAlarm.yaml") +local Battery = capabilities.battery +local BatteryLevel = capabilities.batteryLevel +local Lock = capabilities.lock + +local PRI_CLU = 0xFCC0 + +local HOST_COUNT = "__host_count" +local PERSIST_DATA = "__persist_area" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-lock-battery.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Lumi", + model = "aqara.lock.akr001", + server_clusters = { PRI_CLU } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + local SUPPORTED_ALARM_VALUES = { "damaged", "forcedOpeningAttempt", "unableToLockTheDoor", "notClosedForALongTime", + "highTemperature", "attemptsExceeded" } + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockAlarm.supportedAlarmValues(SUPPORTED_ALARM_VALUES, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + Lock.supportedUnlockDirections({"fromInside", "fromOutside"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.type("AA"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.type("AA"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.quantity(6))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.quantity(6))) + local credentialInfoData = { + { credentialId = 1, credentialType = "keypad", userId = "1", userLabel = "june", userType = "host" } + } + mock_device:set_field(PERSIST_DATA, credentialInfoData, { persist = true }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockCredentialInfo.credentialInfo(credentialInfoData, { visibility = { displayed = false } }))) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle - only regular user", + function() + mock_device:set_field(HOST_COUNT, 1, { persist = true }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + remoteControlStatus.remoteControlEnabled('true', { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.battery("normal"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockAlarm.alarm.clear({ visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + antiLockStatus.antiLockStatus('unknown', { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Lock.lock("locked"))) + end +) + +test.run_registered_tests() diff --git a/drivers/Aqara/aqara-presence-sensor/config.yml b/drivers/Aqara/aqara-presence-sensor/config.yml new file mode 100644 index 0000000000..83bc690de3 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/config.yml @@ -0,0 +1,5 @@ +name: 'Aqara Presence Sensor' +packageKey: 'aqara-presence-sensor' +permissions: + lan: {} + discovery: {} diff --git a/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-fallDetection.yml b/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-fallDetection.yml new file mode 100644 index 0000000000..0455fb3946 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-fallDetection.yml @@ -0,0 +1,20 @@ +name: aqara-fp2-fallDetection +components: + - id: main + capabilities: + - id: activitySensor + version: 1 + - id: presenceSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor + - id: mode + capabilities: + - id: stse.deviceMode + version: 1 + categories: + - name: PresenceSensor diff --git a/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-sleepMonitoring.yml b/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-sleepMonitoring.yml new file mode 100644 index 0000000000..cb71dc61f9 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-sleepMonitoring.yml @@ -0,0 +1,16 @@ +name: aqara-fp2-sleepMonitoring +components: + - id: main + capabilities: + - id: illuminanceMeasurement + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor + - id: mode + capabilities: + - id: stse.deviceMode + version: 1 + categories: + - name: PresenceSensor diff --git a/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-zoneDetection.yml b/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-zoneDetection.yml new file mode 100644 index 0000000000..f0d466c16e --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/profiles/aqara-fp2-zoneDetection.yml @@ -0,0 +1,22 @@ +name: aqara-fp2-zoneDetection +components: + - id: main + capabilities: + - id: presenceSensor + version: 1 + - id: movementSensor + version: 1 + - id: multipleZonePresence + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor + - id: mode + capabilities: + - id: stse.deviceMode + version: 1 + categories: + - name: PresenceSensor diff --git a/drivers/Aqara/aqara-presence-sensor/search-parameters.yml b/drivers/Aqara/aqara-presence-sensor/search-parameters.yml new file mode 100644 index 0000000000..4866e6eb16 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/search-parameters.yml @@ -0,0 +1,3 @@ +mdns: + - service: "_Aqara-FP2._tcp" + - service: "_aqara-fp2._tcp" diff --git a/drivers/Aqara/aqara-presence-sensor/src/discovery.lua b/drivers/Aqara/aqara-presence-sensor/src/discovery.lua new file mode 100644 index 0000000000..c4145f1294 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/discovery.lua @@ -0,0 +1,133 @@ +local log = require "log" +local fields = require "fields" +local discovery_mdns = require "discovery_mdns" +local socket = require "cosock.socket" + +local discovery = { + last_try_time = {} +} + +function discovery.set_device_field(driver, device) + local device_cache_value = driver.datastore.discovery_cache[device.device_network_id] + + -- persistent fields + if device_cache_value ~= nil then + log.info_with({ hub_logs = true }, string.format("device found in cache. dni= %s", device.device_network_id)) + device:set_field(fields.CREDENTIAL, device_cache_value.credential, { persist = true }) + device:set_field(fields.DEVICE_IPV4, device_cache_value.ip, { persist = true }) + device:set_field(fields.DEVICE_INFO, device_cache_value.device_info, { persist = true }) + else + log.error_with({ hub_logs = true }, string.format("device not found in cache. dni= %s", device.device_network_id)) + end + + driver.datastore.discovery_cache[device.device_network_id] = nil +end + +local function update_device_discovery_cache(driver, dni, ip, device_info) + if driver.datastore.discovery_cache[dni] == nil then + driver.datastore.discovery_cache[dni] = {} + end + driver.datastore.discovery_cache[dni].ip = ip + driver.datastore.discovery_cache[dni].device_info = device_info +end + +local function try_add_device(driver, device_dni, device_ip) + log.trace(string.format("try_add_device : dni= %s, ip= %s", device_dni, device_ip)) + local device_info = driver.discovery_helper.get_device_info(driver, device_dni, device_ip) + + update_device_discovery_cache(driver, device_dni, device_ip, device_info) + local create_device_msg = driver.discovery_helper.get_device_create_msg(driver, device_dni, device_ip, device_info) + if not create_device_msg then + log.error_with({ hub_logs = true }, string.format("Failed to get device info. dni= %s, ip= %s", device_dni, device_ip)) + return "device info not found" + end + + local credential = driver.discovery_helper.get_credential(driver, device_dni, device_ip) + + if not credential then + if driver.datastore.discovery_cache[device_dni] and driver.datastore.discovery_cache[device_dni].credential then + log.info(string.format("use stored credential. This may have expired. dni= %s, ip= %s", device_dni, device_ip)) + else + log.error_with({ hub_logs = true }, string.format("Failed to get credential. The device appears to have already generated a credential. In that case, a device reset is needed to generate a new credential. dni= %s, ip= %s", device_dni, device_ip)) + return "credential not found" + end + else + log.info(string.format("success to get credential. dni= %s, ip= %s", device_dni, device_ip)) + driver.datastore.discovery_cache[device_dni].credential = credential + end + + log.info_with({ hub_logs = true }, string.format("try_create_device. dni= %s, ip= %s", device_dni, device_ip)) + + local success, ret = pcall(driver.try_create_device, driver, create_device_msg) + if success then + discovery.last_try_time[device_dni] = os.time() + else + log.error_with({ hub_logs = true }, string.format("Failed to try_create_device. dni= %s, %s", device_dni, ret)) + end + return nil +end + +function discovery.device_added(driver, device) + log.info_with({ hub_logs = true }, string.format("device_added. dni= %s", device.device_network_id)) + discovery.set_device_field(driver, device) + discovery.last_try_time[device.device_network_id] = nil + driver.lifecycle_handlers.init(driver, device) +end + +function discovery.find_ip_table(driver) + return discovery_mdns.find_ip_table_by_mdns(driver) +end + +local function discovery_device(driver) + local unknown_discovered_devices = {} + local known_discovered_devices = {} + local known_devices = {} + + for _, device in pairs(driver:get_devices()) do + known_devices[device.device_network_id] = device + end + + local ip_table = discovery.find_ip_table(driver) + + for dni, ip in pairs(ip_table) do + if not known_devices[dni] then + unknown_discovered_devices[dni] = ip + else + known_discovered_devices[dni] = ip + end + end + + for dni, ip in pairs(known_discovered_devices) do + log.trace(string.format("known dni= %s, ip= %s", dni, ip)) + end + + for dni, ip in pairs(unknown_discovered_devices) do + log.trace(string.format("unknown dni= %s, ip= %s", dni, ip)) + local is_already_added = false + for _, device in pairs(driver:get_devices()) do + if device.device_network_id == dni then + is_already_added = true + break + end + end + if not is_already_added then + local time_since_last_try = os.time() - (discovery.last_try_time[dni] or 0) + if (not discovery.last_try_time[dni]) or (time_since_last_try > 10) then + try_add_device(driver, dni, ip) + else + log.trace(string.format("skip adding device because it was tried recently. dni= %s, ip= %s, since %s sec", dni, ip, time_since_last_try)) + end + end + end +end + +function discovery.do_network_discovery(driver, _, should_continue) + log.info_with({ hub_logs = true }, string.format("discovery start for Aqara FP2")) + discovery.last_try_time = {} + while should_continue() do + discovery_device(driver) + socket.sleep(1) + end +end + +return discovery diff --git a/drivers/Aqara/aqara-presence-sensor/src/discovery_mdns.lua b/drivers/Aqara/aqara-presence-sensor/src/discovery_mdns.lua new file mode 100644 index 0000000000..c00267da65 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/discovery_mdns.lua @@ -0,0 +1,143 @@ +local log = require "log" +local mdns = require "st.mdns" +local net_utils = require "st.net_utils" + +local discovery_mdns = {} + +local function byte_array_to_plain_text(byte_array) + return string.char(table.unpack(byte_array)) +end + +local function get_text_by_srvname(srvname, discovery_responses) + for _, answer_item in pairs(discovery_responses.answers or {}) do + if answer_item.kind.TxtRecord ~= nil and answer_item.name == srvname then + return answer_item.kind.TxtRecord.text + end + end +end + +local function get_srvname_by_hostname(hostname, discovery_responses) + for _, answer_item in pairs(discovery_responses.answers or {}) do + if answer_item.kind.SrvRecord ~= nil and answer_item.kind.SrvRecord.target == hostname then + return answer_item.name + end + end +end + +local function get_hostname_by_ip(ip, discovery_responses) + for _, answer_item in pairs(discovery_responses.answers or {}) do + if answer_item.kind.ARecord ~= nil and answer_item.kind.ARecord.ipv4 == ip then + return answer_item.name + end + end +end + + +local function find_text_in_answers_by_ip(ip, discovery_responses) + local hostname = get_hostname_by_ip(ip, discovery_responses) + local srvname = get_srvname_by_hostname(hostname, discovery_responses) + local text = get_text_by_srvname(srvname, discovery_responses) + + return text +end + +function discovery_mdns.find_text_list_in_mdns_response(driver, ip, discovery_responses) + local text_list = {} + + for _, found_item in pairs(discovery_responses.found or {}) do + if found_item.host_info.address == ip then + for _, raw_text_array in pairs(found_item.txt.text or {}) do + local text_item = byte_array_to_plain_text(raw_text_array) + table.insert(text_list, text_item) + end + end + end + + local answer_text = find_text_in_answers_by_ip(ip, discovery_responses) + for _, text_item in pairs(answer_text or {}) do + table.insert(text_list, text_item) + end + return text_list +end + +local function filter_response_by_service_name(service_type, domain, discovery_responses) + local filtered_responses = { + answers = {}, + found = {} + } + + for _, answer in pairs(discovery_responses.answers or {}) do + table.insert(filtered_responses.answers, answer) + end + + for _, additional in pairs(discovery_responses.additional or {}) do + table.insert(filtered_responses.answers, additional) + end + + for _, found in pairs(discovery_responses.found or {}) do + if found.service_info.service_type == service_type then + table.insert(filtered_responses.found, found) + end + end + + return filtered_responses +end + +local function insert_dni_ip_from_answers(driver, filtered_responses, target_table) + for _, answer in pairs(filtered_responses.answers) do + local dni, ip + log.info("answer_name, arecod = " .. tostring(answer.name) .. ", " .. tostring(answer.kind.ARecord)) + + if answer.kind.ARecord ~= nil then + ip = answer.kind.ARecord.ipv4 + end + + if ip ~= nil then + dni = driver.discovery_helper.get_dni(driver, ip, filtered_responses) + + if dni ~= nil then + target_table[dni] = ip + end + end + end +end + +local function insert_dni_ip_from_found(driver, filtered_responses, target_table) + for _, found in pairs(filtered_responses.found) do + local dni, ip + log.info("found_name = " .. tostring(found.service_info.service_type)) + if found.host_info.address ~= nil and net_utils.validate_ipv4_string(found.host_info.address) then + log.info("ip = " .. tostring(found.host_info.address)) + ip = found.host_info.address + end + + if ip ~= nil then + dni = driver.discovery_helper.get_dni(driver, ip, filtered_responses) + + if dni ~= nil then + target_table[dni] = ip + end + end + end +end + +local function get_dni_ip_table_from_mdns_responses(driver, service_type, domain, discovery_responses) + local dni_ip_table = {} + + local filtered_responses = filter_response_by_service_name(service_type, domain, discovery_responses) + + insert_dni_ip_from_answers(driver, filtered_responses, dni_ip_table) + insert_dni_ip_from_found(driver, filtered_responses, dni_ip_table) + + return dni_ip_table +end + +function discovery_mdns.find_ip_table_by_mdns(driver) + log.info("discovery_mdns.find_device_ips") + local service_type, domain = driver.discovery_helper.get_service_type_and_domain() + local discovery_responses = mdns.discover(service_type, domain) or { found = {} } + local dni_ip_table = get_dni_ip_table_from_mdns_responses(driver, service_type, domain, discovery_responses) + return dni_ip_table +end + +return discovery_mdns diff --git a/drivers/Aqara/aqara-presence-sensor/src/fields.lua b/drivers/Aqara/aqara-presence-sensor/src/fields.lua new file mode 100644 index 0000000000..95951d85fc --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/fields.lua @@ -0,0 +1,17 @@ +--- Table of constants used to index in to device store fields +--- @module "fields" +--- @class table +--- @field IPV4 string the ipV4 address of the device + +local fields = { + DEVICE_IPV4 = "device_ipv4", + DEVICE_INFO = "device_info", + CONN_INFO = "conn_info", + EVENT_SOURCE = "eventsource", + MONITORING_TIMER = "monitoring_timer", + CREDENTIAL = "credential", + _INIT = "init", + LAST_DISCONNECTED_TIME = "last_disconnected_time" +} + +return fields diff --git a/drivers/Aqara/aqara-presence-sensor/src/fp2/api.lua b/drivers/Aqara/aqara-presence-sensor/src/fp2/api.lua new file mode 100644 index 0000000000..e6c620be3f --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/fp2/api.lua @@ -0,0 +1,91 @@ +local log = require "log" +local json = require "st.json" +local RestClient = require "lunchbox.rest" +local utils = require "utils" +local st_utils = require "st.utils" + +local fp2_api = {} +fp2_api.__index = fp2_api + +local SSL_CONFIG = { + mode = "client", + protocol = "any", + verify = "peer", + options = "all", + cafile = "./selfSignedRootByAqaraLife.crt" +} + +local ADDITIONAL_HEADERS = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", +} + +function fp2_api.labeled_socket_builder(label) + local socket_builder = utils.labeled_socket_builder(label, SSL_CONFIG) + return socket_builder +end + +local function get_base_url(device_ip) + return "https://" .. device_ip .. ":443" +end + +local function process_rest_response(response, err, partial) + if err ~= nil then + return response, err, nil + elseif response ~= nil then + local _, decoded_json = pcall(json.decode, response:get_body()) + return decoded_json, nil, response.status + else + return nil, "no response or error received", nil + end +end + +local function do_get(api_instance, path) + return process_rest_response(RestClient.one_shot_get(api_instance.base_url .. path, api_instance.headers, api_instance.socket_builder)) +end + +function fp2_api.new_device_manager(device_ip, bridge_info, socket_builder) + local base_url = get_base_url(device_ip) + + return setmetatable( + { + headers = st_utils.deep_copy(ADDITIONAL_HEADERS), + socket_builder = socket_builder, + base_url = base_url, + }, fp2_api + ) +end + +function fp2_api:add_header(key, value) + self.headers[key] = value +end + +function fp2_api.get_credential(device_ip, socket_builder) + local response, error, status = process_rest_response(RestClient.one_shot_get(get_base_url(device_ip) .. "/authcode", + ADDITIONAL_HEADERS, socket_builder)) + if not error and status == 200 then + local token = response + return token + else + log.error(string.format("get_credential : ip = %s, failed to get token, error = %s", device_ip, error)) + end +end + +function fp2_api.get_info(device_ip, socket_builder) + return process_rest_response(RestClient.one_shot_get(get_base_url(device_ip) .. "/info", ADDITIONAL_HEADERS, + socket_builder)) +end + +function fp2_api:get_attr() + return do_get(self, "/attr") +end + +function fp2_api:get_remove() + return do_get(self, "/remove") +end + +function fp2_api:get_sse_url() + return self.base_url .. "/status" +end + +return fp2_api diff --git a/drivers/Aqara/aqara-presence-sensor/src/fp2/device_manager.lua b/drivers/Aqara/aqara-presence-sensor/src/fp2/device_manager.lua new file mode 100644 index 0000000000..66d4e8ac6c --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/fp2/device_manager.lua @@ -0,0 +1,279 @@ +local log = require "log" +local json = require "st.json" +local fields = require "fields" +local capabilities = require "st.capabilities" +local multipleZonePresence = require "multipleZonePresence" + +local PresenceSensor = capabilities.presenceSensor +local MovementSensor = capabilities.movementSensor +local ActivitySensor = capabilities.activitySensor +local DeviceMode = capabilities["stse.deviceMode"] + +local MOVEMENT_TIMER = "movement_timer" +local MOVEMENT_TIME = 5 +local COMP_MODE = "mode" + +local device_manager = {} +device_manager.__index = device_manager + +local FP2_MODES = { "zoneDetection", "fallDetection", "sleepMonitoring" } + +function device_manager.presence_handler(driver, device, zone, evt_value) + if not device:supports_capability(PresenceSensor) then return end + local evt_action = "not present" + if evt_value == 1 then evt_action = "present" end + device:emit_event(PresenceSensor.presence(evt_action)) +end + +function device_manager.movement_handler(driver, device, zone, evt_value) + if not device:supports_capability(MovementSensor) then return end + + local val = evt_value + local no_movement = function() + if not device:supports_capability(MovementSensor) then return end + device:emit_event(MovementSensor.movement("inactive")) + end + device:set_field(MOVEMENT_TIMER, device.thread:call_with_delay(MOVEMENT_TIME, no_movement)) + + if val == 0 then + device:emit_event(MovementSensor.movement("entering")) + elseif val == 1 then + device:emit_event(MovementSensor.movement("leaving")) + elseif val == 2 then + device:emit_event(MovementSensor.movement("enteringLeft")) + elseif val == 3 then + device:emit_event(MovementSensor.movement("leavingRight")) + elseif val == 4 then + device:emit_event(MovementSensor.movement("enteringRight")) + elseif val == 5 then + device:emit_event(MovementSensor.movement("leavingLeft")) + elseif val == 6 then + device:emit_event(MovementSensor.movement("approaching")) + elseif val == 7 then + device:emit_event(MovementSensor.movement("movingAway")) + end +end + +function device_manager.zone_presence_handler(driver, device, zone, evt_value) + if not device:supports_capability(capabilities.multipleZonePresence) then return end + + local zoneInfo = multipleZonePresence.findZoneById(driver, device, zone) + if not zoneInfo then + multipleZonePresence.createZone(driver, device, "zone" .. zone, zone) + end + local evt_action = multipleZonePresence.notPresent + if evt_value == 1 then evt_action = multipleZonePresence.present end + multipleZonePresence.changeState(driver, device, zone, evt_action) + multipleZonePresence.updateAttribute(driver, device) +end + +function device_manager.illuminance_handler(driver, device, zone, evt_value) + device:emit_event(capabilities.illuminanceMeasurement.illuminance(evt_value)) +end + +function device_manager.work_mode_handler(driver, device, zone, evt_value) + local cur_mode = device:get_latest_state(COMP_MODE, DeviceMode.ID, DeviceMode.mode.NAME) or 0 + local mode = 1 + local profile_name = "aqara-fp2-zoneDetection" + if not cur_mode then + device:emit_component_event(device.profile.components[COMP_MODE], DeviceMode.mode(FP2_MODES[mode])) + return + elseif evt_value == 0x03 then + if cur_mode == FP2_MODES[1] then return end + elseif evt_value == 0x05 then + if cur_mode == FP2_MODES[2] then return end + mode = 2 + profile_name = "aqara-fp2-fallDetection" + elseif evt_value == 0x09 then + if cur_mode == FP2_MODES[3] then return end + mode = 3 + profile_name = "aqara-fp2-sleepMonitoring" + end + device:emit_component_event(device.profile.components[COMP_MODE], DeviceMode.mode(FP2_MODES[mode])) + device:try_update_metadata({ profile = profile_name }) +end + +function device_manager.zone_quantities_handler(driver, device, zone, evt_value) + if not device:supports_capability(capabilities.multipleZonePresence) then return end + + for i = 0, 29 do + local zonePos = tostring(i + 1) + local zoneInfo = multipleZonePresence.findZoneById(driver, device, zonePos) + local curStatus = 0x1 & (evt_value >> i) + if zoneInfo and curStatus == 0 then -- delete + multipleZonePresence.deleteZone(driver, device, zonePos) + elseif not zoneInfo and curStatus == 1 then -- create + multipleZonePresence.createZone(driver, device, "zone" .. zonePos, zonePos) + multipleZonePresence.changeState(driver, device, zonePos, multipleZonePresence.notPresent) + end + end + multipleZonePresence.updateAttribute(driver, device) +end + +function device_manager.monitoring_mode_handler(driver, device, zone, evt_value) + if not device:supports_capability(MovementSensor) then return end + + local supportedEnum = { "inactive", "approaching", "movingAway", "entering", "leaving" } + if evt_value == 0x01 then + local additional = { "enteringLeft", "enteringRight", "leavingLeft", "leavingRight" } + table.move(additional, 1, #additional, 4, supportedEnum) + end + + device:emit_event(MovementSensor.supportedMovements(supportedEnum, { visibility = { displayed = false } })) +end + +function device_manager.fall_event_handler(driver, device, zone, evt_value) + if not device:supports_capability(ActivitySensor) then return end + + local event_name = "noActivity" + if evt_value == 0x01 then + event_name = "falling" + end + + device:emit_event(ActivitySensor.activity(event_name)) +end + +local resource_id = { + ["3.51.85"] = { zone = "", event_handler = device_manager.presence_handler }, + ["13.27.85"] = { zone = "", event_handler = device_manager.movement_handler }, + ["3.1.85"] = { zone = "1", event_handler = device_manager.zone_presence_handler }, + ["3.2.85"] = { zone = "2", event_handler = device_manager.zone_presence_handler }, + ["3.3.85"] = { zone = "3", event_handler = device_manager.zone_presence_handler }, + ["3.4.85"] = { zone = "4", event_handler = device_manager.zone_presence_handler }, + ["3.5.85"] = { zone = "5", event_handler = device_manager.zone_presence_handler }, + ["3.6.85"] = { zone = "6", event_handler = device_manager.zone_presence_handler }, + ["3.7.85"] = { zone = "7", event_handler = device_manager.zone_presence_handler }, + ["3.8.85"] = { zone = "8", event_handler = device_manager.zone_presence_handler }, + ["3.9.85"] = { zone = "9", event_handler = device_manager.zone_presence_handler }, + ["3.10.85"] = { zone = "10", event_handler = device_manager.zone_presence_handler }, + ["3.11.85"] = { zone = "11", event_handler = device_manager.zone_presence_handler }, + ["3.12.85"] = { zone = "12", event_handler = device_manager.zone_presence_handler }, + ["3.13.85"] = { zone = "13", event_handler = device_manager.zone_presence_handler }, + ["3.14.85"] = { zone = "14", event_handler = device_manager.zone_presence_handler }, + ["3.15.85"] = { zone = "15", event_handler = device_manager.zone_presence_handler }, + ["3.16.85"] = { zone = "16", event_handler = device_manager.zone_presence_handler }, + ["3.17.85"] = { zone = "17", event_handler = device_manager.zone_presence_handler }, + ["3.18.85"] = { zone = "18", event_handler = device_manager.zone_presence_handler }, + ["3.19.85"] = { zone = "19", event_handler = device_manager.zone_presence_handler }, + ["3.20.85"] = { zone = "20", event_handler = device_manager.zone_presence_handler }, + ["3.21.85"] = { zone = "21", event_handler = device_manager.zone_presence_handler }, + ["3.22.85"] = { zone = "22", event_handler = device_manager.zone_presence_handler }, + ["3.23.85"] = { zone = "23", event_handler = device_manager.zone_presence_handler }, + ["3.24.85"] = { zone = "24", event_handler = device_manager.zone_presence_handler }, + ["3.25.85"] = { zone = "25", event_handler = device_manager.zone_presence_handler }, + ["3.26.85"] = { zone = "26", event_handler = device_manager.zone_presence_handler }, + ["3.27.85"] = { zone = "27", event_handler = device_manager.zone_presence_handler }, + ["3.28.85"] = { zone = "28", event_handler = device_manager.zone_presence_handler }, + ["3.29.85"] = { zone = "29", event_handler = device_manager.zone_presence_handler }, + ["3.30.85"] = { zone = "30", event_handler = device_manager.zone_presence_handler }, + ["0.4.85"] = { zone = "", event_handler = device_manager.illuminance_handler }, + ["14.49.85"] = { zone = "", event_handler = device_manager.work_mode_handler }, + ["14.55.85"] = { zone = "", event_handler = device_manager.monitoring_mode_handler }, + ["4.31.85"] = { zone = "", event_handler = device_manager.fall_event_handler }, + ["200.2.20000"] = { zone = "", event_handler = device_manager.zone_quantities_handler } +} + +function device_manager.init_presence(driver, device) + if device:supports_capability(PresenceSensor) + and device:get_latest_state("main", PresenceSensor.ID, PresenceSensor.presence.NAME) == nil then + device:emit_event(PresenceSensor.presence("not present")) + end +end + +function device_manager.init_movement(driver, device) + if not device:supports_capability(MovementSensor) then return end + device:emit_event(MovementSensor.movement("inactive")) +end + +function device_manager.init_activity(driver, device) + if not device:supports_capability(ActivitySensor) then return end + device:emit_event(ActivitySensor.activity("noActivity")) +end + +function device_manager.set_zone_info_to_latest_state(driver, device) + if not device:supports_capability(capabilities.multipleZonePresence) then return end + + local zoneInfoTable = device:get_latest_state("main", capabilities.multipleZonePresence.ID, + capabilities.multipleZonePresence.zoneState.NAME, {}) + multipleZonePresence.setZoneInfo(driver, device, zoneInfoTable) +end + +function device_manager.handle_status(driver, device, status) + if not status then + log.warn("device_manager.handle_status : status is nil") + return + end + + for k, _ in pairs(status) do + if resource_id[k] then + resource_id[k].event_handler(driver, device, resource_id[k].zone, tonumber(status[k])) + else + log.warn("device_manager.handle_status : resource id status is nil") + end + end +end + +function device_manager.update_status(driver, device) + local conn_info = device:get_field(fields.CONN_INFO) + + if not conn_info then + log.warn(string.format("device_manager.update_status : failed to find conn_info, dni = %s", + device.device_network_id)) + return + end + + local _, err, status = conn_info:get_attr() + + if err or status ~= 200 then + log.error(string.format("device_manager.update_status : failed to get status, dni= %s, err= %s, status= %s", + device.device_network_id, err, status)) + if status == 404 then + device:offline() + end + return + end +end + +local sse_event_handlers = { + ["message"] = device_manager.handle_status +} + +function device_manager.handle_sse_event(driver, device, event_type, data) + local _, device_json = pcall(json.decode, data) + + local event_handler = sse_event_handlers[event_type] + if event_handler and device_json then + event_handler(driver, device, device_json) + else + log.error(string.format("handle_sse_event : unknown event type. dni = %s, event_type = '%s'", + device.device_network_id, event_type)) + end +end + +function device_manager.is_valid_connection(driver, device, conn_info) + if not conn_info then + log.warn(string.format("device_manager.is_valid_connection : failed to find conn_info, dni = %s", + device.device_network_id)) + return false + end + + local _, err, status = conn_info:get_attr() + if err or status ~= 200 then + log.warn(string.format( + "device_manager.is_valid_connection : failed to connect to device, dni = %s, err= %s, status= %s", + device.device_network_id, err, status)) + return false + end + + return true +end + +function device_manager.device_monitor(driver, device, device_info) + device_manager.update_status(driver, device) +end + +function device_manager.get_sse_url(driver, device, conn_info) + return conn_info:get_sse_url() +end + +return device_manager diff --git a/drivers/Aqara/aqara-presence-sensor/src/fp2/discovery_helper.lua b/drivers/Aqara/aqara-presence-sensor/src/fp2/discovery_helper.lua new file mode 100644 index 0000000000..422b690db4 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/fp2/discovery_helper.lua @@ -0,0 +1,88 @@ +local log = require "log" +local discovery_helper = {} + +local SERVICE_TYPE = "_Aqara-FP2._tcp" +local DOMAIN = "local" + +local fp2_api = require "fp2.api" +local discovery_mdns = require "discovery_mdns" + +function discovery_helper.get_dni(driver, ip, discovery_responses) + local text_list = discovery_mdns.find_text_list_in_mdns_response(driver, ip, discovery_responses) + for _, text in ipairs(text_list) do + for key, value in string.gmatch(text, "(%S+)=(%S+)") do + if key == "mac" then + return value + end + end + end + + log.error("discovery_helper.get_dni : failed to find dni") + return nil +end + +function discovery_helper.get_service_type_and_domain() + return SERVICE_TYPE, DOMAIN +end + +function discovery_helper.get_device_create_msg(driver, device_dni, device_ip, device_info) + if not device_info then + device_info = fp2_api.get_info(device_ip, fp2_api.labeled_socket_builder(device_dni)) + + if not device_info then + log.warn("failed to create device create msg. device_info is nil.") + return nil + end + end + + local device_label = device_info.label or "Aqara-FP2" + if device_dni then + -- To make it easier to distinguish devices, add the last four letters of dni to the label + -- for example, if device_info.label is "Aqara-FP2" and device_dni is "00:11:22:33:44:55", then device_label will be "Aqara-FP2 (4455)" + device_label = string.format("%s (%s)", device_label, string.sub(string.gsub(tostring(device_dni), ":", ""), -4)) + end + + local create_device_msg = { + type = "LAN", + device_network_id = device_dni, + label = device_label, + profile = "aqara-fp2-zoneDetection", + manufacturer = device_info.manufacturerName, + model = device_info.modelName, + vendor_provided_label = device_info.label, + } + return create_device_msg +end + +function discovery_helper.get_credential(driver, bridge_dni, bridge_ip) + local credential = fp2_api.get_credential(bridge_ip, fp2_api.labeled_socket_builder(bridge_dni)) + + if not credential then + log.warn("credential is nil") + return nil + end + + return "Bearer " .. credential.token +end + +function discovery_helper.get_connection_info(driver, device_dni, device_ip, device_info) + local conn_info = fp2_api.new_device_manager(device_ip, device_info, fp2_api.labeled_socket_builder(device_dni)) + + if conn_info == nil then + log.warn("conn_info is nil") + end + + return conn_info +end + +function discovery_helper.get_device_info(driver, device_dni, device_ip) + local device_info = fp2_api.get_info(device_ip, fp2_api.labeled_socket_builder(device_dni)) + + if device_info == nil then + log.warn("device_info is nil") + end + + return device_info +end + +return discovery_helper diff --git a/drivers/Aqara/aqara-presence-sensor/src/init.lua b/drivers/Aqara/aqara-presence-sensor/src/init.lua new file mode 100644 index 0000000000..1232bcb80e --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/init.lua @@ -0,0 +1,280 @@ +local log = require "log" +local socket = require "cosock.socket" +local capabilities = require "st.capabilities" +local Driver = require "st.driver" +local discovery = require "discovery" +local fields = require "fields" +local fp2_discovery_helper = require "fp2.discovery_helper" +local fp2_device_manager = require "fp2.device_manager" +local fp2_api = require "fp2.api" +local multipleZonePresence = require "multipleZonePresence" +local EventSource = require "lunchbox.sse.eventsource" + +local DEFAULT_MONITORING_INTERVAL = 300 +local OFFLINE_TIMEOUT = 150 +local CREDENTIAL_KEY_HEADER = "Authorization" + +local function handle_sse_event(driver, device, msg) + driver.device_manager.handle_sse_event(driver, device, msg.type, msg.data) +end + +local function status_update(driver, device) + local conn_info = device:get_field(fields.CONN_INFO) + if not conn_info then + log.warn(string.format("refresh : failed to find conn_info, dni = %s", device.device_network_id)) + return false, "failed to find conn_info" + else + local resp, err, status = conn_info:get_attr() + + if err or status ~= 200 then + log.error(string.format("refresh : failed to get attr, dni= %s, err= %s, status= %s", device.device_network_id, err, + status)) + return false, "failed to get attr" + else + driver.device_manager.handle_status(driver, device, resp) + end + end + return true +end + +local function create_sse(driver, device, credential) + local conn_info = device:get_field(fields.CONN_INFO) + + local sse_url = driver.device_manager.get_sse_url(driver, device, conn_info) + if not sse_url then + log.error_with({ hub_logs = true }, "failed to get sse_url") + else + log.trace(string.format("Creating SSE EventSource for %s, sse_url= %s", device.device_network_id, sse_url)) + local label = string.format("%s-SSE", device.device_network_id) + local eventsource = EventSource.new(sse_url, { [CREDENTIAL_KEY_HEADER] = credential }, + fp2_api.labeled_socket_builder(label)) + + eventsource.onmessage = function(msg) + if msg then + handle_sse_event(driver, device, msg) + end + end + + + eventsource.onerror = function() + local last_disconnected_time = device:get_field(fields.LAST_DISCONNECTED_TIME) + local now = os.time() + + if not last_disconnected_time then + log.error_with({ hub_logs = true }, string.format("Eventsource error: disconnected, dni= %s, time = %s, host=%s", device.device_network_id, now, eventsource.url.host)) + device:set_field(fields.LAST_DISCONNECTED_TIME, now) + else + local time_diff = os.difftime(now, last_disconnected_time) + if time_diff > OFFLINE_TIMEOUT then + log.error_with({ hub_logs = true }, string.format("Eventsource error: Offline. disconnected for %s secs, dni= %s", time_diff, device.device_network_id)) + device:offline() + end + end + end + + eventsource.onopen = function() + log.info_with({ hub_logs = true }, string.format("Eventsource open: dni= %s, host=%s", device.device_network_id, eventsource.url.host)) + device:online() + device:set_field(fields.LAST_DISCONNECTED_TIME, nil) + local success, err = status_update(driver, device) + if not success then + log.warn(string.format("Failed to status_update during eventsource.onopen, err = %s dni= %s", err, device.device_network_id)) + success, err = status_update(driver, device) + if not success then + log.error_with({ hub_logs = true }, string.format("Failed to status_update during eventsource.onopen again, err = %s dni= %s", err, device.device_network_id)) + end + end + end + + local old_eventsource = device:get_field(fields.EVENT_SOURCE) + if old_eventsource then + old_eventsource:close() + end + device:set_field(fields.EVENT_SOURCE, eventsource) + end +end + +local function update_connection(driver, device, device_ip, device_info) + local device_dni = device.device_network_id + local conn_info = driver.discovery_helper.get_connection_info(driver, device_dni, device_ip, device_info) + local credential = device:get_field(fields.CREDENTIAL) + + conn_info:add_header(CREDENTIAL_KEY_HEADER, credential) + + if driver.device_manager.is_valid_connection(driver, device, conn_info) then + device:set_field(fields.CONN_INFO, conn_info) + + create_sse(driver, device, credential) + end +end + + +local function find_new_connection(driver, device) + local ip_table = discovery.find_ip_table(driver) + local ip = ip_table[device.device_network_id] + if ip then + device:set_field(fields.DEVICE_IPV4, ip, { persist = true }) + local device_info = device:get_field(fields.DEVICE_INFO) + update_connection(driver, device, ip, device_info) + else + log.warn("find new conneciton : ip is nil") + end +end + +local function check_and_update_connection(driver, device) + local conn_info = device:get_field(fields.CONN_INFO) + local eventsource = device:get_field(fields.EVENT_SOURCE) + if eventsource and eventsource.ready_state == eventsource.ReadyStates.OPEN then + log.info(string.format("SSE connection is being maintained well, dni = %s", device.device_network_id)) + elseif not driver.device_manager.is_valid_connection(driver, device, conn_info) then + find_new_connection(driver, device) + end +end + +local function create_monitoring_thread(driver, device, device_info) + local old_timer = device:get_field(fields.MONITORING_TIMER) + if old_timer ~= nil then + device.thread:cancel_timer(old_timer) + end + + local monitoring_interval = DEFAULT_MONITORING_INTERVAL + local new_timer = device.thread:call_on_schedule(monitoring_interval, function() + check_and_update_connection(driver, device) + end, "monitor_timer") + device:set_field(fields.MONITORING_TIMER, new_timer) +end + + + +local function do_refresh(driver, device, cmd) + local success, err = status_update(driver, device) + if not success then + log.info(string.format("Failed to status_update during do_refresh, err = %s dni= %s", err, device.device_network_id)) + check_and_update_connection(driver, device) + end + driver.device_manager.init_presence(driver, device) + driver.device_manager.init_movement(driver, device) + driver.device_manager.init_activity(driver, device) +end + +local function device_removed(driver, device) + local conn_info = device:get_field(fields.CONN_INFO) + driver.removing_devices = driver.removing_devices + 1 + log.info_with({ hub_logs = true }, string.format("Device removed: dni= %s", device.device_network_id)) + if not conn_info then + log.warn_with({ hub_logs = true }, string.format("remove : failed to find conn_info, dni = %s", device.device_network_id)) + else + local _, err, status = conn_info:get_remove() + + if err or status ~= 200 then + log.error_with({ hub_logs = true }, string.format("remove : failed to get remove, dni= %s, err= %s, status= %s", device.device_network_id, + err, + status)) + else + log.info_with({ hub_logs = true }, string.format("Device removed: token reset success. dni= %s", device.device_network_id)) + end + end + + local eventsource = device:get_field(fields.EVENT_SOURCE) + if eventsource then + eventsource:close() + end + driver.removing_devices = driver.removing_devices - 1 +end + +local function device_init(driver, device) + if device:get_field(fields._INIT) then + return + end + device:set_field(fields._INIT, true, { persist = false }) + + local device_dni = device.device_network_id + driver.controlled_devices[device_dni] = device + + if driver.datastore.discovery_cache[device_dni] then + log.warn("set unsaved device field") + discovery.set_device_field(driver, device) + end + + local device_ip = device:get_field(fields.DEVICE_IPV4) + local device_info = device:get_field(fields.DEVICE_INFO) + local credential = device:get_field(fields.CREDENTIAL) + + if not credential then + log.error_with({ hub_logs = true }, "failed to find credential.") + device:offline() + return + end + + driver.device_manager.set_zone_info_to_latest_state(driver, device) + + log.trace(string.format("Creating device monitoring for %s", device.device_network_id)) + create_monitoring_thread(driver, device, device_info) + + update_connection(driver, device, device_ip, device_info) + + local eventsource = device:get_field(fields.EVENT_SOURCE) + if not eventsource then + log.error_with({ hub_logs = true }, "failed to create EVENT_SOURCE.") + device:offline() + return + end + + do_refresh(driver, device, nil) +end + +local function device_info_changed(driver, device, event, args) + do_refresh(driver, device, nil) +end + +local function driver_lifecycle_handler(driver, event_name) + log.info(string.format("driver lifecycle event :'%s'", event_name)) + if event_name == "shutdown" then + local MAXIMUM_WAITING_TIME = 30 + local WAITING_TIME = 1 + local total_waiting_time = 0 + + while (driver.removing_devices > 0) do + log.info("waiting for all devices to be removed") + total_waiting_time = total_waiting_time + WAITING_TIME + socket.sleep(WAITING_TIME) -- wait for 1 seconds + + if total_waiting_time > MAXIMUM_WAITING_TIME then + log.error_with({ hub_logs = true }, "maximum waiting time exceeded") + end + end + log.info(string.format("forced exit")) + os.exit(0) + end +end + +local lan_driver = Driver("aqara-fp2", + { + discovery = discovery.do_network_discovery, + lifecycle_handlers = { + added = discovery.device_added, + init = device_init, + infoChanged = device_info_changed, + removed = device_removed + }, + driver_lifecycle = driver_lifecycle_handler, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + }, + [multipleZonePresence.id] = { + [multipleZonePresence.commands.updateZoneName.name] = multipleZonePresence.commands.updateZoneName.handler, + } + }, + discovery_helper = fp2_discovery_helper, + device_manager = fp2_device_manager, + controlled_devices = {}, + removing_devices = 0 + } +) + +if lan_driver.datastore.discovery_cache == nil then + lan_driver.datastore.discovery_cache = {} +end + +lan_driver:run() diff --git a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/init.lua b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/init.lua new file mode 100644 index 0000000000..4767995ec2 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/init.lua @@ -0,0 +1,4 @@ +local RestClient = require "lunchbox.rest" +local EventSource = require "lunchbox.sse.eventsource" + +return { RestClient = RestClient, EventSource = EventSource } diff --git a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/rest.lua b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/rest.lua new file mode 100644 index 0000000000..dec3a28a37 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/rest.lua @@ -0,0 +1,398 @@ +---@class ChunkedResponse : Response +---@field package _received_body boolean +---@field package _parsed_headers boolean +---@field public new fun(status_code: number, socket: table?): ChunkedResponse +---@field public fill_body fun(self: ChunkedResponse): string? +---@field public append_body fun(self: ChunkedResponse, next_chunk_body: string): ChunkedResponse +local socket = require "cosock.socket" +local utils = require "utils" + +local lb_utils = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" --[[@as ChunkedResponse]] +local api_version = require("version").api + +local RestCallStates = { + SEND = "Send", + RECEIVE = "Receive", + RETRY = "Retry", + RECONNECT = "Reconnect", + COMPLETE = "Complete", +} + +local function connect(client) + local port = 80 + local use_ssl = false + + if client.base_url.scheme == "https" then + port = 443 + use_ssl = true + end + + if client.base_url.port ~= port then port = client.base_url.port end + + local sock, err = client.socket_builder(client.base_url.host, port, use_ssl) + + if sock == nil then + client.socket = nil + return false, err + end + + client.socket = sock + return true +end + +local function reconnect(client) + if client.socket ~= nil then + client.socket:close() + client.socket = nil + end + return connect(client) +end + +---comment +---@param client RestClient +---@param request Request +---@return integer? bytes_sent +---@return string? err_msg +---@return integer idx +local function send_request(client, request) + if client.socket == nil then + return nil, "no socket available", 0 + end + local payload = request:serialize() + + local bytes, err, idx = nil, nil, 0 + + repeat + bytes, err, idx = client.socket:send(payload, idx + 1, #payload) + until (bytes == #payload) + or (err ~= nil) + + return bytes, err, idx +end + +local function parse_chunked_response(original_response, sock) + local ChunkedTransferStates = { + EXPECTING_CHUNK_LENGTH = "ExpectingChunkLength", + EXPECTING_BODY_CHUNK = "ExpectingBodyChunk", + } + + local full_response = Response.new(original_response.status, nil) --[[@as ChunkedResponse]] + + for header in original_response.headers:iter() do full_response.headers:append_chunk(header) end + + local original_body, err = original_response:get_body() + if type(original_body) ~= "string" or err ~= nil then + return original_body, (err or "unexpected nil in error position") + end + local next_chunk_bytes = tonumber(original_body, 16) + local next_chunk_body = "" + local bytes_read = 0; + + local state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + + repeat + local pat = nil + local next_recv, next_err, partial = nil, nil, nil + + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + pat = next_chunk_bytes + else + pat = "*l" + end + + next_recv, next_err, partial = sock:receive(pat) + + if next_err ~= nil then + if string.lower(next_err) == "closed" then + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + next_chunk_bytes = 0 + else + return nil, next_err + end + else + return nil, ("unexpected error reading chunked transfer: " .. next_err) + end + end + + if next_recv ~= nil and #next_recv >= 1 then + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + bytes_read = bytes_read + #next_recv + next_chunk_body = next_chunk_body .. next_recv + + if bytes_read >= next_chunk_bytes then + full_response = full_response:append_body(next_chunk_body) + next_chunk_body = "" + bytes_read = 0 + + state = ChunkedTransferStates.EXPECTING_CHUNK_LENGTH + end + elseif state == ChunkedTransferStates.EXPECTING_CHUNK_LENGTH then + next_chunk_bytes = tonumber(next_recv, 16) + + state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + end + end + until next_chunk_bytes == 0 + + local _ = sock:receive("*l") -- clear the trailing CRLF + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function recv_additional_response(original_response, sock) + local full_response = Response.new(original_response.status, nil) + local headers = original_response:get_headers() + local content_length_str = headers:get_one("Content-Length") + local content_length = nil + local bytes_read = 0 + + if content_length_str then + content_length = math.tointeger(content_length_str) + end + + local next_recv, next_err, partial + + repeat + next_recv, next_err, partial = sock:receive(content_length - bytes_read) + + if next_recv ~= nil and #next_recv >= 1 then + full_response:append_body(next_recv) + bytes_read = bytes_read + #next_recv + end + + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + bytes_read = bytes_read + #partial + end + until next_err == "closed" or bytes_read >= content_length + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function handle_response(sock) + if api_version >= 9 then + local response, err = Response.tcp_source(sock) + if err or (not response) then return response, (err or "unknown error") end + if response.status == 403 then return response, "403 Forbidden" end + return response, response:fill_body() + end + -- called select right before passing in so we receive immediately + local initial_recv, initial_err, partial = Response.source(function() return sock:receive('*l') end) + + local full_response = nil + + if initial_recv ~= nil then + local headers = initial_recv:get_headers() + + if headers:get_one("Content-Length") then + full_response = recv_additional_response(initial_recv, sock) + elseif headers and headers:get_one("Transfer-Encoding") == "chunked" then + local response, err = parse_chunked_response(initial_recv, sock) + if err ~= nil then + return nil, err + end + full_response = response + else + full_response = initial_recv + end + + return full_response + else + return nil, initial_err, partial + end +end + +local function execute_request(client, request, retry_fn) + if not client._active then + return nil, "Called `execute request` on a terminated REST Client", nil + end + + if client.socket == nil then + local success, err = connect(client) + if not success then return nil, err, nil end + end + + local should_retry = retry_fn + + if type(should_retry) ~= "function" then + should_retry = function() return false end + end + + -- send output + local _bytes_sent, send_err, _idx = nil, nil, 0 + -- recv output + local response, recv_err, partial = nil, nil, nil + -- return values + local ret, err = nil, nil + + local backoff = utils.backoff_builder(60, 1, 0.1) + local current_state = RestCallStates.SEND + + repeat + local retry = should_retry() + if current_state == RestCallStates.SEND then + backoff = utils.backoff_builder(60, 1, 0.1) + _bytes_sent, send_err, _idx = send_request(client, request) + + if not send_err then + current_state = RestCallStates.RECEIVE + elseif retry then + if string.lower(send_err) == "closed" or string.lower(send_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = send_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECEIVE then + response, recv_err, partial = handle_response(client.socket) + + if not recv_err then + ret = response + err = nil + current_state = RestCallStates.COMPLETE + elseif retry then + if string.lower(recv_err) == "closed" or string.lower(recv_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = recv_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECONNECT then + local success, reconn_err = reconnect(client) + if success then + current_state = RestCallStates.RETRY + elseif not retry then + ret = nil + err = reconn_err + current_state = RestCallStates.COMPLETE + else + socket.sleep(backoff()) + end + elseif current_state == RestCallStates.RETRY then + bytes_sent, send_err, _idx = nil, nil, 0 + response, recv_err, partial = nil, nil, nil + current_state = RestCallStates.SEND + socket.sleep(backoff()) + end + until current_state == RestCallStates.COMPLETE + + return ret, err, partial +end + +---@class RestClient +--- +---@field base_url table `net.url` URL table +---@field socket table `cosock` TCP socket +local RestClient = {} +RestClient.__index = RestClient + +function RestClient.one_shot_get(full_url, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:get(url_table.path, additional_headers) + client:shutdown() + return ret, err +end + +function RestClient.one_shot_post(full_url, body, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:post(url_table.path, body, additional_headers) + client:shutdown() + return ret, err +end + +function RestClient:close_socket() + if self.socket ~= nil and self._active then + self.socket:close() + self.socket = nil + end +end + +function RestClient:shutdown() + self:close_socket() + self._active = false +end + +function RestClient:update_base_url(new_url) + if self.socket ~= nil then + self.socket:close() + self.socket = nil + end + + self.base_url = lb_utils.force_url_table(new_url) +end + +function RestClient:get(path, additional_headers, retry_fn) + local request = Request.new("GET", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + return execute_request(self, request, retry_fn) +end + +function RestClient:post(path, body_string, additional_headers, retry_fn) + local request = Request.new("POST", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient:put(path, body_string, additional_headers, retry_fn) + local request = Request.new("PUT", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient.new(base_url, sock_builder) + base_url = lb_utils.force_url_table(base_url) + + if type(sock_builder) ~= "function" then sock_builder = utils.labeled_socket_builder() end + + return + setmetatable({ base_url = base_url, socket_builder = sock_builder, socket = nil, _active = true }, RestClient) +end + +return RestClient diff --git a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua new file mode 100644 index 0000000000..9b64844821 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua @@ -0,0 +1,552 @@ +local cosock = require "cosock" +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" + +local log = require "log" +local util = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" + +local MAX_RECONNECT_TIME_SEC = 60 + +--- A pure Lua implementation of the EventSource interface. +--- The EventSource interface represents the client end of an HTTP(S) +--- connection that receives an event stream following the Server-Sent events +--- specification. +--- +--- MDN Documentation for EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource +--- HTML Spec: https://html.spec.whatwg.org/multipage/server-sent-events.html +--- +--- @class EventSource +--- @field public url table A `net.url` table representing the URL for the connection +--- @field public ready_state number Enumeration of the ready states outlined in the spec. +--- @field public onopen function in-line callback for on-open events +--- @field public onmessage function in-line callback for on-message events +--- @field public onerror function in-line callback for on-error events; error callbacks will fire +--- @field package _reconnect boolean flag that says whether or not the client should attempt to reconnect on close. +--- @field package _reconnect_time_millis number The amount of time to wait between reconnects, in millis. Can be sent by the server. +--- @field package _sock_builder function|nil optional. If this function exists, it will be called to create a new TCP socket on connection. +--- @field package _sock table? the TCP socket for the connection +--- @field package _needs_more boolean flag to track whether or not we're still expecting mroe on this source before we dispatch +--- @field package _last_field string the last field the parsing path saw, in case it needs to append more to its value +--- @field package _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. +--- @field package _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. +--- @field package _listeners table event listeners attached using the add_event_listener API instead of the inline callbacks. +local EventSource = {} +EventSource.__index = EventSource + +--- The Ready States that an EventSource can be in. We use base 0 to match the specification. +EventSource.ReadyStates = util.read_only { + CONNECTING = 0, -- The connection has not yet been established + OPEN = 1, -- The connection is open + CLOSED = 2 -- The connection has closed +} + +--- The event types supported by this source, patterned after their values in JavaScript. +EventSource.EventTypes = util.read_only { + ON_OPEN = "open", + ON_MESSAGE = "message", + ON_ERROR = "error", +} + +--- Helper function that creates the initial Request to start the stream. +--- @function create_request +--- @local +--- @param url_table table a net.url table +--- @param extra_headers table a set of key/value pairs (strings) to capture any extra HTTP headers needed. +local function create_request(url_table, extra_headers) + local request = Request.new("GET", url_table.path, nil) + :add_header("user-agent", "smartthings-lua-edge-driver") + :add_header("host", string.format("%s", url_table.host)) + :add_header("connection", "keep-alive") + :add_header("accept", "text/event-stream") + + if type(extra_headers) == "table" then + for k, v in pairs(extra_headers) do + request = request:add_header(k, v) + end + end + + return request +end + +--- Helper function to send the request and kick off the stream. +--- @function send_stream_start_request +--- @local +--- @param payload string the entire string buffer to send +--- @param sock table the TCP socket to send it over +local function send_stream_start_request(payload, sock) + local bytes, err, idx = nil, nil, 0 + + repeat + bytes, err, idx = sock:send(payload, idx + 1, #payload) + until (bytes == #payload) or (err ~= nil) + + if err then + log.error_with({ hub_logs = true }, "send error: " .. err) + end + + return bytes, err, idx +end + +--- Helper function to create an table representing an event from the source's parse buffers. +--- @function make_event +--- @local +--- @param source EventSource +local function make_event(source) + local event_type = nil + + if #source._parse_buffers["event"] > 0 then + event_type = source._parse_buffers["event"] + end + + return { + type = event_type or "message", + data = source._parse_buffers["data"], + origin = source.url.scheme .. "://" .. source.url.host, + lastEventId = source._parse_buffers["id"] + } +end + +--- SSE spec for dispatching an event: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage +--- @function dispatch_event +--- @local +--- @param source EventSource +local function dispatch_event(source) + local data_buffer = source._parse_buffers["data"] + local is_blank_line = data_buffer ~= nil and + (#data_buffer == 0) or + data_buffer == "\n" or + data_buffer == "\r" or + data_buffer == "\r\n" + if data_buffer ~= nil and not is_blank_line then + local event = util.read_only(make_event(source)) + + if type(source.onmessage) == "function" then + source.onmessage(event) + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_MESSAGE]) do + if type(listener) == "function" then + listener(event) + end + end + end + + source._parse_buffers["event"] = "" + source._parse_buffers["data"] = "" +end + +local valid_fields = util.read_only { + ["event"] = true, + ["data"] = true, + ["id"] = true, + ["retry"] = true +} + +-- An event stream "line" can end in more than one way; from the spec: +-- Lines must be separated by either +-- a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, +-- a single U+000A LINE FEED (LF) character, +-- or a single U+000D CARRIAGE RETURN (CR) character. +-- +-- util.iter_string_lines won't suffice here because: +-- a.) it assumes \n, and +-- b.) it doesn't differentiate between a "line" that ends without a newline and one that does. +-- +-- h/t to github.com/FreeMasen for the suggestions on the efficient implementation of this +local function find_line_endings(chunk) + local r_idx, n_idx = string.find(chunk, "[\r\n]+") + if r_idx == nil or r_idx == n_idx then + -- 1 character or no match + return r_idx, n_idx + end + local slice = string.sub(chunk, r_idx, n_idx) + if slice == "\r\n" then + return r_idx, n_idx + end + -- invalid multi character match, return first character only + return r_idx, r_idx +end + +local function event_lines(chunk) + local remaining = chunk + local line_end, rn_end + local remainder_sent = false + return function() + line_end, rn_end = find_line_endings(remaining) + if not line_end then + if remainder_sent or (not remaining) or #remaining == 0 then + return nil + else + remainder_sent = true + return remaining, false + end + end + local next_line = string.sub(remaining, 1, line_end - 1) + remaining = string.sub(remaining, rn_end + 1) + return next_line, true + end +end +--- SSE spec for interpreting an event stream: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface +--- @function parse +--- @local +--- @param source EventSource +--- @param recv string the received payload from the last socket receive +local function sse_parse_chunk(source, recv) + for line, complete in event_lines(recv) do + if not source._needs_more and (#line == 0 or (not line:match("([%w%p]+)"))) then -- empty/blank lines indicate dispatch + dispatch_event(source) + elseif source._needs_more then + local append = line + if source._last_field == "data" and complete then append = append .. "\n" end + if complete then source._needs_more = false end + source._parse_buffers[source._last_field] = source._parse_buffers[source._last_field] .. append + else + if line:sub(1, 1) ~= ":" then -- ignore any complete lines that start w/ a colon + local matches = line:gmatch("(%w*)(:*)(.*)") -- colon after field is optional, in that case it's a field w/ no value + + for field, _colon, value in matches do + value = value:gsub("^[^%g]", "", 1) -- trim a single leading space character + + if valid_fields[field] then + source._last_field = field + if field == "retry" then + local new_time = tonumber(value, 10) + if type(new_time) == "number" then + source._reconnect_time_millis = new_time + end + elseif field == "data" then + local append = (value or "") + if complete then append = append .. "\n" end + source._parse_buffers[field] = source._parse_buffers[field] .. append + elseif field == "id" then + -- skip ID's if they contain the NULL character + if not string.find(value, '\0') then + source._parse_buffers[field] = value + end + else + source._parse_buffers[field] = value + end + end + source._needs_more = source._needs_more or (not complete) + end + end + end + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CONNECTING state. +--- @function connecting_action +--- @local +--- @param source EventSource +local function connecting_action(source) + if not source._sock then + if type(source._sock_builder) == "function" then + -- source._sock = source._sock_builder() + local use_ssl = false + if source.url.scheme == "https" then + use_ssl = true + end + + source._sock = source._sock_builder(source.url.host, source.url.port, use_ssl) + else + source._sock, err = socket.tcp() + if err ~= nil then return nil, err end + + _, err = source._sock:settimeout(60) + if err ~= nil then return nil, err end + + _, err = source._sock:connect(source.url.host, source.url.port) + if err ~= nil then return nil, err end + + _, err = source._sock:setoption("keepalive", true) + if err ~= nil then return nil, err end + + if source.url.scheme == "https" then + source._sock, err = ssl.wrap(source._sock, { + mode = "client", + protocol = "any", + verify = "none", + options = "all" + }) + if err ~= nil then return nil, err end + _, err = source._sock:dohandshake() + if err ~= nil then return nil, err end + end + end + end + + local request = create_request(source.url, source._extra_headers) + + local last_event_id = source._parse_buffers["id"] + + if last_event_id ~= nil and #last_event_id > 0 then + request = request:add_header("Last-Event-ID", last_event_id) + end + + if not source._sock then + return nil, "source._sock is nil" + end + + local _, err, _ = send_stream_start_request(request:serialize(), source._sock) + + if err ~= nil then + return nil, err + end + + local response + response, err = Response.tcp_source(source._sock) + + if not response or err ~= nil then + return nil, err or "nil response from Response.tcp_source" + end + + if response.status == 401 then + source._reconnect = false + return nil, "Server response is 401 Unauthorized, stop reconnect", { response.status, response.status_msg } + end + + if response.status ~= 200 then + return nil, "Server responded with status other than 200 OK", { response.status, response.status_msg } + end + + local headers, err = response:get_headers() + if err ~= nil then + return nil, err + end + local content_type = string.lower((headers and headers:get_one('content-type') or "none")) + if not content_type:find("text/event-stream", 1, true) then + local err_msg = "Expected content type of text/event-stream in response headers, received: " .. content_type + return nil, err_msg + end + + source.ready_state = EventSource.ReadyStates.OPEN + source._reconnect = true + source._reconnect_time_millis = 1000 + + if type(source.onopen) == "function" then + source.onopen() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_OPEN]) do + if type(listener) == "function" then + listener() + end + end +end +--- Helper function that captures the cyclic logic of the EventSource while in the OPEN state. +--- @function open_action +--- @local +--- @param source EventSource +local function open_action(source) + local recv, err, partial = source._sock:receive('*l') + + if err then + --- connection is fine but there was nothing + --- to be read from the other end so we just + --- early return. + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + + -- the number of bytes to read per the chunked encoding spec + local recv_as_num = tonumber(recv, 16) + + if recv_as_num ~= nil then + -- A chunk size of 0 signals the end of the chunked transfer. Some + -- versions of the underlying socket library will raise a hard error + -- (rather than returning a graceful nil/"closed" pair) if we ask to + -- receive 0 bytes, so avoid calling receive() in that case entirely. + if recv_as_num == 0 then + recv = "" + else + recv, err, partial = source._sock:receive(recv_as_num) + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + end + local _, err, partial = source._sock:receive('*l') -- clear the final line + + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + sse_parse_chunk(source, recv) + else + local recv_dbg = recv or "" + if #recv_dbg == 0 then recv_dbg = "" end + recv_dbg = recv_dbg:gsub("\r\n", ""):gsub("\n", ""):gsub("\r", "") + log.error_with({ hub_logs = true }, + string.format("Received %s while expecting a chunked encoding payload length (hex number)\n", recv_dbg)) + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CLOSED state. +--- @function closed_action +--- @local +--- @param source EventSource +local function closed_action(source) + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + + if source._reconnect then + if type(source.onerror) == "function" then + source.onerror() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_ERROR]) do + if type(listener) == "function" then + listener() + end + end + + local sleep_time_secs = source._reconnect_time_millis / 1000.0 + socket.sleep(sleep_time_secs) + + if source._reconnect_time_millis <= MAX_RECONNECT_TIME_SEC * 1000 then + source._reconnect_time_millis = source._reconnect_time_millis + 1000.0 + end + source.ready_state = EventSource.ReadyStates.CONNECTING + end +end + +local state_actions = { + [EventSource.ReadyStates.CONNECTING] = connecting_action, + [EventSource.ReadyStates.OPEN] = open_action, + [EventSource.ReadyStates.CLOSED] = closed_action +} + +--- Create a new EventSource. The only required parameter is the URL, which can +--- be a string or a net.url table. The string form will be converted to a net.url table. +--- +--- @param url string|table a string or a net.url table representing the complete URL (minimally a scheme/host/path, port optional) for the event stream. +--- @param extra_headers table|nil an optional table of key-value pairs (strings) to be added to the initial GET request +--- @param sock_builder function|nil an optional function to be used to create the TCP socket for the stream. If nil, a set of defaults will be used to create a new TCP socket. +--- @return EventSource a new EventSource +function EventSource.new(url, extra_headers, sock_builder) + local url_table = util.force_url_table(url) + + local use_ssl = false + if url_table.scheme == "https" then + use_ssl = true + end + + if not url_table.port then + if url_table.scheme == "http" then + url_table.port = 80 + elseif url_table.scheme == "https" then + url_table.port = 443 + end + end + + local sock = nil + + if type(sock_builder) == "function" then + -- sock = sock_builder() + sock = sock_builder(url_table.host, url_table.port, use_ssl) + end + + local source = setmetatable({ + url = url_table, + ready_state = EventSource.ReadyStates.CONNECTING, + onopen = nil, + onmessage = nil, + onerror = nil, + _needs_more = false, + _last_field = nil, + _reconnect = true, + _reconnect_time_millis = 1000, + _sock_builder = sock_builder, + _sock = sock, + _extra_headers = extra_headers, + _parse_buffers = { + ["data"] = "", + ["id"] = "", + ["event"] = "", + }, + _listeners = { + [EventSource.EventTypes.ON_OPEN] = {}, + [EventSource.EventTypes.ON_MESSAGE] = {}, + [EventSource.EventTypes.ON_ERROR] = {} + }, + }, EventSource) + + cosock.spawn(function() + local st_utils = require "st.utils" + while true do + if source.ready_state == EventSource.ReadyStates.CLOSED and + not source._reconnect + then + return + end + local _, action_err, partial = state_actions[source.ready_state](source) + if action_err ~= nil then + if action_err ~= "timeout" or action_err ~= "wantread" then + log.error_with({ hub_logs = true }, "Event Source Coroutine State Machine error(" .. (source.url.host or "") .. "):" .. action_err) + if partial ~= nil and #partial > 0 then + log.error_with({ hub_logs = true }, st_utils.stringify_table(partial, "\tReceived Partial", true)) + end + source.ready_state = EventSource.ReadyStates.CLOSED + end + end + end + end) + + return source +end + +--- Close the event source, signalling that a reconnect is not desired +function EventSource:close() + self._reconnect = false + if self._sock ~= nil then + self._sock:close() + end + self._sock = nil + self.ready_state = EventSource.ReadyStates.CLOSED +end + +--- Add a callback to the event source +---@param listener_type string One of "message", "open", or "error" +---@param listener function the callback to be called in case of an event. Open and Error events have no payload. The message event will have a single argument, a table. +function EventSource:add_event_listener(listener_type, listener) + local list = self._listeners[listener_type] + + if list then + table.insert(list, listener) + end +end + +return EventSource diff --git a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/util.lua b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/util.lua new file mode 100644 index 0000000000..3bb5231a96 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/util.lua @@ -0,0 +1,45 @@ +local net_url = require "net.url" +local util = {} + +util.force_url_table = function(url) + if type(url) ~= "table" then url = net_url.parse(url) end + + if not url.port then + if url.scheme == "http" then + url.port = 80 + elseif url.scheme == "https" then + url.port = 443 + end + end + + return url +end + +util.read_only = function(tbl) + if type(tbl) == "table" then + local proxy = {} + local mt = { -- create metatable + __index = tbl, + __newindex = function(t, k, v) error("attempt to update a read-only table", 2) end, + } + setmetatable(proxy, mt) + return proxy + else + return tbl + end +end + +util.iter_string_lines = function(str) + if str:sub(-1) ~= "\n" then str = str .. "\n" end + + return str:gmatch("(.-)\n") +end + +util.copy_data = function(tbl) + local ret = {} + for k, v in pairs(tbl) do ret[k] = v end + + return ret +end + +return util diff --git a/drivers/Aqara/aqara-presence-sensor/src/multipleZonePresence.lua b/drivers/Aqara/aqara-presence-sensor/src/multipleZonePresence.lua new file mode 100644 index 0000000000..7436ce4c59 --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/multipleZonePresence.lua @@ -0,0 +1,154 @@ +local capabilities = require "st.capabilities" +local mzp = {} + +mzp.capability = capabilities["multipleZonePresence"] +mzp.id = "multipleZonePresence" +mzp.commands = {} + +mzp.present = "present" +mzp.notPresent = "not present" + +local ZONE_INFO_KEY = "zoneInfo" + +function mzp.findZoneById(driver, device, id) + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + for index, zoneInfo in pairs(zoneInfoTable) do + if zoneInfo.id == id then + return zoneInfo, index + end + end + return nil, nil +end + +function mzp.findNewZoneId(driver, device) + local maxId = 0 + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + for _, zoneInfo in pairs(zoneInfoTable) do + local intId = tonumber(zoneInfo.id) + if intId and intId > maxId then + maxId = intId + end + end + return tostring(maxId + 1) +end + +function mzp.createZone(driver, device, name, id) + local err, createdId = nil, nil + local zoneInfo = {} + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + if id == nil then + id = mzp.findNewZoneId(driver, device) + end + if mzp.findZoneById(driver, device, id) then + err = string.format("id %s already exists", id) + return err, createdId + end + zoneInfo.id = id + zoneInfo.name = name + zoneInfo.state = mzp.notPresent + zoneInfoTable["zone"..id] = zoneInfo + createdId = id + + device:set_field(ZONE_INFO_KEY, zoneInfoTable, { persist = true }) + + return err, createdId +end + +function mzp.deleteZone(driver, device, id) + local err, deletedId = nil, nil + local _, index = mzp.findZoneById(driver, device, id) + if index then + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + zoneInfoTable[index] = nil + deletedId = id + device:set_field(ZONE_INFO_KEY, zoneInfoTable, { persist = true }) + else + err = string.format("id %s doesn't exist", id) + end + return err, deletedId +end + +function mzp.renameZone(driver, device, id, name) + local err, changedId = nil, nil + local _, index = mzp.findZoneById(driver, device, id) + if index then + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + zoneInfoTable[index].name = name + changedId = id + device:set_field(ZONE_INFO_KEY, zoneInfoTable, { persist = true }) + else + err = string.format("id %s doesn't exist", id) + end + return err, changedId +end + +function mzp.changeState(driver, device, id, state) + local err, changedId = nil, nil + local zoneInfo, index = mzp.findZoneById(driver, device, id) + if zoneInfo then + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + zoneInfoTable[index].state = state + changedId = id + device:set_field(ZONE_INFO_KEY, zoneInfoTable, { persist = true }) + else + err = string.format("id %s doesn't exist", id) + end + return err, changedId +end + +function mzp.setZoneInfo(driver, device, inputZoneInfoTable) + --prevents overwriting with a default name ("zone%d"). + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + for __, inputZoneInfo in pairs(inputZoneInfoTable) do + local zoneInfo, index = mzp.findZoneById(driver, device, inputZoneInfo.id) + if zoneInfo then + if inputZoneInfo.name ~= "zone" .. inputZoneInfo.id then + zoneInfoTable[index].name = inputZoneInfo.name + end + else + local newZoneInfo = {} + newZoneInfo.id = inputZoneInfo.id + newZoneInfo.name = inputZoneInfo.name + newZoneInfo.state = mzp.notPresent + table.insert(zoneInfoTable, newZoneInfo) + end + end + device:set_field(ZONE_INFO_KEY, zoneInfoTable, { persist = true }) +end + +mzp.commands.updateZoneName = {} +mzp.commands.updateZoneName.name = "updateZoneName" +function mzp.commands.updateZoneName.handler(driver, device, args) + local name = args.args.name + local id = args.args.id + mzp.renameZone(driver, device, id, name) + mzp.updateAttribute(driver, device) +end + +mzp.commands.deleteZone = {} +mzp.commands.deleteZone.name = "deleteZone" +function mzp.commands.deleteZone.handler(driver, device, args) + local id = args.args.id + mzp.deleteZone(driver, device, id) + mzp.updateAttribute(driver, device) +end + +mzp.commands.createZone = {} +mzp.commands.createZone.name = "createZone" +function mzp.commands.createZone.handler(driver, device, args) + local name = args.args.name + local id = args.args.id + mzp.createZone(driver, device, name, id) + mzp.updateAttribute(driver, device) +end + +function mzp.updateAttribute(driver, device) + local zoneInfoTable = device:get_field(ZONE_INFO_KEY) or {} + local zoneStatePayload = {} + for _, zoneInfo in pairs(zoneInfoTable) do + table.insert(zoneStatePayload, zoneInfo) + end + device:emit_event(mzp.capability.zoneState({ value = zoneStatePayload })) +end + +return mzp diff --git a/drivers/Aqara/aqara-presence-sensor/src/selfSignedRootByAqaraLife.crt b/drivers/Aqara/aqara-presence-sensor/src/selfSignedRootByAqaraLife.crt new file mode 100644 index 0000000000..2dd6dd333c --- /dev/null +++ b/drivers/Aqara/aqara-presence-sensor/src/selfSignedRootByAqaraLife.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDRjCCAi6gAwIBAgIBATANBgkqhkiG9w0BAQsFADA6MQswCQYDVQQGEwJLUjET +MBEGA1UECgwKQXFhcmEgTGlmZTEWMBQGA1UEAwwNQXFhcmEgTGlmZSBDQTAgFw0y +NDA1MjAwNjEyMjFaGA8yMTI0MDQyNjA2MTIyMVowOjELMAkGA1UEBhMCS1IxEzAR +BgNVBAoMCkFxYXJhIExpZmUxFjAUBgNVBAMMDUFxYXJhIExpZmUgQ0EwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6rq5QcW/3saXs+gaRaqmR87KcABnW +7gNhjAodBJzv8TNCxUyXAP+xwSnCKsHoW4I3koA0Cze0FJ/f9u7exuyLr8cF0Sjv +k5EeRD4AQ81OeacS79D3bxgUs7WrU//WJo8HIyrs0qdVNv9p2kDzFuY1Jw0+CEir +SDAA8wpyxKylQhPQalEQIn44WlB9JAIifIK+oRq389JZjWutFXL+TvR/uSOjjLqO +A0bsRV9SFjmXg3YViPI6z09qwQb+CI2qV+6B+5EdB8YSF8SxA6z5p7FTCi+IcEr1 +fGvfopOZAg9KWk+a8p5GyLCh0cl50XlsBxrJyfP5xX1yjkFHvuIL3lNDAgMBAAGj +VTBTMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFNFpFOdYz0nxQDLDbl1X +fk530i/jMAsGA1UdDwQEAwIBBjARBglghkgBhvhCAQEEBAMCAAcwDQYJKoZIhvcN +AQELBQADggEBAKn6/oIfP0EOA8aMo65OZEta8MGwsUm7dNgtG8WkHjeU/y6XZm52 +I/LuYDtXIxaIXj2jspz5GLUxE12PMIkJZVwaDb4g0kACm3X5JKO09W7G9zTn/Jwp +UxpbaHVLfoNUNdQr7Ye3hBtcIcdgH569QGuQVIkDSI3bxEBDgL4v5vZLeqPQV4Il +PnsyuLMxyARpYmj0vYqbSm8brbpTlyeQbOOtgb+sYygDIoSSjmB4Ihk1sm9l+9oP +xPc5vrFx9C/88sweX9/wUzMdTAiGYaXd/w8YDoVEAtQui7oTDKpINnU9gZ481ERr +u9qH5VTGcSDqTc9QdYCf2wqYwKmbkWpUR8g= +-----END CERTIFICATE----- diff --git a/drivers/SmartThings/philips-hue/src/utils.lua b/drivers/Aqara/aqara-presence-sensor/src/utils.lua similarity index 59% rename from drivers/SmartThings/philips-hue/src/utils.lua rename to drivers/Aqara/aqara-presence-sensor/src/utils.lua index 546d70bdbd..4e4f8c2c14 100644 --- a/drivers/SmartThings/philips-hue/src/utils.lua +++ b/drivers/Aqara/aqara-presence-sensor/src/utils.lua @@ -1,9 +1,7 @@ -local Fields = require "fields" local log = require "log" ----@class hue.utils +---@module 'utils' local utils = {} -local MAC_ADDRESS_STR_LEN = 12 function utils.str_starts_with(str, start) return str:sub(1, #start) == start @@ -28,59 +26,6 @@ function utils.is_nan(number) return tostring(number) == tostring(0 / 0) end - ---- Attempts an exhaustive check of all the ways a device ---- can indicate that it represents a Hue Bridge. ----@param driver HueDriver ----@param device HueDevice ----@return boolean is_bridge true if the device record represents a Hue Bridge -function utils.is_bridge(driver, device) - return (device:get_field(Fields.DEVICE_TYPE) == "bridge") - or (driver.datastore.bridge_netinfo[device.device_network_id] ~= nil) - or utils.is_edge_bridge(device) or utils.is_dth_light(device) - or (device.parent_assigned_child_key == nil) -end - ---- Only checked during `added` callback, or as a later ---- fallback check in the chain of booleans used in `is_bridge`. ---- ----@see hue.utils.is_bridge ----@param device HueDevice ----@return boolean -function utils.is_edge_bridge(device) - return device.device_network_id and #device.device_network_id == MAC_ADDRESS_STR_LEN and - not (device.data and device.data.username) -end - ---- Only checked during `added` callback, or as a later ---- fallback check in the chain of booleans used in `is_bridge`. ---- ----@see hue.utils.is_bridge ----@param device HueDevice ----@return boolean -function utils.is_edge_light(device) - return device.parent_assigned_child_key ~= nil and #device.parent_assigned_child_key > MAC_ADDRESS_STR_LEN and - not (device.data and device.data.username and device.data.bulbId) -end - ---- Only checked during `added` callback ----@param device HueDevice ----@return boolean -function utils.is_dth_bridge(device) - return device.data ~= nil - and not device.data.bulbId - and device.data.username ~= nil -end - ---- Only checked during `added` callback ----@param device HueDevice ----@return boolean -function utils.is_dth_light(device) - return device.data ~= nil - and device.data.bulbId ~= nil - and device.data.username ~= nil -end - -- build a exponential backoff time value generator -- -- max: the maximum wait interval (not including `rand factor`) @@ -106,7 +51,7 @@ function utils.backoff_builder(max, inc, rand) end end -function utils.labeled_socket_builder(label) +function utils.labeled_socket_builder(label, ssl_config) local log = require "log" local socket = require "cosock.socket" local ssl = require "cosock.ssl" @@ -116,10 +61,14 @@ function utils.labeled_socket_builder(label) label = label .. " " end + if not ssl_config then + ssl_config = { mode = "client", protocol = "any", verify = "none", options = "all" } + end + local function make_socket(host, port, wrap_ssl) log.info( string.format( - "%sCreating TCP socket for Hue REST Connection", label + "%sCreating TCP socket for REST Connection", label ) ) local _ = nil @@ -128,30 +77,27 @@ function utils.labeled_socket_builder(label) if err ~= nil or (not sock) then return nil, (err or "unknown error creating TCP socket") end - log.info( string.format( - "%sSetting TCP socket timeout for Hue REST Connection", label + "%sSetting TCP socket timeout for REST Connection", label ) ) _, err = sock:settimeout(60) if err ~= nil then return nil, "settimeout error: " .. err end - log.info( string.format( - "%sConnecting TCP socket for Hue REST Connection", label + "%sConnecting TCP socket for REST Connection", label ) ) _, err = sock:connect(host, port) if err ~= nil then return nil, "Connect error: " .. err end - log.info( string.format( - "%sSet Keepalive for TCP socket for Hue REST Connection", label + "%sSet Keepalive for TCP socket for REST Connection", label ) ) _, err = sock:setoption("keepalive", true) @@ -162,17 +108,17 @@ function utils.labeled_socket_builder(label) if wrap_ssl then log.info( string.format( - "%sCreating SSL wrapper for for Hue REST Connection", label + "%sCreating SSL wrapper for for REST Connection", label ) ) sock, err = - ssl.wrap(sock, { mode = "client", protocol = "any", verify = "none", options = "all" }) - if not sock or err ~= nil then - return nil, (err and "SSL wrap error: " .. err) or "Unexpected nil socket returned from ssl.wrap" + ssl.wrap(sock, ssl_config) + if err ~= nil then + return nil, "SSL wrap error: " .. err end log.info( string.format( - "%sPerforming SSL handshake for for Hue REST Connection", label + "%sPerforming SSL handshake for for REST Connection", label ) ) _, err = sock:dohandshake() @@ -180,10 +126,9 @@ function utils.labeled_socket_builder(label) return nil, "Error with SSL handshake: " .. err end end - log.info( string.format( - "%sSuccessfully created TCP connection for Hue", label + "%sSuccessfully created TCP connection", label ) ) return sock, err diff --git a/drivers/DeepSmart/deepsmart/CLAUDE.md b/drivers/DeepSmart/deepsmart/CLAUDE.md new file mode 100644 index 0000000000..67a03c4cf7 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a **SmartThings Edge Driver** written in Lua that integrates DEEPSMART KNX gateways (called "Wiser" bridges) with Samsung SmartThings. It discovers bridges via SSDP, communicates over HTTPS REST APIs, and translates KNX telegrams to SmartThings capability events. + +There is no build system, package manager, or test suite — this is a pure source project deployed directly to the SmartThings Edge platform. + +## Architecture + +``` +src/ +├── init.lua # Driver entry point — registers capabilities, lifecycle/command handlers, starts the IP-check schedule +├── config.lua # Device type enums (AC=3, HEATER=4, NEWFAN=53, SWITCH=14, SLIDER=16, HUE=25), address type constants +├── discovery.lua # SSDP-based bridge discovery. Callback spawns bridge creation and device loading +├── lifecycles.lua # init/added/removed handlers for both bridge devices and child (edge) devices +├── commands.lua # Translates SmartThings capability commands → Wisers.control() calls with appropriate addrtypes +├── ssdp.lua # Raw UDP SSDP M-SEARCH broadcast, parses responses for UUID+IP +├── utils.lua # Socket builder (TCP+SSL via cosock), backoff generator, deep table comparison +├── selfSignedRoot.crt # CA certificate for bridge HTTPS connections +├── deepsmart/ +│ ├── wisers.lua # Central orchestrator: bridge lifecycle (add/del/reload/refresh), KNX↔SmartThings translation, 2s poll loop +│ ├── api.lua # HTTPS REST client wrapping lunchbox.rest: load_config, load_dp2knx, load_dpenum, query, control +│ ├── devices.lua # Parses bridge JSON config into device/protocol/address index. Maps KNX group addresses → device DP IDs +│ ├── dp2knx.lua # Maps productId → device type + DP ID ↔ address-type associations +│ └── dpenum.lua # Enum value conversion: SmartThings values (cool/heat/auto) ↔ DEEPSMART integer values +└── lunchbox/ + ├── init.lua # Module re-export: RestClient, EventSource + ├── rest.lua # Full-featured HTTPS client with connection pooling, retry/backoff, chunked transfer support + ├── sse/eventsource.lua # Server-Sent Events client + └── util.lua # URL parsing helpers, read-only table proxy +``` + +## How It Works — Data Flow + +### Discovery & Setup +1. User scans for devices → `discovery.start()` sends SSDP M-SEARCH for `DEEPSMART-ARM` +2. Each bridge UUID+IP is passed to `wisers.add_wiser()`, which creates a bridge device and fetches all configs (devices, dp2knx, dpenum) from the bridge's HTTPS API +3. Every 2 seconds, `wiser_loop()` polls the bridge for changed devices — this keeps device state in sync + +### Command Path (SmartThings → KNX) +1. SmartThings app sends command → `commands.lua` handler +2. Handler determines `addrtypes` based on device type (e.g., AC mode = addrtype 1) +3. Calls `wisers.control(device, command, addrtypes)` which: + - Looks up the DP ID for the product + addrtype via `dp2knx` + - Finds the KNX send address(es) from `devices` + - Converts SmartThings values to DEEPSMART values via `dpenum` + - Posts `control` API call to bridge + +### Response Path (KNX → SmartThings) +1. `wiser_loop()` polls `/homecontroller/api/v1/config/changeddevs` every 2s +2. For changed devices, calls `query_deviceid()` which reads all KNX feedback addresses +3. KNX values are translated back to SmartThings values via `dpenum` and `dp2knx` +4. `driver:set_switch()`, `driver:ac_report()`, etc. emit SmartThings capability events + +### Device Addressing +- `device_network_id` format: `wiser:pid:id[_idx]` (e.g., `abc123:cekfhkz5:42_1`) +- One physical DEEPSMART device can map to multiple SmartThings child devices (`idx` suffix) +- Bridge devices have `parent_assigned_child_key == nil`; child devices always have one + +## Key Files for Common Changes + +- **Adding a new device type**: Add the enum to `config.lua`, create a profile YAML in `profiles/`, add PID mapping in `dp2knx.lua`, add command handling in `commands.lua`, add response handling in `wisers.lua:knx_response()` +- **Changing API endpoints**: Edit `src/deepsmart/api.lua` +- **Changing discovery behavior**: Edit `src/discovery.lua` or `src/ssdp.lua` +- **Changing device lifecycle behavior**: Edit `src/lifecycles.lua` + +## SmartThings Platform Constraints + +- Runs on SmartThings Edge Lua runtime — `require('st.driver')`, `require('st.capabilities')`, etc. are platform-provided +- Uses `cosock` for TCP/SSL sockets (not standard Lua socket) +- The `luncheon` library provides HTTP Request/Response objects +- Driver data is persisted via `device:set_field(key, value, {persist = true})` +- Bridge configuration (devices, dp2knx, dpenum) are stored as persisted fields on the bridge device + +## Device Types and Their Capabilities + +| Type | Enum | Profile | Capabilities | +|------|------|---------|--------------| +| AC | 3 | Ac.v1 | switch, temperatureMeasurement, thermostatHeatingSetpoint, airConditionerMode, airConditionerFanMode | +| Heater | 4 | Heater.v1 | thermostatMode, thermostatOperatingState, thermostatHeatingSetpoint, temperatureMeasurement | +| NewFan | 53 | Newfan.v1 | switch, airConditionerFanMode | +| Switch | 14 | Light.v1 | switch | +| Slider | 16 | Slider.v1 | switch, switchLevel | +| Hue | 25 | Hue.v1 | switch, switchLevel, colorTemperature | +| Bridge | — | Deepsmart-bridge.v1 | refresh | + +## Scheduling + +- **Every 600 seconds**: `check_ip()` SSDP scan to detect bridge IP changes (e.g., DHCP renewal) +- **Every 2 seconds**: `wiser_loop()` polls bridge for changed device states and keeps the poll connection alive diff --git a/drivers/DeepSmart/deepsmart/config.yml b/drivers/DeepSmart/deepsmart/config.yml new file mode 100644 index 0000000000..697703917c --- /dev/null +++ b/drivers/DeepSmart/deepsmart/config.yml @@ -0,0 +1,5 @@ +name: 'DEEPSMART KNX GATEWAY' +packageKey: 'DEEPSMART-KNX.GATEWAY' +permissions: + lan: {} + discovery: {} diff --git a/drivers/DeepSmart/deepsmart/profiles/Ac.yml b/drivers/DeepSmart/deepsmart/profiles/Ac.yml new file mode 100644 index 0000000000..8d1139908a --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Ac.yml @@ -0,0 +1,22 @@ +name: Ac.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [ 16, 30 ] + - id: airConditionerMode + version: 1 + - id: airConditionerFanMode + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner diff --git a/drivers/DeepSmart/deepsmart/profiles/Curtain.yml b/drivers/DeepSmart/deepsmart/profiles/Curtain.yml new file mode 100644 index 0000000000..8a8d90203f --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Curtain.yml @@ -0,0 +1,12 @@ +name: Curtain.v1 +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeLevel + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind diff --git a/drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml b/drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml new file mode 100644 index 0000000000..428b1b048b --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml @@ -0,0 +1,8 @@ +name: Deepsmart.bridge +components: +- id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: Bridges diff --git a/drivers/DeepSmart/deepsmart/profiles/Heater.yml b/drivers/DeepSmart/deepsmart/profiles/Heater.yml new file mode 100644 index 0000000000..74bb369dde --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Heater.yml @@ -0,0 +1,20 @@ +name: Heater.v1 +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatOperatingState + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [ 5, 35 ] + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/DeepSmart/deepsmart/profiles/Hue.yml b/drivers/DeepSmart/deepsmart/profiles/Hue.yml new file mode 100644 index 0000000000..c9f72b69a7 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Hue.yml @@ -0,0 +1,18 @@ +name: Hue.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: colorTemperature + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +metadata: + deviceType: Light + ocfDeviceType: oic.d.light + deviceTypeId: Light diff --git a/drivers/DeepSmart/deepsmart/profiles/Light.yml b/drivers/DeepSmart/deepsmart/profiles/Light.yml new file mode 100644 index 0000000000..7cea315f7a --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Light.yml @@ -0,0 +1,14 @@ +name: Light.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +metadata: + deviceType: Light + ocfDeviceType: oic.d.light + deviceTypeId: Light diff --git a/drivers/DeepSmart/deepsmart/profiles/Newfan.yml b/drivers/DeepSmart/deepsmart/profiles/Newfan.yml new file mode 100644 index 0000000000..7baa525ede --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Newfan.yml @@ -0,0 +1,12 @@ +name: Newfan.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: airConditionerFanMode + version: 1 + - id: refresh + version: 1 + categories: + - name: Vent diff --git a/drivers/DeepSmart/deepsmart/profiles/Slider.yml b/drivers/DeepSmart/deepsmart/profiles/Slider.yml new file mode 100644 index 0000000000..0c66d62986 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Slider.yml @@ -0,0 +1,16 @@ +name: Slider.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +metadata: + deviceType: Light + ocfDeviceType: oic.d.light + deviceTypeId: Light diff --git a/drivers/DeepSmart/deepsmart/search-parameters.yml b/drivers/DeepSmart/deepsmart/search-parameters.yml new file mode 100644 index 0000000000..06f9f57e77 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/search-parameters.yml @@ -0,0 +1,2 @@ +ssdp: + - searchTerm: "DEEPSMART-ARM" diff --git a/drivers/DeepSmart/deepsmart/src/commands.lua b/drivers/DeepSmart/deepsmart/src/commands.lua new file mode 100644 index 0000000000..dab3acee21 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/commands.lua @@ -0,0 +1,227 @@ +local caps = require('st.capabilities') +local log = require('log') + +local config = require('config') +local wisers = require('deepsmart.wisers') + +local command_handler = {} + +------------------ +-- Refresh command +function command_handler.refresh(_, device) + local bridge_id = device.device_network_id + local parent_assigned_child_key = device.parent_assigned_child_key + local success = false + local is_bridge = wisers.is_device_bridge(device) + log.info('refresh bridge '..bridge_id..' device') + if (not is_bridge) then + success = wisers.refresh(device) + log.info('hub refresh device '..parent_assigned_child_key) + else + -- reload wiser devices + wisers.refresh_wiser(bridge_id) + log.info('hub refresh device '..bridge_id) + end + -- Check success + if success then + -- Define online status + device:online() + else + log.error('failed to poll device state') + -- Set device as offline + device:offline() + end +end + +---------------- +-- Switch command +---------------- +function command_handler.set_switch(_, device, command) + local on_off = command.command + log.info('hub control device '..device.parent_assigned_child_key..' onoff '..on_off) + -- gt devtype + local devtype = wisers.get_dev_type(device) + local addrtypes = {} + -- get devtype onoff addrtype + if (devtype == config.ENUM.AC or devtype == config.ENUM.HEATER or devtype == config.ENUM.NEWFAN or devtype == config.ENUM.SWITCH or devtype == config.ENUM.SLIDER or devtype == config.ENUM.HUE) then + addrtypes[1] = config.DEVICE.ONOFF + else + addrtypes[1] = 0 + end + -- send command + local success = wisers.control(device, command, addrtypes) + -- Check if success + if success then + if on_off == 'off' then + return device:emit_event(caps.switch.switch.off()) + end + return device:emit_event(caps.switch.switch.on()) + end + log.error('no response from device') + return 0 +end + +---------------- +-- Switch level command +---------------- +function command_handler.set_level(_, device, command) + local lvl = command.args.level + local success = wisers.control(device, command, {config.SLIDER.BRIGHT}) + -- Check if success + if success then + if lvl == 0 then + device:emit_event(caps.switch.switch.off()) + else + device:emit_event(caps.switch.switch.on()) + end + device:emit_event(caps.switchLevel.level(lvl)) + return + end + log.error('no response from device') +end + +---------------- +-- Color control command +---------------- +function command_handler.set_color(_, device, command) + local success = wisers.control(device, command, {config.HUE.HUE}) + + -- Check if success + if success then + device:emit_event(caps.switch.switch.on()) + device:emit_event(caps.colorTemperature.colorTemperature(command.args.temperature)) + return + end + log.error('no response from device') +end + +---------------- +-- fan mode command +---------------- +function command_handler.set_thermostat_fan_mode(driver, device, command) + local devtype = wisers.get_dev_type(device) + local addrtypes = {} + if (devtype == config.ENUM.AC) then + addrtypes[1] = config.AC.FAN + elseif devtype == config.ENUM.NEWFAN then + addrtypes[1] = config.NEWFAN.FAN + end + local success = wisers.control(device, command, addrtypes) + -- Check if success + if success then + return device:emit_event(caps.airConditionerFanMode.fanMode(command.args.fanMode)) + end + log.error('no response from device') + return 0 +end + +---------------- +-- mode command +---------------- +function command_handler.set_airconditioner_mode(driver, device, command) + local success = wisers.control(device, command, {config.AC.MODE}) + -- Check if success + if success then + return device:emit_event(caps.airConditionerMode.airConditionerMode(command.args.mode)) + end + log.error('no response from device') +end + +---------------- +-- heater mode command +---------------- +function command_handler.set_thermostat_mode(driver, device, command) + -- send command + local success = wisers.control(device, command, {config.HEATER.ONOFF}) + -- Check if success + if success then + if command.args.mode == 'off' then + device:emit_event(caps.thermostatMode.thermostatMode.off()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.idle()) + else + device:emit_event(caps.thermostatMode.thermostatMode.heat()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.heating()) + end + return + end + log.error('no response from device') + return 0 +end + +---------------- +-- set heating point command +---------------- +function command_handler.set_setheatingpoint(driver, device, command) + local devtype = wisers.get_dev_type(device) + local addrtypes = {} + if (devtype == config.ENUM.AC) then + addrtypes[1] = config.AC.SETTEMP + elseif devtype == config.ENUM.HEATER then + addrtypes[1] = config.HEATER.SETTEMP + end + local success = wisers.control(device, command, addrtypes) + -- Check if success + if success then + return device:emit_event(caps.thermostatHeatingSetpoint.heatingSetpoint({value=command.args.setpoint,unit='C'})) + end + log.error('no response from device') +end + +---------------- +-- Window Shade command (open/close/pause) +---------------- +function command_handler.set_shade(_, device, command) + local cmd = command.command + log.info('hub control device '..device.parent_assigned_child_key..' shade '..cmd) + -- determine addrtypes based on command + local addrtypes = {} + if (cmd == 'open') then + addrtypes[1] = config.CURTAIN.OPEN + addrtypes[2] = config.CURTAIN.CLOSE + elseif (cmd == 'close') then + addrtypes[1] = config.CURTAIN.OPEN + addrtypes[2] = config.CURTAIN.CLOSE + elseif (cmd == 'pause') then + addrtypes[1] = config.CURTAIN.PAUSE + else + addrtypes[1] = config.CURTAIN.PAUSE + end + local success = wisers.control(device, command, addrtypes) + if success then + device:online() + if cmd == 'close' then + return device:emit_event(caps.windowShade.windowShade.closed()) + elseif cmd == 'open' then + return device:emit_event(caps.windowShade.windowShade.open()) + elseif cmd == 'pause' then + return device:emit_event(caps.windowShade.windowShade.paused()) + end + -- pause: no state change, just acknowledge + return + end + log.error('no response from device') + return 0 +end + +---------------- +-- Window Shade Level command +---------------- +function command_handler.set_shade_level(_, device, command) + local level = command.args.shadeLevel + log.info('hub control device '..device.parent_assigned_child_key..' shade level '..level) + local success = wisers.control(device, command, {config.CURTAIN.LEVEL}) + if success then + device:online() + device:emit_event(caps.windowShadeLevel.shadeLevel(level)) + if level == 0 then + device:emit_event(caps.windowShade.windowShade.closed()) + elseif level == 100 then + device:emit_event(caps.windowShade.windowShade.open()) + end + return + end + log.error('no response from device') +end + + +return command_handler diff --git a/drivers/DeepSmart/deepsmart/src/config.lua b/drivers/DeepSmart/deepsmart/src/config.lua new file mode 100644 index 0000000000..c49c037a93 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/config.lua @@ -0,0 +1,78 @@ +local config = {} +-- device info +-- NOTE: In the future this information +-- may be submitted through the Developer +-- Workspace to avoid hardcoded values. +config.DEVICE_PROFILE={} +config.DEVICE_PROFILE[3]='Ac.v1' +config.DEVICE_PROFILE[4]='Heater.v1' +config.DEVICE_PROFILE[14]='Light.v1' +config.DEVICE_PROFILE[16]='Slider.v1' +config.DEVICE_PROFILE[39]='Slider.v1' +config.DEVICE_PROFILE[25]='Hue.v1' +config.DEVICE_PROFILE[38]='Hue.v1' +config.DEVICE_PROFILE[53]='Newfan.v1' +config.DEVICE_PROFILE[15]='Curtain.v1' +config.DEVICE_TYPE='LAN' + +-- SSDP Config +config.MC_ADDRESS='239.255.255.250' +config.MC_PORT=1900 +config.MC_TIMEOUT=6 + +config.ENUM = {} +config.ENUM.AC = 3 +config.ENUM.HEATER = 4 +config.ENUM.NEWFAN = 53 +config.ENUM.SWITCH = 14 +config.ENUM.SLIDER = 39 +config.ENUM.HUE = 38 +config.ENUM.CURTAIN = 15 + +--device addrtype +config.AC = {} +config.AC.ONOFF = 0 +config.AC.MODE = 1 +config.AC.FAN = 2 +config.AC.SETTEMP = 3 +config.AC.TEMP = 4 + +config.HEATER = {} +config.HEATER.ONOFF = 0 +config.HEATER.SETTEMP = 2 +config.HEATER.TEMP = 3 + +config.NEWFAN = {} +config.NEWFAN.ONOFF = 0 +config.NEWFAN.FAN = 1 + +config.SWITCH = {} +config.SWITCH.ONOFF = 0 + +config.SLIDER = {} +config.SLIDER.ONOFF = 0 +config.SLIDER.BRIGHT = 1 + +config.HUE = {} +config.HUE.ONOFF = 0 +config.HUE.BRIGHT = 1 +config.HUE.HUE = 2 + +config.CURTAIN = {} +config.CURTAIN.PAUSE= 0 +config.CURTAIN.OPEN = 1 +config.CURTAIN.CLOSE = 2 +config.CURTAIN.LEVEL = 3 + +config.DEVICE = {} +config.DEVICE.ONOFF = 0 + +config.FIELD = {} +config.FIELD.DP2KNX = "dp2knx" +config.FIELD.DPENUM = "dpenum" +config.FIELD.DEVICES = "devices" +config.FIELD.IP = "ip" +config.FIELD.INVALID = "invalid" + + +return config diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/api.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/api.lua new file mode 100644 index 0000000000..e5a72fa74c --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/api.lua @@ -0,0 +1,227 @@ +local log = require('log') +local RestClient = require "lunchbox.rest" +local utils = require "utils" +local json = require('st.json') + +local Api = {} +Api.__index = Api +--------------- +-------------------- +local SSL_CONFIG = { + mode = "client", + protocol = "any", + verify = "peer", + options = "all", + cafile = "./selfSignedRoot.crt" +} + +local ADDITIONAL_HEADERS = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", +} + + +function Api.client(wiser_index_code, ip) + local ret = setmetatable({ + wiser_index_code = wiser_index_code, + ip = ip, + client = nil + }, Api) + + return ret +end + +local function process_rest_response(response, err, partial) + if err ~= nil then + return response, err, nil + elseif response ~= nil then + return response:get_body(), nil, response.status + else + return nil, "no response or error received", nil + end +end + +local function retry_fn(retry_attempts) + local count = 0 + return function() + count = count + 1 + return count < retry_attempts + end +end + +function Api:do_get(url) + -- get url + local client = RestClient.new("https://"..self.ip, utils.labeled_socket_builder(self.wiser_index_code, SSL_CONFIG)) + if (client == nil) then + log.warn('do_get url '..url..' client is nil') + return nil,'client nil',404 + end + log.debug('do_get '..url) + local response,err,partial = client:get(url, ADDITIONAL_HEADERS, retry_fn(3)) + client:shutdown() + client = nil + return process_rest_response(response,err,partial) +end + +function Api:do_post(url, content) + -- get url + local client = RestClient.new("https://"..self.ip, utils.labeled_socket_builder(self.wiser_index_code, SSL_CONFIG)) + if (client == nil) then + log.warn('do_post url '..url..' client is nil') + return nil,'client nil',404 + end + log.debug('do_post '..url..' content '..content) + local response,err,partial = client:post(url, content, ADDITIONAL_HEADERS, retry_fn(3)) + if (err ~= nil) then + log.warn('post url '..url..' content '..content..' error '..err) + else + if (response == nil or response:get_body() == nil) then + log.warn('post url '..url..' content '..content..' res nil') + else + log.trace('post url '..url..' content '..content..' res '..response:get_body()) + end + end + client:shutdown() + client = nil + return process_rest_response(response,err,partial) +end + +------------ +-- load config from wiser +------------ +function Api:load_config() + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/devices' + local retry = 2 + while (retry > 0) do + log.trace('begin to load devices '..dest_url..' retry '..retry) + local res_body,_,code = self:do_get(dest_url) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load config {}') + return true,'{}' + end + return true,res_body + end + retry = retry - 1 + if (code ~= nil) then + log.warn('load config error code '..code) + else + log.warn('load config code nil') + end + end + return false,nil +end +------------ +-- load dp2knx config from wiser +------------ +function Api:load_dp2knx() + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/dp2knx' + local retry = 2 + while (retry > 0) do + local res_body,_,code = self:do_get(dest_url) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load config {}') + return true,'{}' + end + return true,res_body + end + retry = retry - 1 + if (code ~= nil) then + log.warn('load dp2knx code '..code) + else + log.warn('load dp2knx code nil') + end + end + return false,nil +end +------------ +-- load dpenum config from wiser +------------ +function Api:load_dpenum(wiser) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/dpsamenum' + local retry = 2 + while (retry > 0) do + local res_body,_,code = self:do_get(dest_url) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load config {}') + return true,'{}' + end + return true,res_body + end + retry = retry - 1 + end + return false,nil +end +------------ +-- load changed devs +------------ +function Api:load_changeddevs(last_time) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/changeddevs' + local t = last_time + if (t == nil) then + t = "0" + end + local content = '{\"command\":\"getchangeddevs\","params":{"time":"'..t..'"}}' + local res_body,_,code = self:do_post(dest_url, content) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load changeddevs {}') + return true,'{}' + end + return true,res_body + end + return false,nil +end +------------ +-- query devs +------------ +function Api:query(addrs) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/query' + local req = {command='query',addrs=addrs} + local content = json.encode(req) + local retry = 2 + while (retry > 0) do + local res_body,_,code = self:do_post(dest_url, content) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load changeddevs {}') + return '{}' + end + return res_body + end + retry = retry - 1 + end + return nil +end +------------ +-- control devs +------------ +function Api:control(addr, type, val) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/control' + local cmdlist = {} + cmdlist[1] = {addr=addr,type=type,val=tonumber(val),delay=0} + local req = {command='control',cmdlist=cmdlist} + local content = json.encode(req) + local retry = 2 + while (retry > 0) do + local _,_,code = self:do_post(dest_url, content) + -- Handle response + if code == 200 then + return true + end + retry = retry - 1 + end + return false +end + + + +return Api + diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua new file mode 100644 index 0000000000..28ae098725 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua @@ -0,0 +1,270 @@ +local log = require('log') +local json = require('st.json') + +local Devices = {} +Devices.__index = Devices + + +--device +----wiser_index_code +----id +----uuid +----name +----type +----roomid +----location +----productId +----productType +----roomName +----dps +------dpId +------dpName +------name +------dataType +------value +------protocolObjId + +--protocols +----protocolObjId +----feedbackList +------addr +------dataType +------value +------min +------max +----sendList +------addr +------dataType +------value +------min +------max + +----------- +-- convert knx addr from 'a/b/c' to integer +-- bits 5 3 8 +-- knx a b c +-- a is high bits c is low bits +----------- +function Devices.parse_addr(addr) + local tmp = addr + local b,e = string.find(tmp, '/') + local fir = tonumber(string.sub(tmp, 1, b-1)) + tmp = string.sub(tmp, e+1) + b,e = string.find(tmp, '/') + local sec = tonumber(string.sub(tmp, 1, b-1)) + local thr = tonumber(string.sub(tmp, e+1)) + if (fir > 15) then + return ((fir << 12)&0xF000) | (0x800|(sec << 8)) | thr; + else + return (fir << 12) | (sec << 8) | thr; + end +end + +--------------- +-- load config from wiser +-- old_config is the prev config, config will be compared with old_config to find add/dev devs +--------------- +function Devices.load_config(wiser_index_code, config, old_config, add_devs, del_devs) + local device = setmetatable({ + wiser_index_code = wiser_index_code, + devices = {}, + scenes = {}, + protocols = {}, + protocol2dps = {}, + addrs = {} + }, Devices) + if (config == nil) then + return device + end + -- if config has no devices then just return + local _,js = pcall(json.decode, config) + if (js == nil or js.devices == nil) then + log.trace('parse config nil') + return device + end + -- make protocols to protocol map + for i,v in pairs(js.protocolObjs) do + log.trace('parse protocolObj '..v.id) + device.protocols[v.id] = v + end + -- parse all devs + for i,v in pairs(js.devices) do + local id = v.id + local pid = v.productId + if (pid == nil) then + goto continue + end + -- use wiser:pid:id as the unique dev id + local dev_id = wiser_index_code..':'..pid..':'..id + v.dev_id = dev_id + v.wiser_index_code = wiser_index_code + v.dpids = {} + device.devices[dev_id] = v + log.trace('parse dev id '..id..' pid '..pid..' devId '..dev_id) + if (v.dps == nil) then + goto continue + end + -- parse dev all dps + for _,dp in pairs(v.dps) do + log.trace('parse dev id '..dev_id..' dpid '..dp.dpId) + local dpid = tonumber(dp.dpId) + v.dpids[dpid] = dp + -- if dp has no protocol then we just ignore + -- protocol is the read/write knx config for cur dp + if (dp.protocolObjId ~= nil) then + local dpmap = device.protocol2dps[dp.protocolObjId] + if (dpmap == nil) then + device.protocol2dps[dp.protocolObjId] = {} + dpmap = device.protocol2dps[dp.protocolObjId] + end + local dp_info = {} + dp_info.dev_id = dev_id + dp_info.dpid = dpid + dpmap[#dpmap+1] = dp_info + -- addr + -- find protocol by protocolObjId + local protocol = device.protocols[dp.protocolObjId] + if (protocol ~= nil) then + if (protocol.sendList ~= nil) then + for i1,v1 in pairs(protocol.sendList) do + local addr = Devices.parse_addr(v1.addr) + v1.addr_int = addr + log.trace('protocol '..dp.protocolObjId..' sendList addr '..v1.addr..' val '..addr) + local addr_map = device.addrs[addr] + if (addr_map == nil) then + device.addrs[addr] = {} + addr_map = device.addrs[addr] + end + addr_map[#addr_map+1] = dp_info + end + end -- sendList + if (protocol.feedbackList ~= nil) then + for i1,v1 in pairs(protocol.feedbackList) do + local addr = Devices.parse_addr(v1.addr) + v1.addr_int = addr + log.trace('protocol '..dp.protocolObjId..' feedbackkList addr '..v1.addr..' val '..addr) + local addr_map = device.addrs[addr] + if (addr_map == nil) then + device.addrs[addr] = {} + addr_map = device.addrs[addr] + end + local exist = false + for _,v in pairs(addr_map) do + if (v.dev_id == dp_info.dev_id and v.dpid == dp_info.dpid) then + exist = true + break; + end + end + if (not exist) then + addr_map[#addr_map+1] = dp_info + end + end + end -- feedbackList + end -- if protocol exists + end -- if dp has protocol + end -- end dev dps terator + ::continue:: + end -- end devs iterator + -- make all load devices as new devices + -- add same device(same device_neywork_id) will do nothing for samsung hub + -- if hub delete some device from app. Reload config will make the deleted device to normal device + if (true and add_devs ~= nil) then + for _,dev in pairs(device.devices) do + add_devs[dev.dev_id] = dev + log.trace('dev '..dev.dev_id..' add') + end + end + -- compare with old_config to find the del_devs + -- ie. old config has device A,B,C,D + -- new config has device A,B,E, then C&&D should delete from hub + if (del_devs ~= nil and add_devs ~= nil and old_config ~= nil and old_config.devices ~= nil) then + for _,dev in pairs(old_config.devices) do + if (add_devs[dev.dev_id] == nil) then + log.info('dev '..dev.dev_id..' is deleted') + del_devs[dev.dev_id] = dev + end + end + end + return device +end +------------------ +function Devices:get_device(id) + return self.devices[id] +end + + +function Devices:get_addr_dev_dpid(addr) + return self.addrs[addr] +end + +function Devices:get_dev_dpid_addr(dev_id, dpid) + local device = self:get_device(dev_id) + if (device == nil) then + log.warn('dev '..dev_id..' is not exist') + return nil,nil + end + for i,dp in pairs(device.dps) do + log.trace('dev '..dev_id..' dpId '..dp.dpId..' params dpid '..dpid) + if (dp.dpId == tostring(dpid)) then + local protocolObjId = dp.protocolObjId + if (protocolObjId == nil) then + log.trace('dev '..dev_id..' dp '..dpid..' protocol is nil') + return nil,nil + end + log.trace('dpid '..dpid..' protocol '..protocolObjId) + -- get protocols + local protocol = self.protocols[protocolObjId] + if (protocol == nil) then + log.warn('protocol '..protocolObjId..' is not exist') + return nil,nil + end + -- find sendList && feedbackList + local send_list = {} + local feedback_list = {} + if (protocol.sendList ~= nil) then + for _,v in pairs(protocol.sendList) do + log.trace('add send addr '..v.addr) + send_list[v.addr] = v + end + end + if (protocol.feedbackList ~= nil) then + for _,v in pairs(protocol.feedbackList) do + log.trace('add recv addr '..v.addr) + feedback_list[v.addr] = v + end + end + log.trace('sendlist count '..#send_list..' feedback_list count '..#feedback_list) + return send_list,feedback_list + end + end + return nil,nil +end + + +function Devices:get_dev_addrs(dev_id) + local device = self:get_device(dev_id) + if (device == nil) then + log.warn('device '..dev_id..' is not exist') + return nil,nil + end + local send_list = {} + local feedback_list = {} + for i,dp in pairs(device.dpids) do + local send,recv = self:get_dev_dpid_addr(dev_id, dp.dpId) + if (send ~= nil) then + for addr,v in pairs(send) do + send_list[dp.dpId] = v + log.trace('get dev '..dev_id..' dpid '..dp.dpId..' send addr '..addr) + end + end + if (recv ~= nil) then + for addr,v in pairs(recv) do + feedback_list[dp.dpId] = v + log.trace('get dev '..dev_id..' dpid '..dp.dpId..' recv addr '..addr) + end + end + end + return send_list,feedback_list +end + +return Devices diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua new file mode 100644 index 0000000000..172f0d661b --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua @@ -0,0 +1,164 @@ +local log = require('log') +local json = require('st.json') +local config = require('config') + +local Dp2Knx = {} +Dp2Knx.__index = Dp2Knx +Dp2Knx.pids = { + ['cekfhkz5'] = {pid='cekfhkz5',type=config.ENUM.AC, devs={[1]={[1]=0,[4]=1,[5]=2,[2]=3,[3]=4,[101]=5}}}, + ['drrearrq'] = {pid='drrearrq',type=config.ENUM.HEATER, devs={[1]={[1]=0,[24]=2,[16]=3}}}, + ['w35ineur'] = {pid='w35ineur',type=config.ENUM.NEWFAN, devs={[1]={[1]=0,[12]=1}}}, + ['fzjliiuu'] = {pid='fzjliiuu',type=config.ENUM.SWITCH, devs={[1]={[1]=0}}}, + ['a2u6yabn'] = {pid='a2u6yabn',type=config.ENUM.SLIDER, devs={[1]={[101]=0,[102]=1}}}, + ['uyyvbtjs'] = {pid='uyyvbtjs',type=config.ENUM.HUE, devs={[1]={[101]=0,[102]=1,[24]=2}}}, + ['oruqmmxg'] = {pid='oruqmmxg',type=config.ENUM.HUE, devs={[1]={[101]=0,[102]=1,[24]=2}}}, + ['5bempufw'] = {pid='5bempufw',type=config.ENUM.CURTAIN, devs={[1]={[4]=0,[2]=3,[1]=1}}} +} +------------------ +-- load wiser dp2knx config +-- for same pid, dp2knx loaded from wiser will cover the default config +------------------ +function Dp2Knx.load_config(wiser_index_code, data) + local ret = setmetatable({ + wiser_index_code = wiser_index_code, + pids = Dp2Knx.pids + }, Dp2Knx) + if (data ~= nil) then + -- parse data + local _,js = pcall(json.decode, data) + if (js ~= nil and js.pids ~= nil) then + for k,v in pairs(js.pids) do + local pid = v.pid + log.info('load dp2knx pid '..pid) + ret.pids[pid] = v + end + end + end + return ret +end +------------------ +-- get pid type +------------------ +function Dp2Knx:get_pid_type(pid) + log.trace('get pid '..pid..' devtype') + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + log.warn('pid '..pid..' not configed') + return nil + end + return dp2knx.type +end +------------------- +-- pid+dpid->addrtype +-- get dev addrtype by pid&&dpid +-- returns: +-- devtype,addrtype,idx(dev index) +------------------- +function Dp2Knx:get_addrtype_by_pid_dpid(pid, dpid) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + log.warn('pid '..pid..' not exist in dp2knx') + return nil,nil,nil + end + for i,v in pairs(dp2knx.devs) do + -- i1->dpid v1->addrtype + for i1,v1 in pairs(v) do + if (tostring(i1) == tostring(dpid)) then + return dp2knx.type,v1,i + end + end + end + log.trace('pid '..pid..' dpid '..dpid..' is not configed') + return dp2knx.type,nil,nil +end +-- get pid addrtype dpid +function Dp2Knx:get_dpid_by_pid_addrtype(pid, idx, addrtype) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + for i,v in pairs(dev) do + if (v == addrtype) then + if (type(i) == "string") then + return tonumber(i) + else + return i + end + end + end + return nil +end + +function Dp2Knx:get_dev_addr_types(pid, idx) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + local addr_types = {} + local idx = 1 + for i,v in pairs(dev) do + addr_types[idx] = v + idx = idx + 1 + end + return addr_types +end + + +function Dp2Knx:get_dev_count(pid) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return 0 + end + if (dp2knx.devs == nil) then + return 0 + end + return #dp2knx.devs +end + + +function Dp2Knx:get_dev_addr_dpids(pid, idx) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + return dev +end + +function Dp2Knx:get_dev_dpids(pid, idx) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + local dpids = {} + local idx = 1 + for i,v in pairs(dev) do + if (type(i) == "string") then + dpids[idx] = tonumber(i) + else + dpids[idx] = i + end + idx = idx + 1 + end + return dpids +end + + + + +return Dp2Knx diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua new file mode 100644 index 0000000000..f1333ce255 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua @@ -0,0 +1,99 @@ +local json = require('st.json') + +local DpEnum = {} +DpEnum.__index = DpEnum + +---------------- +-- dpenum default config +-- ac&&newfan are default configed +-- for ac: +-- mode +-- samsung cool heat fan dry auto +-- deepsmart 3 1 9 0 2 +-- fanspeed +-- samsung low medium high auto +-- deepsmart 1 3 5 0 +-- for newfan +-- fanspeed +-- samsung low medium high auto +-- deepsmart 1 3 5 0 +---------------- +DpEnum.pids = { + ['cekfhkz5'] = {pid='cekfhkz5',dpids={{dpid=4,devDp='mode',enums={['3']='cool',['1']='heat',['9']='fan',['0']='dry',['2']='auto'}},{dpid=5,devDp='fanMode',enums={['1']='low',['3']='medium',['5']='high',['0']='auto'}}}}, + ['w35ineur'] = {pid='w35ineur',dpids={{dpid=12,devDp='fanMode',enums={['1']='low',['3']='medium',['5']='high',['0']='auto'}}}} +} +--dpenum +----dpid:cmd +----dpid_enum +------[dpEnum:devEnum] +--------------- +-- load dpenum from wiser +-- for same pid config, wiser dpenum will cover the hub initial config +--------------- +function DpEnum.load_config(wiser_index_code, data) + local ret = setmetatable({ + wiser_index_code = wiser_index_code, + pids = DpEnum.pids + }, DpEnum) + if (data ~= nil) then + -- parse data + local _,js = pcall(json.decode, data) + if (js ~= nil and js.pids ~= nil) then + -- cover pid config to pids + for k,v in pairs(js.pids) do + local pid = v.pid + ret.pids[pid] = v + end + end + end + return ret +end + +------------------ +-- get pid dpenum +------------------ +function DpEnum:get_pid(pid) + local pidenum = self.pids[pid] + return pidenum +end + +------------------ +-- dpid+dpid+dev_val->pid_val +-- get deepsmart enum value by samsung enum val +------------------ +function DpEnum:get_pid_val_by_dev_val(pid, dpid, dev_val) + local pidenum = self.pids[pid] + if (pidenum == nil) then + return nil + end + for i,v in pairs(pidenum.dpids) do + if (tostring(v.dpid) == tostring(dpid)) then + for i1,v1 in pairs(v.enums) do + if (tostring(v1) == tostring(dev_val)) then + return i1 + end + end + return nil + end + end + return nil +end +-- get pid addrtype dpid +------------------ +-- dpid+dpid+pid_val->dev_val +-- get samsung enum value by deepsmart enum val +------------------ +function DpEnum:get_dev_val_by_pid_val(pid, dpid, pid_val) + local pidenum = self.pids[pid] + if (pidenum == nil) then + return nil + end + for i,v in pairs(pidenum.dpids) do + if (tostring(v.dpid) == tostring(dpid)) then + return v.enums[tostring(pid_val)] + end + end + return nil +end + +return DpEnum diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua new file mode 100644 index 0000000000..b4ee39dc52 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua @@ -0,0 +1,942 @@ +local log = require('log') +local json = require('st.json') +local caps = require('st.capabilities') +local api = require('deepsmart.api') +local Devices = require('deepsmart.devices') +local dp2knx = require('deepsmart.dp2knx') +local dpenum = require('deepsmart.dpenum') +local config = require('config') +local ssdp = require('ssdp') +local Wisers = {} + + +Wisers.wisers = {} +Wisers.idmap = {} +Wisers.ips = {} +---------- +-- parse device_network_id to dev_id,idx +-- deepsmart device's device_network_id is defined as wiser:pid:id[_idx], _idx is optional +-- if one deepsmart device convert to one hub device then idx is ignored +-- else one deepsmart device convert to several hub devices then idx is needed, +-- as wiser:pid:id_1 -> first device +-- as wiser:pid:id_2 -> second device +-- as wiser:pid:id_n -> nth device +---------- +function Wisers.parse_device_network_id(device_network_id) + local dev_id = device_network_id + local idx = 1 + -- if _ exists then idx exists + local beginpos,endpos = string.find(dev_id, '_') + if (beginpos ~= nil) then + dev_id = string.sub(device_network_id, 1, beginpos-1) + local str = string.sub(device_network_id, endpos+1) + idx = tonumber(str) + end + -- find pid + local pid = nil + beginpos,endpos = string.find(dev_id, ':') + if (beginpos ~= nil) then + local str = string.sub(device_network_id, endpos+1) + local pos,_ = string.find(str, ':') + if (pos ~= nil) then + pid = string.sub(device_network_id, 1, pos-1) + end + end + return dev_id,pid,idx +end +--------------- +-- Parse protocol +function Wisers.get_wiser(wiser_index_code) + return Wisers.wisers[wiser_index_code] +end + + +-- check if device is bridge +function Wisers.is_device_bridge(device) + if (device.parent_assigned_child_key == nil) then + return true + end + return false +end +------------- +-- it runs every 30s +-- it will find new wisers around and refresh the binded wiser info when wiser is online +------------- +function Wisers.check_ip(discovery) + -- find all wisers + ssdp.search(discovery.ssdp_checkip_callback) +end + +-- try update wiser(if added) ip +function Wisers.update_wiser_ip(uuid, ip) + local wiser = Wisers.get_wiser(uuid) + if (wiser == nil) then + log.warn('wiser '..uuid..' not exist') + return + end + if (wiser.ip ~= ip) then + log.info('wiser '..uuid..' change ip to '..(ip or '')) + wiser.bridge:set_field(config.FIELD.IP, ip, { persist = true}) + wiser["ip"] = ip + wiser["api"] = api.client(uuid, ip) + wiser["loopapi"] = api.client(uuid, ip) + end +end + +function Wisers.wiser_loop(wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn('wiser '..wiser_index_code..' not exist') + return + end + if (wiser.config == nil) then + log.trace('wiser has no devices') + return + end + if (wiser.loopapi == nil) then + log.trace('wiser api is not inited') + return + end + -- get wiser + -- query changed devs + local ret,res = wiser.loopapi:load_changeddevs(wiser.last_active_time) + if (ret) then + log.trace('load changed devs '..res..' old state '..wiser.state) + -- parse changed devs + local _,js = pcall(json.decode, res) + if (js ~= nil and js.data ~= nil and js.data.last ~= nil and js.data.devs ~= nil) then + wiser.last_active_time = js.data.last + log.trace('change wiser '..wiser_index_code..' last time '..wiser.last_active_time) + for _,v in pairs(js.data.devs) do + -- dev empty is wiser + if (v == '') then + -- refresh subdevice status + Wisers.driver:inject_capability_command(wiser.bridge, { + capability = caps.refresh.ID, + command = caps.refresh.commands.refresh.NAME, + args = {} + }) + else + log.trace('query changed dev '..v) + Wisers.query_deviceid(v, true) + end + end + end + -- has response means bridge is online + wiser.bridge:online() + if (wiser.state == 0) then + log.trace('wiser '..wiser_index_code..' online') + wiser.state = 1 + Wisers.refresh_wiser_devices(wiser_index_code) + end + else + log.trace('load changed devs nil old state '..wiser.state) + -- no response means bridge is offline + if (wiser.state == 1) then + wiser.state = 0 + log.trace('wiser '..wiser_index_code..' offline') + wiser.bridge:offline() + end + end +end + + +function Wisers.create_device(device) + log.info('===== CREATING DEVICE...') + local wiser = Wisers.get_wiser(device.wiser_index_code) + if (wiser == nil) then + log.warn('device '..device.dev_id..' wiser '..device.wiser_index_code..' not exist') + return 0 + end + if (wiser.bridge == nil) then + log.warn('device '..device.dev_id..' wiser '..device.wiser_index_code..' bridge not inited') + return 0 + end + if (device.productId == nil) then + log.trace('ignore device '..device.dev_id..' with null pid') + return 0 + end + -- parse type + local dev_type = wiser.dp2knx:get_pid_type(device.productId) + if (dev_type == nil) then + log.warn('device '..device.dev_id..' productId '..device.productId..' is not configed') + return 0 + end + log.trace('dev '..device.dev_id..' productId '..device.productId..' dev_type '..dev_type) + if (config.DEVICE_PROFILE[dev_type] == nil) then + log.warn('device type '..dev_type..' is not supported now') + return 0 + end + -- find device count + local dev_count = wiser.dp2knx:get_dev_count(device.productId) + if (dev_count == nil or dev_count == 0) then + log.warn('pid '..device.productId..' has no devs configed') + return 0 + end + log.info('pid '..device.productId..' has dev count '..dev_count) + for i = 1,dev_count do + local dev_name = device.name + local dev_id = device.dev_id + if (dev_count > 1) then + dev_name = device.name..'_'..i + dev_id = device.dev_id..'_'..i + end + log.info('create device '..dev_id..' i '..i..' name '..dev_name) + -- device metadata table + local metadata = { + type = 'EDGE_CHILD', + label = dev_name, + profile = config.DEVICE_PROFILE[dev_type], + manufacturer = 'deepsmart', + model = 'deepsmart', + vendor_provided_label = device.name, + parent_device_id = wiser.bridge.id, + parent_assigned_child_key = dev_id + } + Wisers.driver:try_create_device(metadata) + end + return 0 +end + +------------ +-- refresh single wiser +-- load wiser all configs and create new device +------------ +function Wisers.refresh_wiser(wiser_index_code) + log.info('refresh wiser '..wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn(' wiser '..wiser_index_code..' not exist') + return 0 + end + local add_devs = {} + local del_devs = {} + Wisers.reload(wiser_index_code, add_devs, del_devs) + log.trace('refresh_wiser '..wiser_index_code..' add '..#add_devs..' del '..#del_devs) + -- add devices + for _,v in pairs(add_devs) do + Wisers.create_device(v) + end + log.trace('refresh_wiser '..wiser_index_code..' after create device') + -- del devices + for dev_id,_ in pairs(del_devs) do + local id = Wisers.idmap[dev_id] + if (id == nil) then + log.trace('dev '..dev_id..' has no hub device') + goto delretry + end + log.trace('get dev '..dev_id..' hub devid '..id) + local dev = Wisers.driver:get_device_info(id) + if (dev == nil) then + log.warn('dev '..id..' is not in hub') + goto delretry + else + log.info('del dev_id '..dev_id..' hub id '..id..' device') + dev:set_field(config.FIELD.INVALID, 1, { persist = true}) + end + ::delretry:: + end + -- save device_network_id->id + local devices = Wisers.driver:get_devices() + for i,v in pairs(devices) do + if (v.parent_assigned_child_key ~= nil) then + log.debug('save dev '..i..' id '..v.id..' networkid '..v.parent_assigned_child_key) + Wisers.idmap[v.parent_assigned_child_key] = v.id + end + end + -- refresh wiser devices online + Wisers.refresh_wiser_devices(wiser_index_code) + log.info('refresh wiser success') + return true,nil +end + +function Wisers.refresh_wiser_devices(wiser_index_code) + log.info('refresh wiser '..wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn(' wiser '..wiser_index_code..' not exist') + return false,'wiser not exist' + end + -- refresh wiser devices online + if (wiser.config ~= nil and wiser.config.devices ~= nil) then + for dev_id,_ in pairs(wiser.config.devices) do + -- find hub dev + local id = Wisers.idmap[dev_id] + if (id == nil) then + log.warn('dev '..dev_id..' is not in hub idmap') + goto devretry + end + log.trace('get dev '..dev_id..' hub devid '..id) + local dev = Wisers.driver:get_device_info(id) + if (dev == nil) then + log.warn('dev '..id..' is not in hub') + goto devretry + end + local flag = dev:get_field(config.FIELD.INVALID) + if (flag == nil or flag == 0) then + log.trace('dev '..dev_id..' online') + dev:online() + else + log.trace('dev '..dev_id..' offline') + dev:offline() + end + ::devretry:: + end + end + log.info('refresh wiser devices success') + return true,nil +end + +------------------ +-- add wiser +-- create wiser client(https) +-- api: load device configs, dp2knx config, dpenum config +------------------ +function Wisers.add_wiser(uuid, ip, bridge) + -- init all wisers + log.info('add wiser '..uuid..' ip('..(ip or 'nil')..')') + local wiser = Wisers.get_wiser(uuid) + -- first check wiser ip, if ip is same then do nothing + -- in case the wiser's ip changed by dhcp or router restart or router changed or mannal static ip change + if (wiser ~= nil) then + if (wiser.ip == ip) then + log.trace('wiser '..uuid..' is same') + else + Wisers.update_wiser_ip(uuid, ip) + end + return + end + -- if wiser is new then create new wiser + if (wiser == nil) then + -- create bridge device + -- if bridge is nil then new bridge is added + -- after try_create_device just return(in lifecycle added func will add new wiser) + if (bridge == nil) then + local wiser_device_msg = { + type = "LAN", + device_network_id = uuid, + label = "DEEPSMART KNX bridge-"..uuid, + profile = "Deepsmart.bridge", + manufacturer = "DEEPSMART", + model = "KNX gateway", + vendor_provided_label = "DEEPSMART KNX bridge" + } + -- save wiser ip + Wisers.ips[uuid] = ip + log.trace('create bridge device') + Wisers.driver:try_create_device(wiser_device_msg) + return + end + log.debug('create wiser '..uuid) + Wisers.wisers[uuid] = {} + wiser = Wisers.wisers[uuid] + wiser.bridge = bridge + wiser.state = 1 + wiser.ctrlmap = {} + wiser["wiser_index_code"] = uuid + wiser["last_active_time"] = "0" + wiser["ip"] = ip + -- wiser online + bridge:online() + if (ip ~= nil) then + wiser["api"] = api.client(uuid, ip) + wiser["loopapi"] = api.client(uuid, ip) + end + local config_str = bridge:get_field(config.FIELD.DEVICES) + local dp2knx_str = bridge:get_field(config.FIELD.DP2KNX) + local dpenum_str = bridge:get_field(config.FIELD.DPENUM) + wiser["config"] = Devices.load_config(uuid, config_str, nil, nil, nil) + wiser["dp2knx"] = dp2knx.load_config(uuid, dp2knx_str) + wiser["dpenum"] = dpenum.load_config(uuid, dpenum_str) + end + -- wiser loop for get updated devices from wiser + -- every 2second use https api to get updated devices from given time + -- after get results save the last time for next request + Wisers.driver:call_on_schedule( + 2, + function () + return Wisers.wiser_loop(uuid) + end, + 'wiser loop') + log.info('add wisers over') +end +------------ +-- del device +------------ +function Wisers.del_device_by_id(devid) + local id = Wisers.idmap[devid] + if (id == nil) then + log.warn('dev '..devid..' is not in hub') + return false + end + log.info('dev '..devid..' hub id '..id..' delete from hub') + Wisers.driver:try_delete_device(id) + return true +end +function Wisers.del_device(device) + local parent_assigned_child_key = device.parent_assigned_child_key + log.info('del device device_network_id '..device.device_network_id) + if (parent_assigned_child_key == nil) then + log.info('del bridge '..device.device_network_id) + Wisers.del_wiser(device.device_network_id) + Wisers.driver:try_delete_device(device.id) + else + -- del device from hub + log.info('del device '..device.id) + Wisers.driver:try_delete_device(device.id) + local dev_id,_,idx = Wisers.parse_device_network_id(parent_assigned_child_key) + log.trace('del device '..parent_assigned_child_key..' -> dev_id '..dev_id..' idx '..idx) + -- get device wiser + local wiser,_ = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..dev_id..' is not exist for del device') + return false,'wiser is nil' + end + -- del config dev + if (wiser.config ~= nil and wiser.config.devices ~= nil) then + wiser.config.devices[dev_id] = nil + end + end +end +------------ +-- unbind the wiser +-- it's not used now(no scene to use) +------------ +function Wisers.del_wiser(wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + return false + end + wiser.state = 0 + Wisers.wisers[wiser_index_code] = nil + log.info('wiser '..wiser_index_code..' del over') + return true +end +--------------- +-- get device wiser +-- returns +-- wiser,device +--------------- +function Wisers.get_device_wiser(dev_id) + -- check all wisers to find the device + for i,wiser in pairs(Wisers.wisers) do + if (wiser.config ~= nil and wiser.config.devices ~= nil) then + local device = wiser.config.devices[dev_id] + if (device ~= nil) then + return wiser,device + end + end + end + return nil,nil +end + + +-- reload devices&&dps +function Wisers.reload(uuid, add_devs, del_devs) + local wiser = Wisers.get_wiser(uuid) + if (wiser == nil or wiser.bridge == nil) then + log.warn('wiser '..uuid..' is not exist for reload') + return true + end + log.info('reload wiser '..wiser.wiser_index_code..' ip('..(wiser.ip or '')..')') + -- if wiser ip is not changed then do nothing + local deepsmartapi = wiser.api + if (deepsmartapi == nil) then + log.trace('wiser api is not inited') + return false + end + -- get devices + local configret,configstr = deepsmartapi:load_config() + log.trace(string.format('load config (%s) ret(%s)', configstr, tostring(configret))) + if (configret and configstr ~= wiser.bridge:get_field(config.FIELD.DEVICES)) then + local old_config = wiser.config + wiser["config"] = Devices.load_config(wiser.wiser_index_code, configstr, old_config, add_devs, del_devs) + wiser.bridge:set_field(config.FIELD.DEVICES, configstr, { persist = true}) + end + -- get dp info + local dp2knxret,dp2knx_config = deepsmartapi:load_dp2knx() + log.trace(string.format('load dp2knx (%s) ret(%s)', dp2knx_config, tostring(dp2knxret))) + if (dp2knxret and dp2knx_config ~= wiser.bridge:get_field(config.FIELD.DP2KNX)) then + wiser["dp2knx"] = dp2knx.load_config(wiser.wiser_index_code, dp2knx_config) + wiser.bridge:set_field(config.FIELD.DP2KNX, dp2knx_config, { persist = true}) + end + -- get enum info + local dpenumret,dpenum_config = deepsmartapi:load_dpenum() + log.trace(string.format('load dpenum (%s) ret(%s)', dpenum_config, tostring(dpenumret))) + if (dpenumret and dpenum_config ~= wiser.bridge:get_field(config.FIELD.DPENUM)) then + wiser["dpenum"] = dpenum.load_config(wiser.wiser_index_code, dpenum_config) + wiser.bridge:set_field(config.FIELD.DPENUM, dpenum_config, { persist = true}) + end + return true +end + +-------------- +-- refresh hub device +-- just query deepsmart device +-------------- +function Wisers.refresh(device) + return Wisers.query(device, false) +end + + +------------ +-- process deepsmart knx control command +-- same with response +------------ +function Wisers.knx_control(wiser_index_code, addr, dataType, value, delay) + local vals = {[addr] = value} + log.info('control addr '..addr..' dataType '..dataType..' value '..value) + return Wisers.knx_response(wiser_index_code, vals) +end + +------------ +-- process deepsmart knx response +-- refresh hub device status +------------ +function Wisers.knx_response(wiser_index_code, knxes) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn('wiser '..wiser_index_code..' is not exist for response addrs') + return 0 + end + -- deal all knxes one by one + for addr,value in pairs(knxes) do + log.info('wiser '..wiser_index_code..' response addr '..addr..' value '..value) + if (wiser.config == nil) then + log.warn('wiser '..wiser_index_code..' has no config') + goto knxretry + end + -- get dps by addr + local dps = wiser.config:get_addr_dev_dpid(addr) + if (dps == nil) then + log.warn('addr '..addr..' has no dps') + goto knxretry + end + log.trace('wiser '..wiser_index_code..' response addr '..addr..' get dps count '..#dps) + for i,dp in pairs(dps) do + local dpid = dp.dpid + log.trace(' addr '..addr..' -> dev_id '..dp.dev_id..' dpid '..dpid) + -- convert val to smartthings + local device = wiser.config:get_device(dp.dev_id) + if (device == nil) then + log.warn('dev '..dp.dev_id..' is not exist for response') + goto dpretry + end + local dev_id = dp.dev_id + local dev_count = wiser.dp2knx:get_dev_count(device.productId) + log.trace('pid '..device.productId..' get dev count '..dev_count) + local devtype,addrtype,idx = wiser.dp2knx:get_addrtype_by_pid_dpid(device.productId, dpid) + if (dev_count > 1) then + dev_id = dp.dev_id..'_'..idx + end + -- find hub dev + local id = Wisers.idmap[dev_id] + if (id == nil) then + log.warn('dev '..dev_id..' is not in hub idmap') + goto dpretry + end + log.trace('get dev '..dev_id..' hub devid '..id) + local dev = Wisers.driver:get_device_info(id) + if (dev == nil) then + log.warn('dev '..id..' is not in hub') + goto dpretry + end + if (devtype ~= nil and addrtype ~= nil) then + log.trace('dev '..dev_id..' pid '..device.productId..' convert to devtype '..devtype..' addrtype '..addrtype) + if (devtype == config.ENUM.SWITCH) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (devtype == config.ENUM.AC) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + local mode = wiser.dpenum:get_dev_val_by_pid_val(device.productId, dpid, value) + Wisers.driver:ac_report(dev, nil, mode, nil,nil, nil,nil) + elseif (addrtype == 2) then + local fan = wiser.dpenum:get_dev_val_by_pid_val(device.productId, dpid, value) + Wisers.driver:ac_report(dev, nil,nil,fan,nil, nil,nil) + elseif (addrtype == 3) then + Wisers.driver:ac_report(dev, nil,nil,nil,value/100, nil,nil) + elseif (addrtype == 4) then + Wisers.driver:ac_report(dev, nil,nil,nil,nil, value/100,nil) + end + elseif (devtype == config.ENUM.HEATER) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:heater_report(dev, on_off, nil,nil,nil) + elseif (addrtype == 2) then + Wisers.driver:heater_report(dev, nil,value/100,nil,nil) + elseif (addrtype == 3) then + Wisers.driver:heater_report(dev, nil,nil,value/100,nil) + end + elseif (devtype == config.ENUM.NEWFAN) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + local fan = wiser.dpenum:get_dev_val_by_pid_val(device.productId, dpid, value) + Wisers.driver:ac_report(dev, nil,nil,fan,nil, nil,nil) + end + elseif (devtype == config.ENUM.SLIDER) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + Wisers.driver:set_level(dev, value*100//255) + end + elseif (devtype == config.ENUM.HUE) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + Wisers.driver:set_level(dev, value*100//255) + elseif (addrtype == 2) then + Wisers.driver:set_hue(dev, value) + end + elseif (devtype == config.ENUM.CURTAIN) then + if (addrtype == 1 or addrtype == 2) then + local on_off = 'close' + if (value ~= 0) then + on_off = 'open' + end + Wisers.driver:curtain_report(dev, on_off, nil, nil) + elseif (addrtype == 0) then + Wisers.driver:curtain_report(dev, "pause", nil, nil) + elseif (addrtype == 3) then + Wisers.driver:curtain_report(dev, nil, (128+value*100)//255, nil) + end + end + else + log.trace('pid '..device.productId..' dpid '..dpid..' convert to devtype addrtype nil') + end + ::dpretry:: + end + ::knxretry:: + end + return 0 +end + +-------------- +-- get devtype by device_network_id +-------------- +function Wisers.get_dev_type(device) + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype') + if (device.profile.components.main.capabilities.windowShade ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype curtain') + return config.ENUM.CURTAIN + elseif (device.profile.components.main.capabilities.airConditionerMode ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype ac') + return config.ENUM.AC + elseif (device.profile.components.main.capabilities.thermostatMode ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype heater') + return config.ENUM.HEATER + elseif (device.profile.components.main.capabilities.airConditionerFanMode ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype newfan') + return config.ENUM.NEWFAN + elseif (device.profile.components.main.capabilities.colorTemperature ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype hue') + return config.ENUM.HUE + elseif (device.profile.components.main.capabilities.switchLevel~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype slider') + return config.ENUM.SLIDER + else + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype switch') + return config.ENUM.SWITCH + end +end + +--------------- +-- test code +-- report default value to control device +--------------- +function Wisers.default_report(driver, device) + local uuid = device.parent_assigned_child_key + local dev_id,_,idx = Wisers.parse_device_network_id(uuid) + log.trace('device '..uuid..' -> dev_id '..dev_id..' idx '..idx..' default report') + local wiser,dev = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..device.parent_assigned_child_key..' is not exist for read') + return false,'device not exist' + end + if (dev == nil) then + log.warn('device '..device.dev_id..' is not in wiser devs') + return false,'device is not in wisers' + end + local devtype = wiser.dp2knx:get_pid_type(dev.productId) + log.trace('dev '..dev.productId..' get type '..devtype) + if (devtype == config.ENUM.SWITCH) then + driver:set_switch(device, "off") + elseif (devtype == config.ENUM.SLIDER) then + driver:set_switch(device, "off") + driver:set_level(device, 0) + elseif (devtype == config.ENUM.HUE) then + driver:set_switch(device, "off") + driver:set_level(device, 0) + driver:set_hue(device, 0) + elseif (devtype == config.ENUM.AC) then + driver:ac_report(device, "off", "auto", "auto", 25, 25, nil) + elseif (devtype == config.ENUM.HEATER) then + driver:ac_report(device, "off", nil, nil, 25, 25, nil) + elseif (devtype == config.ENUM.NEWFAN) then + driver:ac_report(device, "off", nil, "low", nil,nil, nil) + elseif (devtype == config.ENUM.CURTAIN) then + driver:curtain_report(device, "closed", 0, nil) + end + return true,nil +end + +-------------- +-- query hub device +-- 1 find wiser +-- 2 get all feedback addrs +-- 3 query all addrs +-------------- +function Wisers.query_deviceid(uuid, use_loop) + local dev_id,_,idx = Wisers.parse_device_network_id(uuid) + log.trace('device '..uuid..' -> dev_id '..dev_id..' idx '..idx..' query') + local wiser = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..uuid..' is not exist for read') + return false,'device not exist' + end + -- find all feedback addrs + local sendlist,recv = wiser.config:get_dev_addrs(dev_id) + if (recv ~= nil) then + local addrs = {} + local recv2send = {} + local ignore_recv = {} + for dpid,addr in pairs(recv) do + addrs[#addrs+1] = addr.addr_int + log.trace('query dev '..dev_id..' recv addr '..addr.addr_int) + if (sendlist ~= nil and sendlist[dpid] ~= nil) then + local send_addr = sendlist[dpid] + if (send_addr.addr_int ~= addr.addr_int) then + if (wiser.ctrlmap[send_addr.addr_int] ~= nil) then + ignore_recv[addr.addr_int] = 1 + log.trace('ignore recv addr '..addr.addr_int..' as send addr '..send_addr.addr_int..' control is just used') + end + recv2send[addr.addr_int] = send_addr.addr_int + addrs[#addrs+1] = send_addr.addr_int + log.trace('query dev '..dev_id..' send addr '..send_addr.addr_int) + end + end + end + log.trace('query dev '..dev_id..' idx '..idx..' addrs count '..#addrs) + local deepsmartapi = wiser.api + if (use_loop) then + deepsmartapi = wiser.loopapi + end + if (deepsmartapi == nil) then + log.trace('wiser api is not inited') + return false,'api is not inited' + end + --send query + local res = deepsmartapi:query(addrs) + if (res == nil) then + log.warn('query device '..uuid..' empty') + else + log.trace('query device '..uuid..' res '..res) + -- parse res(json) + local _,js = pcall(json.decode, res) + if (js == nil or js.data == nil) then + log.trace('parse query res nil') + return false,'parse query res error' + end + -- make protocols to protocol map + local knxes = {} + local ignore_addrs = {} + for i,v in pairs(js.data) do + -- ignore recv when ctrlmap has send once + if (v ~= nil and v.regaddr ~= nil and v.value ~= nil and ignore_addrs[v.regaddr] == nil and ignore_recv[v.regaddr] == nil) then + knxes[v.regaddr] = v.value + if (recv2send[v.regaddr] ~= nil) then + ignore_addrs[recv2send[v.regaddr]] = 1 + log.trace('recv addr '..v.regaddr..' recv data ignore send addr '..recv2send[v.regaddr]) + end + log.trace('get query res addr '..v.regaddr..' value '..v.value) + end + end + wiser.ctrlmap = {} + -- parse knx response + Wisers.knx_response(wiser.wiser_index_code, knxes) + return true,nil + end + end + return false,'query error' +end + +function Wisers.query(device, use_loop) + local uuid = device.parent_assigned_child_key + return Wisers.query_deviceid(uuid, use_loop) +end +-------------- +-- convert hub command to deepsmart commands and control deepsmart devices +-- device: hub device who is controled +-- command: control params +-- addrtypes: device control type +-------------- +function Wisers.control(device, command, addrtypes) + local capability = command.capability + local cmd = command.command + local uuid = device.parent_assigned_child_key + log.trace('device '..device.parent_assigned_child_key..' capability '..command.capability..' cmd '..command.command) + local dev_id,_,idx = Wisers.parse_device_network_id(uuid) + log.trace('device '..uuid..' -> dev_id '..dev_id..' idx '..idx) + -- get device wiser + local wiser,dev = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..dev_id..' is not exist for control') + return false,'wiser is nil' + end + if (dev == nil) then + log.warn('device '..device.dev_id..' is not in wiser devs') + return false,'device is nil' + end + local ret = false + local deepsmartapi = wiser.api + if (deepsmartapi == nil) then + log.trace('wiser api is not inited') + return false,'api is not inited' + end + for _,addrtype in pairs(addrtypes) do + local dpid = wiser.dp2knx:get_dpid_by_pid_addrtype(dev.productId, idx, addrtype) + if (dpid == nil) then + log.warn('get pid '..dev.productId..' idx '..idx..' addrtype '..addrtype..' dpid nil') + goto addrtype_retry + end + log.trace('get pid '..dev.productId..' idx '..idx..' addrtype '..addrtype..' dpid '.. dpid) + -- get dpid addr + local send_list,_ = wiser.config:get_dev_dpid_addr(dev_id, dpid) + if (send_list == nil) then + log.warn('get pid '..dev.productId..' idx '..idx..' addrtype '..addrtype..' dpid '.. dpid..' has no send addrs') + goto addrtype_retry + end + if (capability == 'switch') then + local value = 0 + if (cmd == 'on') then + value = 1 + end + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + elseif (capability == 'switchLevel') then + local value = command.args.level*255//100 + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + elseif (capability == 'colorTemperature') then + -- samsung color temperature is 1-30000 + -- deepsmart color temperature is 0-255 + local value = (command.args.temperature-1)*255//29999 + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + elseif (capability == 'airConditionerMode') then + local mode = wiser.dpenum:get_pid_val_by_dev_val(dev.productId, dpid, command.args.mode) + if (mode ~= nil) then + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..mode) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = mode + deepsmartapi:control(addr.addr_int, addr.dataType, mode, 0) + end + end + ret = true + elseif (capability == 'airConditionerFanMode') then + local fan = wiser.dpenum:get_pid_val_by_dev_val(dev.productId, dpid, command.args.fanMode) + if (fan ~= nil) then + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..fan) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = fan + deepsmartapi:control(addr.addr_int, addr.dataType, fan, 0) + end + end + ret = true + elseif (capability == 'thermostatHeatingSetpoint') then + -- samsung temperature is xx + -- deepsmart temperature is xx00, temperature/100 is the real value + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..command.args.setpoint*100) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = command.args.setpoint*100 + deepsmartapi:control(addr.addr_int, addr.dataType, command.args.setpoint*100, 0) + end + ret = true + elseif (capability == 'thermostatMode') then + local value = 0 + if (command.args.mode == 'heat') then + value = 1 + end + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + elseif (capability == 'windowShade') then + if (cmd == 'pause') then + -- pause sends value 1 to stop addr + for i,addr in pairs(send_list) do + log.info('device '..uuid..' shade pause to addr '..addr.addr_int) + wiser.ctrlmap[addr.addr_int] = 1 + deepsmartapi:control(addr.addr_int, addr.dataType, 1, 0) + end + else + local value = 0 + if (cmd == 'open') then + value = 1 + end + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + end + ret = true + elseif (capability == 'windowShadeLevel') then + -- SmartThings level is 0-100, deepsmart level is 0-255 + local value = command.args.shadeLevel*255//100 + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + end + ::addrtype_retry:: + end + return ret,nil +end + +return Wisers diff --git a/drivers/DeepSmart/deepsmart/src/discovery.lua b/drivers/DeepSmart/deepsmart/src/discovery.lua new file mode 100644 index 0000000000..f824c57615 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/discovery.lua @@ -0,0 +1,45 @@ +local log = require('log') +local Wisers = require "deepsmart.wisers" +local ssdp = require('ssdp') +local net_utils = require('st.net_utils') + +local disco = {} +----------------------- +-- app discover new bridges or new devices +disco.ssdp_discovery_callback = function(uuid, ip) + if (not net_utils.validate_ipv4_string(ip) or uuid == nil) then + log.warn(string.format('ip (%s) or uuid (%s) is invalid', ip, uuid)) + return + end + -- add discovered wiser + Wisers.add_wiser(uuid, ip, nil) + Wisers.refresh_wiser(uuid) +end +-- check whether bridge ip changed +disco.ssdp_checkip_callback = function(uuid, ip) + if (not net_utils.validate_ipv4_string(ip) or uuid == nil) then + log.warn(string.format('ip (%s) or uuid (%s) is invalid', ip, uuid)) + return + end + -- check uuid(if added) + Wisers.update_wiser_ip(uuid, ip) +end +-- Discovery service which will +-- invoke the above private functions. +-- - find_device +-- - parse_ssdp +-- - fetch_device_info +-- - create_device +-- +-- This resource is linked to +-- driver.discovery and it is +-- automatically called when +-- user scan devices from the +-- SmartThings App. +function disco.start(driver, opts, cons) + log.info('in discover start...') + ssdp.search(disco.ssdp_discovery_callback) + log.info('===== DEVICE DISCOVER DEVICES OVER') +end + +return disco diff --git a/drivers/DeepSmart/deepsmart/src/init.lua b/drivers/DeepSmart/deepsmart/src/init.lua new file mode 100644 index 0000000000..da146635d0 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/init.lua @@ -0,0 +1,195 @@ +local Driver = require('st.driver') +local caps = require('st.capabilities') +local wisers = require('deepsmart.wisers') +local log = require('log') +-- local imports +local discovery = require('discovery') +local lifecycles = require('lifecycles') +local commands = require('commands') + +-------------------- +-- Driver definition +local driver = +Driver( +'DEEPSMART', +{ + discovery = discovery.start, + lifecycle_handlers = lifecycles, + supported_capabilities = { + caps.switch, + caps.switchLevel, + caps.colorTemperature, + caps.refresh, + caps.airConditionerMode, + caps.thermostatHeatingSetpoint, + caps.airConditionerFanMode, + caps.thermostatMode, + caps.thermostatOperatingState, + caps.windowShade, + caps.windowShadeLevel + }, + capability_handlers = { + -- Switch command handler + [caps.switch.ID] = { + [caps.switch.commands.on.NAME] = commands.set_switch, + [caps.switch.commands.off.NAME] = commands.set_switch + }, + -- Switch Level command handler + [caps.switchLevel.ID] = { + [caps.switchLevel.commands.setLevel.NAME] = commands.set_level + }, + -- Color Control command handler + [caps.colorTemperature.ID] = { + [caps.colorTemperature.commands.setColorTemperature.NAME] = commands.set_color + }, + -- Refresh command handler + [caps.refresh.ID] = { + [caps.refresh.commands.refresh.NAME] = commands.refresh + }, + [caps.airConditionerMode.ID] = { + [caps.airConditionerMode.commands.setAirConditionerMode.NAME] = commands.set_airconditioner_mode, + }, + [caps.airConditionerFanMode.ID] = { + [caps.airConditionerFanMode.commands.setFanMode.NAME] = commands.set_thermostat_fan_mode, + }, + [caps.thermostatHeatingSetpoint.ID] = { + [caps.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = commands.set_setheatingpoint + }, + [caps.thermostatMode.ID] = { + [caps.thermostatMode.commands.setThermostatMode.NAME] = commands.set_thermostat_mode + }, + -- Window Shade command handler + [caps.windowShade.ID] = { + [caps.windowShade.commands.open.NAME] = commands.set_shade, + [caps.windowShade.commands.close.NAME] = commands.set_shade, + [caps.windowShade.commands.pause.NAME] = commands.set_shade + }, + -- Window Shade Level command handler + [caps.windowShadeLevel.ID] = { + [caps.windowShadeLevel.commands.setShadeLevel.NAME] = commands.set_shade_level + } + } +} +) + +--------------------------------------- +-- Switch control for external commands +function driver:set_switch(device, on_off) + device:online() + if on_off == 'off' then + return device:emit_event(caps.switch.switch.off()) + end + return device:emit_event(caps.switch.switch.on()) +end + +-- Switch level control for external commands +function driver:set_level(device, lvl) + device:online() + if lvl == 0 then + device:emit_event(caps.switch.switch.off()) + else + device:emit_event(caps.switch.switch.on()) + end + return device:emit_event(caps.switchLevel.level(lvl)) +end + +function driver:set_hue(device, hue) + device:online() + return device:emit_event(caps.colorTemperature.colorTemperature(hue*29999//255+1)) +end + + +-- Switch control for external commands +function driver:ac_report(device, onoff, mode, fan, settemp, temp, err) + device:online() + if (onoff ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report onoff '..onoff) + if (onoff == "off") then + device:emit_event(caps.switch.switch.off()) + else + device:emit_event(caps.switch.switch.on()) + end + end + if (mode ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report mode '..mode) + device:emit_event(caps.airConditionerMode.airConditionerMode(mode)) + end + -- fan + if (fan ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report fan '..fan) + device:emit_event(caps.airConditionerFanMode.fanMode(fan)) + end + -- settemp + if (settemp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report settemp '..settemp) + device:emit_event(caps.thermostatHeatingSetpoint.heatingSetpoint({value=settemp,unit='C'})) + end + if (temp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report temp '..temp) + device:emit_event(caps.temperatureMeasurement.temperature({value=temp,unit='C'})) + end + return true,nil +end + +-- Heater control from bridge +function driver:heater_report(device, onoff, settemp, temp, err) + device:online() + if (onoff ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report onoff '..onoff) + if (onoff == "off") then + device:emit_event(caps.thermostatMode.thermostatMode.off()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.idle()) + else + device:emit_event(caps.thermostatMode.thermostatMode.heat()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.heating()) + end + end + -- settemp + if (settemp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report settemp '..settemp) + device:emit_event(caps.thermostatHeatingSetpoint.heatingSetpoint({value=settemp,unit='C'})) + end + if (temp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report temp '..temp) + device:emit_event(caps.temperatureMeasurement.temperature({value=temp,unit='C'})) + end + return true,nil +end + +-- Curtain control from bridge +function driver:curtain_report(device, onoff, level, err) + device:online() + if (onoff ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report onoff '..onoff) + if (onoff == "close") then + device:emit_event(caps.windowShade.windowShade.closed()) + elseif (onoff == "open") then + device:emit_event(caps.windowShade.windowShade.open()) + else + device:emit_event(caps.windowShade.windowShade.paused()) + end + end + if (level ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report level '..level) + device:emit_event(caps.windowShadeLevel.shadeLevel(level)) + if level == 0 then + device:emit_event(caps.windowShade.windowShade.closed()) + elseif level == 100 then + device:emit_event(caps.windowShade.windowShade.open()) + end + end + return true,nil +end + +-- global params init +wisers.driver = driver +-- start ssdp schedule to check bridge ip +driver:call_on_schedule( +600, +function () + return wisers.check_ip(discovery) +end, +'Check ip schedule') +-------------------- +-- Initialize Driver +driver:run() diff --git a/drivers/DeepSmart/deepsmart/src/lifecycles.lua b/drivers/DeepSmart/deepsmart/src/lifecycles.lua new file mode 100644 index 0000000000..beaffede27 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lifecycles.lua @@ -0,0 +1,91 @@ +local config = require('config') +local log = require('log') +local caps = require('st.capabilities') +local wisers = require('deepsmart.wisers') +local lifecycle_handler = {} + +function lifecycle_handler.init(driver, device) + -- report test data + --wisers.default_report(driver, device) + local is_bridge = wisers.is_device_bridge(device) + log.info('device '..device.id..' in init') + -- for bridge do not reload devices&&config + -- just use the devices&&config loaded from wiser's datastore + -- only discover or refresh bridge will reload devices&&config + if (is_bridge) then + -- just add wiser(bridge) to wisers + wisers.add_wiser(device.device_network_id, device:get_field(config.FIELD.IP), device) + -- load devices + wisers.refresh_wiser(device.device_network_id) + else + log.debug('save dev '..device.id..' networkid '..device.parent_assigned_child_key) + wisers.idmap[device.parent_assigned_child_key] = device.id + local devtype = wisers.get_dev_type(device) + if (devtype == config.ENUM.AC) then + local supported_modes = {'heat','cool','dry','fan','auto'} + device:emit_event(caps.airConditionerMode.supportedAcModes(supported_modes, { visibility = { displayed = false } })) + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.NEWFAN then + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.HEATER then + local supported_thermostat_modes = {'heat', 'off'} + device:emit_event(caps.thermostatMode.supportedThermostatModes(supported_thermostat_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.CURTAIN then + wisers.default_report(driver, device) + end + ------------------- + log.info('device '..device.parent_assigned_child_key..' in lifecycle init') + end +end + +function lifecycle_handler.added(driver, device) + log.info('device '..device.id..' added') + if (device.parent_assigned_child_key == nil) then + log.info('bridge device '..device.id..' added') + local ip = wisers.ips[device.device_network_id] + -- for bridge save some infos to bridge device + device:set_field(config.FIELD.IP, ip, { persist = true}) + wisers.add_wiser(device.device_network_id, ip, device) + log.info('bridge device '..device.id..' added over') + return 0 + end + log.debug('save dev '..device.id..' networkid '..device.parent_assigned_child_key) + local devtype = wisers.get_dev_type(device) + -- save device info + wisers.idmap[device.parent_assigned_child_key] = device.id + -- find device type + log.info('device '..device.parent_assigned_child_key..' added devtype '..devtype) + if (devtype == config.ENUM.AC) then + local supported_modes = {'heat','cool','dry','fan','auto'} + device:emit_event(caps.airConditionerMode.supportedAcModes(supported_modes, { visibility = { displayed = false } })) + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.NEWFAN then + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.HEATER then + local supported_thermostat_modes = {'heat', 'off'} + device:emit_event(caps.thermostatMode.supportedThermostatModes(supported_thermostat_modes, { visibility = { displayed = false } })) + end + log.info('edge device '..device.parent_assigned_child_key..' added over') + -- do not refresh + -- wiser loop will refresh all devices +end + +function lifecycle_handler.removed(_, device) + -- Notify device that the device + -- instance has been deleted and + -- parent node must be deleted at + -- device app. + wisers.del_device(device) + -- Remove Schedules created under + -- device.thread to avoid unnecessary + -- CPU processing. + for timer in pairs(device.thread.timers) do + device.thread:cancel_timer(timer) + end +end + +return lifecycle_handler diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/init.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/init.lua new file mode 100644 index 0000000000..2745880f50 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/init.lua @@ -0,0 +1,4 @@ +local RestClient = require "lunchbox.rest" +local EventSource = require "lunchbox.sse.eventsource" + +return {RestClient = RestClient, EventSource = EventSource} diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua new file mode 100644 index 0000000000..7f9c0917ca --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua @@ -0,0 +1,369 @@ +local socket = require "cosock.socket" +local log = require "log" +local utils = require "utils" +local lb_utils = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" + +local RestCallStates = { + SEND = "Send", + RECEIVE = "Receive", + RETRY = "Retry", + RECONNECT = "Reconnect", + COMPLETE = "Complete", +} + +local function connect(client) + local port = 80 + local use_ssl = false + + if client.base_url.scheme == "https" then + port = 443 + use_ssl = true + end + local sock, err = client.socket_builder(client.base_url.host, port, use_ssl) + + if sock == nil then + client.socket = nil + return false, err + end + + client.socket = sock + return true +end + +local function reconnect(client) + if client.socket ~= nil then + client.socket:close() + client.socket = nil + end + return connect(client) +end + +local function send_request(client, request) + if client.socket == nil then + return nil, "no socket available" + end + local payload = request:serialize() + + + local bytes, err, idx = nil, nil, 0 + + repeat bytes, err, idx = client.socket:send(payload, idx + 1, #payload) until (bytes == #payload) + or (err ~= nil) + + return bytes, err, idx +end + +local function parse_chunked_response(original_response, sock) + local ChunkedTransferStates = { + EXPECTING_CHUNK_LENGTH = "ExpectingChunkLength", + EXPECTING_BODY_CHUNK = "ExpectingBodyChunk", + } + + local full_response = Response.new(original_response.status, nil) + + for header in original_response.headers:iter() do full_response.headers:append_chunk(header) end + + local original_body, err = original_response:get_body() + if type(original_body) ~= "string" or err ~= nil then + return original_body, (err or "unexpected nil in error position") + end + local next_chunk_bytes = tonumber(original_body, 16) + local next_chunk_body = "" + local bytes_read = 0; + + local state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + + repeat + local pat = nil + local next_recv, next_err, partial = nil, nil, nil + + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + pat = next_chunk_bytes + else + pat = "*l" + end + + next_recv, next_err, partial = sock:receive(pat) + + if next_err ~= nil then + if string.lower(next_err) == "closed" then + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + next_chunk_bytes = 0 + end + else + return nil, ("unexpected error reading chunked transfer: " .. next_err) + end + end + + if next_recv ~= nil and #next_recv >= 1 then + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + bytes_read = bytes_read + #next_recv + next_chunk_body = next_chunk_body .. next_recv + + if bytes_read >= next_chunk_bytes then + full_response = full_response:append_body(next_chunk_body) + next_chunk_body = "" + bytes_read = 0 + + state = ChunkedTransferStates.EXPECTING_CHUNK_LENGTH + end + elseif state == ChunkedTransferStates.EXPECTING_CHUNK_LENGTH then + next_chunk_bytes = tonumber(next_recv, 16) + + state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + end + end + until next_chunk_bytes == 0 + + local _ = sock:receive("*l") -- clear the trailing CRLF + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function recv_additional_response(original_response, sock) + local full_response = Response.new(original_response.status, nil) + local headers = original_response:get_headers() + local content_length_str = headers:get_one("Content-Length") + local content_length = nil + if content_length_str then + content_length = math.tointeger(content_length_str) + end + + local next_recv, next_err, partial + local total = 0 + repeat + next_recv, next_err, partial = sock:receive(content_length - total) + if next_recv ~= nil and #next_recv >= 1 then + total = total + #next_recv + full_response:append_body(next_recv) + end + + if partial ~= nil and #partial >= 1 then + total = total + #next_recv + full_response:append_body(partial) + end + if (total >= content_length) then + break + end + until next_err == "closed" + + full_response._received_body = true + full_response._parsed_headers = true + return full_response +end +local function handle_response(sock) + -- called select right before passing in so we receive immediately + local initial_recv, initial_err, partial = Response.source(function() return sock:receive('*l') end) + + local full_response = nil + + if initial_recv ~= nil then + local headers = initial_recv:get_headers() + + if headers:get_one("Content-Length") then + full_response = recv_additional_response(initial_recv, sock) + elseif headers:get_one("Transfer-Encoding") == "chunked" then + full_response = parse_chunked_response(initial_recv, sock) + else + full_response = initial_recv + end + + return full_response + else + return nil, initial_err, partial + end +end + +local function execute_request(client, request, retry_fn) + if not client._active then + return nil, "Called `execute request` on a terminated REST Client" + end + + if client.socket == nil then + local success, err = connect(client) + if not success then + return nil, err + end + end + + local should_retry = retry_fn + + if type(should_retry) ~= "function" then + should_retry = function() return false end + end + + -- send output + local _bytes_sent, send_err, _idx = nil, nil, 0 + -- recv output + local response, recv_err, _partial = nil, nil, nil + -- return values + local ret, err = nil, nil + + local backoff = utils.backoff_builder(60, 1, 0.1) + local current_state = RestCallStates.SEND + repeat + local retry = should_retry() + if current_state == RestCallStates.SEND then + backoff = utils.backoff_builder(60, 1, 0.1) + _bytes_sent, send_err, _idx = send_request(client, request) + + if not send_err then + current_state = RestCallStates.RECEIVE + elseif retry then + if string.lower(send_err) == "closed" or string.lower(send_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = send_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECEIVE then + response, recv_err, _partial = handle_response(client.socket) + + if not recv_err then + ret = response + err = nil + current_state = RestCallStates.COMPLETE + elseif retry then + if string.lower(recv_err) == "closed" or string.lower(recv_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = recv_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECONNECT then + local success, reconn_err = reconnect(client) + if success then + current_state = RestCallStates.RETRY + elseif not retry then + ret = nil + err = reconn_err + current_state = RestCallStates.COMPLETE + else + socket.sleep(backoff()) + end + elseif current_state == RestCallStates.RETRY then + bytes_sent, send_err, _idx = nil, nil, 0 + response, recv_err, partial = nil, nil, nil + current_state = RestCallStates.SEND + socket.sleep(backoff()) + end + until current_state == RestCallStates.COMPLETE + + return ret, err +end + +---@class RestClient +--- +---@field base_url table `net.url` URL table +---@field socket table `cosock` TCP socket +local RestClient = {} +RestClient.__index = RestClient + +function RestClient.one_shot_get(full_url, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + local client = RestClient.new(url_table.scheme .. "://" .. url_table.host, socket_builder) + local ret, err = client:get(url_table.path, additional_headers) + client:shutdown() + client = nil + return ret, err +end + +function RestClient.one_shot_post(full_url, body, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + local client = RestClient.new(url_table.scheme .. "://" .. url_table.host, socket_builder) + local ret, err = client:post(url_table.path, body, additional_headers) + client:shutdown() + client = nil + return ret, err +end + +function RestClient:close_socket() + if self.socket ~= nil and self._active then + self.socket:close() + self.socket = nil + end +end + +function RestClient:shutdown() + self:close_socket() + self._active = false +end + +function RestClient:update_base_url(new_url) + if self.socket ~= nil then + self.socket:close() + self.socket = nil + end + + self.base_url = lb_utils.force_url_table(new_url) +end + +function RestClient:get(path, additional_headers, retry_fn) + local request = Request.new("GET", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + return execute_request(self, request, retry_fn) +end + +function RestClient:post(path, body_string, additional_headers, retry_fn) + local request = Request.new("POST", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient:put(path, body_string, additional_headers, retry_fn) + local request = Request.new("PUT", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient.new(base_url, sock_builder) + base_url = lb_utils.force_url_table(base_url) + + if type(sock_builder) ~= "function" then sock_builder = utils.labeled_socket_builder() end + + return + setmetatable({base_url = base_url, socket_builder = sock_builder, socket = nil, _active = true}, RestClient) +end + +return RestClient diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua new file mode 100644 index 0000000000..78fc95923b --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua @@ -0,0 +1,511 @@ +local cosock = require "cosock" +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" + +local log = require "log" +local util = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" + +--- A pure Lua implementation of the EventSource interface. +--- The EventSource interface represents the client end of an HTTP(S) +--- connection that receives an event stream following the Server-Sent events +--- specification. +--- +--- MDN Documentation for EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource +--- HTML Spec: https://html.spec.whatwg.org/multipage/server-sent-events.html +--- +--- @class EventSource +--- @field public url table A `net.url` table representing the URL for the connection +--- @field public ready_state number Enumeration of the ready states outlined in the spec. +--- @field public onopen function in-line callback for on-open events +--- @field public onmessage function in-line callback for on-message events +--- @field public onerror function in-line callback for on-error events; error callbacks will fire +--- @field private _reconnect boolean flag that says whether or not the client should attempt to reconnect on close. +--- @field private _reconnect_time_millis number The amount of time to wait between reconnects, in millis. Can be sent by the server. +--- @field private _sock_builder function|nil optional. If this function exists, it will be called to create a new TCP socket on connection. +--- @field private _sock table the TCP socket for the connection +--- @field private _needs_more boolean flag to track whether or not we're still expecting mroe on this source before we dispatch +--- @field private _last_field string the last field the parsing path saw, in case it needs to append more to its value +--- @field private _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. +--- @field private _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. +--- @field private _listeners table event listeners attached using the add_event_listener API instead of the inline callbacks. +local EventSource = {} +EventSource.__index = EventSource + +--- The Ready States that an EventSource can be in. We use base 0 to match the specification. +EventSource.ReadyStates = util.read_only { + CONNECTING = 0, -- The connection has not yet been established + OPEN = 1, -- The connection is open + CLOSED = 2 -- The connection has closed +} + +--- The event types supported by this source, patterned after their values in JavaScript. +EventSource.EventTypes = util.read_only { + ON_OPEN = "open", + ON_MESSAGE = "message", + ON_ERROR = "error", +} + +--- Helper function that creates the initial Request to start the stream. +--- @function create_request +--- @local +--- @param url_table table a net.url table +--- @param extra_headers table a set of key/value pairs (strings) to capture any extra HTTP headers needed. +local function create_request(url_table, extra_headers) + local request = Request.new("GET", url_table.path, nil) + :add_header("user-agent", "smartthings-lua-edge-driver") + :add_header("host", string.format("%s", url_table.host)) + :add_header("connection", "keep-alive") + :add_header("accept", "text/event-stream") + + if type(extra_headers) == "table" then + for k, v in pairs(extra_headers) do + request = request:add_header(k, v) + end + end + + return request +end + +--- Helper function to send the request and kick off the stream. +--- @function send_stream_start_request +--- @local +--- @param payload string the entire string buffer to send +--- @param sock table the TCP socket to send it over +local function send_stream_start_request(payload, sock) + local bytes, err, idx = nil, nil, 0 + + repeat + bytes, err, idx = sock:send(payload, idx + 1, #payload) + until (bytes == #payload) or (err ~= nil) + + if err then + log.error_with({ hub_logs = true }, "send error: " .. err) + end + + return bytes, err, idx +end + +--- Helper function to create an table representing an event from the source's parse buffers. +--- @function make_event +--- @local +--- @param source EventSource +local function make_event(source) + local event_type = nil + + if #source._parse_buffers["event"] > 0 then + event_type = source._parse_buffers["event"] + end + + return { + type = event_type or "message", + data = source._parse_buffers["data"], + origin = source.url.scheme .. "://" .. source.url.host, + lastEventId = source._parse_buffers["id"] + } +end + +--- SSE spec for dispatching an event: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage +--- @function dispatch_event +--- @local +--- @param source EventSource +local function dispatch_event(source) + local data_buffer = source._parse_buffers["data"] + local is_blank_line = data_buffer ~= nil and + (#data_buffer == 0) or + data_buffer == "\n" or + data_buffer == "\r" or + data_buffer == "\r\n" + if data_buffer ~= nil and not is_blank_line then + local event = util.read_only(make_event(source)) + + if type(source.onmessage) == "function" then + source.onmessage(event) + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_MESSAGE]) do + if type(listener) == "function" then + listener(event) + end + end + end + + source._parse_buffers["event"] = "" + source._parse_buffers["data"] = "" +end + +local valid_fields = util.read_only { + ["event"] = true, + ["data"] = true, + ["id"] = true, + ["retry"] = true +} + +-- An event stream "line" can end in more than one way; from the spec: +-- Lines must be separated by either +-- a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, +-- a single U+000A LINE FEED (LF) character, +-- or a single U+000D CARRIAGE RETURN (CR) character. +-- +-- util.iter_string_lines won't suffice here because: +-- a.) it assumes \n, and +-- b.) it doesn't differentiate between a "line" that ends without a newline and one that does. +-- +-- h/t to github.com/FreeMasen for the suggestions on the efficient implementation of this +local function find_line_endings(chunk) + local r_idx, n_idx = string.find(chunk, "[\r\n]+") + if r_idx == n_idx then + -- 1 character + return r_idx, n_idx + end + local slice = string.sub(chunk, r_idx, n_idx) + if slice == "\r\n" then + return r_idx, n_idx + end + -- invalid multi character match, return first character only + return r_idx, r_idx +end + +local function event_lines(chunk) + local remaining = chunk + local line_end, rn_end + local remainder_sent = false + return function() + line_end, rn_end = find_line_endings(remaining) + if not line_end then + if remainder_sent or (not remaining) or #remaining == 0 then + return nil + else + remainder_sent = true + return remaining, false + end + end + local next_line = string.sub(remaining, 1, line_end - 1) + remaining = string.sub(remaining, rn_end + 1) + return next_line, true + end +end +--- SSE spec for interpreting an event stream: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface +--- @function parse +--- @local +--- @param source EventSource +--- @param recv string the received payload from the last socket receive +local function sse_parse_chunk(source, recv) + for line, complete in event_lines(recv) do + if not source._needs_more and (#line == 0 or (not line:match("([%w%p]+)"))) then -- empty/blank lines indicate dispatch + dispatch_event(source) + elseif source._needs_more then + local append = line + if source._last_field == "data" and complete then append = append .. "\n" end + if complete then source._needs_more = false end + source._parse_buffers[source._last_field] = source._parse_buffers[source._last_field] .. append + else + if line:sub(1, 1) ~= ":" then -- ignore any complete lines that start w/ a colon + local matches = line:gmatch("(%w*)(:*)(.*)") -- colon after field is optional, in that case it's a field w/ no value + + for field, _colon, value in matches do + value = value:gsub("^[^%g]", "", 1) -- trim a single leading space character + + if valid_fields[field] then + source._last_field = field + if field == "retry" then + local new_time = tonumber(value, 10) + if type(new_time) == "number" then + source._reconnect_time_millis = new_time + end + elseif field == "data" then + local append = (value or "") + if complete then append = append .. "\n" end + source._parse_buffers[field] = source._parse_buffers[field] .. append + elseif field == "id" then + -- skip ID's if they contain the NULL character + if not string.find(value, '\0') then + source._parse_buffers[field] = value + end + else + source._parse_buffers[field] = value + end + end + source._needs_more = source._needs_more or (not complete) + end + end + end + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CONNECTING state. +--- @function connecting_action +--- @local +--- @param source EventSource +local function connecting_action(source) + if not source._sock then + if type(source._sock_builder) == "function" then + source._sock = source._sock_builder() + else + source._sock, err = socket.tcp() + if err ~= nil then return nil, err end + + _, err = source._sock:settimeout(60) + if err ~= nil then return nil, err end + + _, err = source._sock:connect(source.url.host, source.url.port) + if err ~= nil then return nil, err end + + _, err = source._sock:setoption("keepalive", true) + if err ~= nil then return nil, err end + + if source.url.scheme == "https" then + source._sock, err = ssl.wrap(source._sock, { + mode = "client", + protocol = "any", + verify = "none", + options = "all" + }) + if err ~= nil then return nil, err end + + _, err = source._sock:dohandshake() + if err ~= nil then return nil, err end + end + end + end + + local request = create_request(source.url, source._extra_headers) + + local last_event_id = source._parse_buffers["id"] + + if last_event_id ~= nil and #last_event_id > 0 then + request = request:add_header("Last-Event-ID", last_event_id) + end + + local _, err, _ = send_stream_start_request(request:serialize(), source._sock) + + if err ~= nil then + return nil, err + end + + local response + response, err = Response.tcp_source(source._sock) + + if err ~= nil then + return nil, err + end + + if response.status ~= 200 then + return nil, "Server responded with status other than 200 OK", { response.status, response.status_msg } + end + + local headers, err = response:get_headers() + if err ~= nil then + return nil, err + end + local content_type = string.lower((headers:get_one('content-type') or "none")) + if not content_type:find("text/event-stream", 1, true) then + local err_msg = "Expected content type of text/event-stream in response headers, received: " .. content_type + return nil, err_msg + end + + source.ready_state = EventSource.ReadyStates.OPEN + + if type(source.onopen) == "function" then + source.onopen() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_OPEN]) do + if type(listener) == "function" then + listener() + end + end +end +--- Helper function that captures the cyclic logic of the EventSource while in the OPEN state. +--- @function open_action +--- @local +--- @param source EventSource +local function open_action(source) + local recv, err, partial = source._sock:receive('*l') + + if err then + --- connection is fine but there was nothing + --- to be read from the other end so we just + --- early return. + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + + -- the number of bytes to read per the chunked encoding spec + local recv_as_num = tonumber(recv, 16) + + if recv_as_num ~= nil then + recv, err, partial = source._sock:receive(recv_as_num) + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + local _, err, partial = source._sock:receive('*l') -- clear the final line + + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + sse_parse_chunk(source, recv) + else + local recv_dbg = recv or "" + if #recv_dbg == 0 then recv_dbg = "" end + recv_dbg = recv_dbg:gsub("\r\n", ""):gsub("\n", ""):gsub("\r", "") + log.error_with({ hub_logs = true }, + string.format("Received %s while expecting a chunked encoding payload length (hex number)\n", recv_dbg)) + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CLOSED state. +--- @function closed_action +--- @local +--- @param source EventSource +local function closed_action(source) + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + + if source._reconnect then + if type(source.onerror) == "function" then + source.onerror() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_ERROR]) do + if type(listener) == "function" then + listener() + end + end + + local sleep_time_secs = source._reconnect_time_millis / 1000.0 + socket.sleep(sleep_time_secs) + + source.ready_state = EventSource.ReadyStates.CONNECTING + end +end + +local state_actions = { + [EventSource.ReadyStates.CONNECTING] = connecting_action, + [EventSource.ReadyStates.OPEN] = open_action, + [EventSource.ReadyStates.CLOSED] = closed_action +} + +--- Create a new EventSource. The only required parameter is the URL, which can +--- be a string or a net.url table. The string form will be converted to a net.url table. +--- +--- @param url string|table a string or a net.url table representing the complete URL (minimally a scheme/host/path, port optional) for the event stream. +--- @param extra_headers table|nil an optional table of key-value pairs (strings) to be added to the initial GET request +--- @param sock_builder function|nil an optional function to be used to create the TCP socket for the stream. If nil, a set of defaults will be used to create a new TCP socket. +--- @return EventSource a new EventSource +function EventSource.new(url, extra_headers, sock_builder) + local url_table = util.force_url_table(url) + + if not url_table.port then + if url_table.scheme == "http" then + url_table.port = 80 + elseif url_table.scheme == "https" then + url_table.port = 443 + end + end + + local sock = nil + + if type(sock_builder) == "function" then + sock = sock_builder() + end + + local source = setmetatable({ + url = url_table, + ready_state = EventSource.ReadyStates.CONNECTING, + onopen = nil, + onmessage = nil, + onerror = nil, + _needs_more = false, + _last_field = nil, + _reconnect = true, + _reconnect_time_millis = 1000, + _sock_builder = sock_builder, + _sock = sock, + _extra_headers = extra_headers, + _parse_buffers = { + ["data"] = "", + ["id"] = "", + ["event"] = "", + }, + _listeners = { + [EventSource.EventTypes.ON_OPEN] = {}, + [EventSource.EventTypes.ON_MESSAGE] = {}, + [EventSource.EventTypes.ON_ERROR] = {} + }, + }, EventSource) + + cosock.spawn(function() + local st_utils = require "st.utils" + while true do + if source.ready_state == EventSource.ReadyStates.CLOSED and + not source._reconnect + then + return + end + local _, action_err, partial = state_actions[source.ready_state](source) + if action_err ~= nil then + if action_err ~= "timeout" or action_err ~= "wantread" then + log.error_with({ hub_logs = true }, "Event Source Coroutine State Machine error: " .. action_err) + if partial ~= nil and #partial > 0 then + log.error_with({ hub_logs = true }, st_utils.stringify_table(partial, "\tReceived Partial", true)) + end + source.ready_state = EventSource.ReadyStates.CLOSED + end + end + end + end) + + return source +end + +--- Close the event source, signalling that a reconnect is not desired +function EventSource:close() + self._reconnect = false + if self._sock ~= nil then + self._sock:close() + end + self._sock = nil + self.ready_state = EventSource.ReadyStates.CLOSED +end + +--- Add a callback to the event source +---@param listener_type string One of "message", "open", or "error" +---@param listener function the callback to be called in case of an event. Open and Error events have no payload. The message event will have a single argument, a table. +function EventSource:add_event_listener(listener_type, listener) + local list = self._listeners[listener_type] + + if list then + table.insert(list, listener) + end +end + +return EventSource diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/util.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/util.lua new file mode 100644 index 0000000000..54a421047f --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/util.lua @@ -0,0 +1,46 @@ +local net_url = require "net.url" + +local util = {} + +util.force_url_table = function(url) + if type(url) ~= "table" then url = net_url.parse(url) end + + if not url.port then + if url.scheme == "http" then + url.port = 80 + elseif url.scheme == "https" then + url.port = 443 + end + end + + return url +end + +util.read_only = function(tbl) + if type(tbl) == "table" then + local proxy = {} + local mt = { -- create metatable + __index = tbl, + __newindex = function(t, k, v) error("attempt to update a read-only table", 2) end, + } + setmetatable(proxy, mt) + return proxy + else + return tbl + end +end + +util.iter_string_lines = function(str) + if str:sub(-1) ~= "\n" then str = str .. "\n" end + + return str:gmatch("(.-)\n") +end + +util.copy_data = function(tbl) + local ret = {} + for k, v in pairs(tbl) do ret[k] = v end + + return ret +end + +return util diff --git a/drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt b/drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt new file mode 100644 index 0000000000..9bcb022651 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDlTCCAn2gAwIBAgIJALBUeJtMnu2RMA0GCSqGSIb3DQEBCwUAMGExCzAJBgNV +BAYTAkNOMREwDwYDVQQIDAhaSEVKSUFORzERMA8GA1UEBwwISEFOR1pIT1UxEjAQ +BgNVBAoMCURFRVBTTUFSVDEYMBYGA1UEAwwPZGVlcHNtYXJ0LmxvY2FsMB4XDTI0 +MDMxMTA2MDUwNFoXDTM0MDMwOTA2MDUwNFowYTELMAkGA1UEBhMCQ04xETAPBgNV +BAgMCFpIRUpJQU5HMREwDwYDVQQHDAhIQU5HWkhPVTESMBAGA1UECgwJREVFUFNN +QVJUMRgwFgYDVQQDDA9kZWVwc21hcnQubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQC0zTA4yKEW2MSd1l4gvi9VrkLok64c7b+617UApuCPfgVn +UQi2l+1ZAYEsFMBMWpmfSg/vEV8cJaMwzXvW2MxvVJ9A4UxJeSVNbZU3CFs80gO8 +E7RnSiFRXdhmWipGBFS8d3wpHck1V3cTTPeLR2RsvXAi1vvQpM3qtUEvIh+A7QGL +b7RtdSwkKqrcHquFfgp0pDD6uDbzukeAobW8QY6N/1ZPHDdj0sfmJ7qFqZPIUWxi +hQD4kcUXnvaFISk7J+eQOJ6Z0kA7l7ZA/1a40Y09FU57Sb8tvhPeQkQI3NqR37WO +kQfCIcw2uXnL6jMoHoGMGZANg1r2M8IMe+92eKF7AgMBAAGjUDBOMB0GA1UdDgQW +BBQypxdj41vsP+iTRSr85D8MnK23UTAfBgNVHSMEGDAWgBQypxdj41vsP+iTRSr8 +5D8MnK23UTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCjwp8Qy56b +qR8h5mvksQgs2i3pAS5qCXLmvoUGAR8Q+azZTy2uQ3pgo6+1SZh+/40ZpaQWvROz +LCaKiVPVRK8xXKQPbMSg7Ls+zVZdKWidZnelzBZvnA1EUKpqJWVDCx0zRVEubv3N +qL+WPEkZt03sXiC3Ezgm6Nib6MwLfQw0OaE8Y37FgDnOWJVEti1zGriUz44vQVlY +jn/WCsYSiTe/mSWDIgx4qEMrmjvFffNerhm0jPAgv7n3UYJf2le++0WBV3vPQSXL +BzV135cS2EdIS3VlwGQ3Pe5iOqDHraMsMMofsxXxiOZZ3gHcvU7VpGIgBnkPgo5x +L/uDa/JtJTLe +-----END CERTIFICATE----- diff --git a/drivers/DeepSmart/deepsmart/src/ssdp.lua b/drivers/DeepSmart/deepsmart/src/ssdp.lua new file mode 100644 index 0000000000..4bbde9150c --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/ssdp.lua @@ -0,0 +1,89 @@ +local socket = require('socket') +local log = require('log') +local config = require('config') + + + + +local SSDP = {} + +local DEEPSMART_SSDP_SEARCH_TERM = "DEEPSMART-ARM" +----------------------- +-- SSDP Response parser +local function parse_ssdp(data) + local res = {} + res.status = data:sub(0, data:find('\r\n')) + for k, v in data:gmatch('([%w-]+): ([%a+-: /=]+)') do + local key = k:lower() + log.info('parse key '..key..' val '..v) + res[key] = v + end + return res +end + + +-- This function enables a UDP +-- Socket and broadcast a single +-- M-SEARCH request, i.e., it +-- must be looped appart. +function SSDP.search(callback) + local bridges = {} + -- UDP socket initialization + local upnp = socket.udp() + local _,err = upnp:setsockname('0.0.0.0', 0) + if err then + log.error(string.format("udp socket failure setsockname: %s", err)) + return false,bridges,err + end + -- use broadcast to find wisers as wifi router's group cast is forbidden by default(need login to router to start group cast) + upnp:setoption('broadcast', true) + local timeout = socket.gettime() + config.MC_TIMEOUT + 1 + local mx = 2 + -- broadcasting request + log.info('===== SCANNING NETWORK...') + local multicast_msg = table.concat({ + "M-SEARCH * HTTP/1.1", + "HOST: 255.255.255.255:1900", + 'MAN: "ssdp:discover"', -- yes, there are really supposed to be quotes in this one + string.format("MX: %s", mx), + string.format("ST: %s", DEEPSMART_SSDP_SEARCH_TERM), + "\r\n" + }, "\r\n") + local _, err = upnp:sendto(multicast_msg, config.MC_ADDRESS, config.MC_PORT) + if err then + log.error(string.format("udp socket failure sendto: %s", err)) + return false,bridges,err + end + while true do + -- Socket will wait n seconds + -- based on the s:setoption(n) + -- to receive a response back. + local time_remaining = math.max(0, timeout - socket.gettime()) + upnp:settimeout(time_remaining) + local res,ip = upnp:receivefrom() + if (res ~= nil) then + log.info('recv wiser '..res..' from '..ip) + local headers = parse_ssdp(res) + -- Device metadata + local usn = headers.usn + local uuid = usn:match("uuid:(%d+)::") + if (uuid ~= nil) then + log.info('usn '..usn..' uuid '..uuid..' ip '..ip) + if callback ~= nil and type(callback) == "function" then + callback(uuid, ip) + end + bridges[uuid] = ip + end + else + break + end + end + log.info('===== SCANNING NETWORK OVER') + -- close udp socket + upnp:close() + + return true,bridges,nil +end + + +return SSDP diff --git a/drivers/DeepSmart/deepsmart/src/utils.lua b/drivers/DeepSmart/deepsmart/src/utils.lua new file mode 100644 index 0000000000..0a0b036246 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/utils.lua @@ -0,0 +1,235 @@ +local log=require('log') + +local utils = {} +utils.base = {0, 0x40, 0x7A, 0x10, 0xF3, 0x5A, 0, 0} + +function utils.tonumber(str) + local len = #str + local bytes = {} + for i=1,8 do + bytes[i] = 0 + end + local lowstr = str + local highstr = nil + if (len > 14) then + lowstr = string.sub(str,len-13) + highstr = string.sub(str, 1, len-14) + end + local low = tonumber(lowstr) + local pos = 1 + while(low > 0) do + bytes[pos] = (low%256) + low = ((low-bytes[pos])>>8) + pos = pos+1 + end + -- high + local high = 0 + if (highstr ~= nil) then + high = tonumber(highstr) + end + local sec = 0 + for i=1,8 do + local fir = high * utils.base[i] + bytes[i] + sec + sec = ((fir-(fir%256))>>8) + bytes[i] = (fir%256) + end + return bytes +end + +function utils.tobytes(num) + local bytes = {} + local pos = 1 + local tmp = num + while(tmp > 0) do + bytes[pos] = (tmp%256) + tmp = ((tmp-bytes[pos])>>8) + pos = pos+1 + end + return bytes +end + +function utils.loadfile(file) + local fp = io.open(file, 'r') + if (fp) then + local content = fp:read('*a') + fp:close() + return content + else + return '' + end +end + + +function utils.str_starts_with(str, start) + return str:sub(1, #start) == start +end + +function utils.is_nan(number) + -- IEEE 754 dictates that NaN compares falsey to everything, including itself. + if number ~= number then + return true + end + + -- If someone passes in something that isn't a Number type, it'll pass the above check. + -- Philosophical question: Something that isn't a number can't technicaly have the value + -- of "nan" but "nan" stands for "not a number", so what do we do here? + if type(number) ~= "number" then + log.warn(string.format("utils.is_nan received value of type %s as argument, returning true", type(number))) + return true + end + + -- In the event that something goes wrong with the above two things, + -- we simply compare the tostring against a known NaN value. + return tostring(number) == tostring(0 / 0) +end + +-- build a exponential backoff time value generator +-- +-- max: the maximum wait interval (not including `rand factor`) +-- inc: the rate at which to exponentially back off +-- rand: a randomization range of (-rand, rand) to be added to each interval +function utils.backoff_builder(max, inc, rand) + local count = 0 + inc = inc or 1 + return function() + local randval = 0 + if rand then + randval = math.random() * rand * 2 - rand + end + + local base = inc * (2 ^ count - 1) + count = count + 1 + + -- ensure base backoff (not including random factor) is less than max + if max then base = math.min(base, max) end + + -- ensure total backoff is >= 0 + return math.max(base + randval, 0) + end +end + +function utils.labeled_socket_builder(label, ssl_config) + local log = require "log" + local socket = require "cosock.socket" + local ssl = require "cosock.ssl" + + label = (label or "") + if #label > 0 then + label = label .. " " + end + + local function make_socket(host, port, wrap_ssl) + log.info( + string.format( + "%sCreating TCP socket for REST Connection", label + ) + ) + local _ = nil + local sock, err = socket.tcp() + + if err ~= nil or (not sock) then + return nil, (err or "unknown error creating TCP socket") + end + + log.info( + string.format( + "%sSetting TCP socket timeout for REST Connection", label + ) + ) + _, err = sock:settimeout(60) + if err ~= nil then + return nil, "settimeout error: " .. err + end + + log.info( + string.format( + "%sConnecting TCP socket for REST Connection", label + ) + ) + _, err = sock:connect(host, port) + if err ~= nil then + return nil, "Connect error: " .. err + end + + log.info( + string.format( + "%sSet Keepalive for TCP socket for REST Connection", label + ) + ) + _, err = sock:setoption("keepalive", true) + if err ~= nil then + return nil, "Setoption error: " .. err + end + + if wrap_ssl then + log.info( + string.format( + "%sCreating SSL wrapper for for REST Connection", label + ) + ) + sock, err = + ssl.wrap(sock, ssl_config) + if err ~= nil then + return nil, "SSL wrap error: " .. err + end + log.info( + string.format( + "%sPerforming SSL handshake for for REST Connection", label + ) + ) + _, err = sock:dohandshake() + if err ~= nil then + return nil, "Error with SSL handshake: " .. err + end + end + + log.info( + string.format( + "%sSuccessfully created TCP connection", label + ) + ) + return sock, err + end + return make_socket +end + +--- From https://gist.github.com/sapphyrus/fd9aeb871e3ce966cc4b0b969f62f539 +--- MIT licensed +function utils.deep_table_eq(tbl1, tbl2) + if tbl1 == tbl2 then + return true + elseif type(tbl1) == "table" and type(tbl2) == "table" then + for key1, value1 in pairs(tbl1) do + local value2 = tbl2[key1] + + if value2 == nil then + -- avoid the type call for missing keys in tbl2 by directly comparing with nil + return false + elseif value1 ~= value2 then + if type(value1) == "table" and type(value2) == "table" then + if not utils.deep_table_eq(value1, value2) then + return false + end + else + return false + end + end + end + + -- check for missing keys in tbl1 + for key2, _ in pairs(tbl2) do + if tbl1[key2] == nil then + return false + end + end + + return true + end + + return false +end + + + +return utils + diff --git a/drivers/SinuxSoft/britzyhub/README.md b/drivers/SinuxSoft/britzyhub/README.md new file mode 100644 index 0000000000..510501f7cb --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/README.md @@ -0,0 +1,9 @@ +# BritzyHub Matter Edge Driver + +Edge driver for registering BritzyHub Matter dedicated Matter devices. + +--- + +## Reference + +- Use CLI.md for detailed SmartThings CLI command summaries diff --git a/drivers/SinuxSoft/britzyhub/config.yml b/drivers/SinuxSoft/britzyhub/config.yml new file mode 100644 index 0000000000..21551f1849 --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/config.yml @@ -0,0 +1,6 @@ +name: 'BritzyHub Matter' +packageKey: 'britzyhub-matter' +permissions: + matter: {} +description: "SmartThings Edge driver for BritzyHub Matter devices." +vendorSupportInformation: "https://sinux.kr" \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/fingerprints.yml b/drivers/SinuxSoft/britzyhub/fingerprints.yml new file mode 100644 index 0000000000..904b544db8 --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/fingerprints.yml @@ -0,0 +1,16 @@ +matterGeneric: + - id: "elevator" + deviceLabel: Elevator + deviceTypes: + - id: 0xFF02 + deviceProfileName: elevator + - id: "gas-valve" + deviceLabel: Gas Valve + deviceTypes: + - id: 0xFF01 + deviceProfileName: gas-valve + - id: "vent" + deviceLabel: Vent + deviceTypes: + - id: 0xFF03 + deviceProfileName: vent \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/profiles/elevator.yml b/drivers/SinuxSoft/britzyhub/profiles/elevator.yml new file mode 100644 index 0000000000..a120b501dc --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/profiles/elevator.yml @@ -0,0 +1,10 @@ +name: elevator +components: + - id: main + capabilities: + - id: elevatorCall + version: 1 + - id: refresh + version: 1 + categories: + - name: Elevator \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/profiles/gas-valve.yml b/drivers/SinuxSoft/britzyhub/profiles/gas-valve.yml new file mode 100644 index 0000000000..30f18c453b --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/profiles/gas-valve.yml @@ -0,0 +1,10 @@ +name: gas-valve +components: + - id: main + capabilities: + - id: safetyValve + version: 1 + - id: refresh + version: 1 + categories: + - name: GasValve \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/profiles/vent.yml b/drivers/SinuxSoft/britzyhub/profiles/vent.yml new file mode 100644 index 0000000000..2c956b307f --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/profiles/vent.yml @@ -0,0 +1,19 @@ +name: vent +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: fanMode + version: 1 + config: + values: + - key: "fanMode.value" + enabledValues: + - low + - medium + - high + - id: refresh + version: 1 + categories: + - name: Vent \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/src/elevator/init.lua b/drivers/SinuxSoft/britzyhub/src/elevator/init.lua new file mode 100644 index 0000000000..51aa1d8e0e --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/src/elevator/init.lua @@ -0,0 +1,91 @@ +-- SinuxSoft (c) 2025 +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local log = require "log" + +local elevator_cap = capabilities.elevatorCall +local onoff_cluster = clusters.OnOff + +local ELEVATOR_DEVICE_TYPE_ID = 0xFF02 + +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, elevator_cap.callStatus.called()) + else + device:emit_event_for_endpoint(ib.endpoint_id, elevator_cap.callStatus.standby()) + end +end + +local function handle_elevator_call(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = onoff_cluster.server.commands.On(device, endpoint_id) + device:send(req) +end + +local function find_default_endpoint(device, cluster) + local eps = device:get_endpoints(cluster) + table.sort(eps) + for _, ep in ipairs(eps) do + if ep ~= 0 then return ep end + end + log.warn(string.format("No endpoint found, using default %d", device.MATTER_DEFAULT_ENDPOINT)) + return device.MATTER_DEFAULT_ENDPOINT +end + +local function component_to_endpoint(device, component_name, cluster_id) + return find_default_endpoint(device, clusters.OnOff.ID) +end + +local function device_init(driver, device) + device:subscribe() + device:set_component_to_endpoint_fn(component_to_endpoint) +end + +local function info_changed(driver, device, event, args) + device:add_subscribed_attribute(onoff_cluster.attributes.OnOff) + device:subscribe() +end + +local function is_matter_elevator(opts, driver, device) + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == ELEVATOR_DEVICE_TYPE_ID then + return true + end + end + end + return false +end + +local elevator_handler = { + NAME = "Elevator Handler", + can_handle = is_matter_elevator, + lifecycle_handlers = { + init = device_init, + infoChanged = info_changed, + }, + matter_handlers = { + attr = { + [onoff_cluster.ID] = { + [onoff_cluster.attributes.OnOff.ID] = on_off_attr_handler, + } + } + }, + capability_handlers = { + [elevator_cap.ID] = { + [elevator_cap.commands.call.NAME] = handle_elevator_call, + } + }, + supported_capabilities = { + elevator_cap, + }, + subscribed_attributes = { + [elevator_cap.ID] = { + onoff_cluster.attributes.OnOff + } + }, +} + +return elevator_handler \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/src/gas-valve/init.lua b/drivers/SinuxSoft/britzyhub/src/gas-valve/init.lua new file mode 100644 index 0000000000..52dd3cafb6 --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/src/gas-valve/init.lua @@ -0,0 +1,91 @@ +-- SinuxSoft (c) 2025 +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local log = require "log" + +local valve_cap = capabilities.safetyValve +local onoff_cluster = clusters.OnOff + +local GAS_VALVE_DEVICE_TYPE_ID = 0xFF01 + +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, valve_cap.valve.open()) + else + device:emit_event_for_endpoint(ib.endpoint_id, valve_cap.valve.closed()) + end +end + +local function handle_valve_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = onoff_cluster.server.commands.Off(device, endpoint_id) + device:send(req) +end + +local function find_default_endpoint(device, cluster) + local eps = device:get_endpoints(cluster) + table.sort(eps) + for _, ep in ipairs(eps) do + if ep ~= 0 then return ep end + end + log.warn(string.format("No endpoint found, using default %d", device.MATTER_DEFAULT_ENDPOINT)) + return device.MATTER_DEFAULT_ENDPOINT +end + +local function component_to_endpoint(device, component_name, cluster_id) + return find_default_endpoint(device, onoff_cluster.ID) +end + +local function device_init(driver, device) + device:subscribe() + device:set_component_to_endpoint_fn(component_to_endpoint) +end + +local function info_changed(driver, device, event, args) + device:add_subscribed_attribute(onoff_cluster.attributes.OnOff) + device:subscribe() +end + +local function is_matter_gas_valve(opts, driver, device) + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == GAS_VALVE_DEVICE_TYPE_ID then + return true + end + end + end + return false +end + +local gas_valve_handler = { + NAME = "Gas Valve Handler", + can_handle = is_matter_gas_valve, + lifecycle_handlers = { + init = device_init, + infoChanged = info_changed, + }, + matter_handlers = { + attr = { + [onoff_cluster.ID] = { + [onoff_cluster.attributes.OnOff.ID] = on_off_attr_handler, + } + } + }, + capability_handlers = { + [valve_cap.ID] = { + [valve_cap.commands.close.NAME] = handle_valve_close, + } + }, + supported_capabilities = { + valve_cap, + }, + subscribed_attributes = { + [valve_cap.ID] = { + onoff_cluster.attributes.OnOff + } + }, +} + +return gas_valve_handler \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/src/init.lua b/drivers/SinuxSoft/britzyhub/src/init.lua new file mode 100644 index 0000000000..60f3cb90ce --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/src/init.lua @@ -0,0 +1,16 @@ +-- SinuxSoft (c) 2025 +-- Licensed under the Apache License, Version 2.0 + +local MatterDriver = require "st.matter.driver" +local log = require "log" + +local matter_driver = MatterDriver("britzyhub-matter", { + sub_drivers = { + require ("elevator"), + require ("gas-valve"), + require ("vent"), + } +}) + +log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) +matter_driver:run() \ No newline at end of file diff --git a/drivers/SinuxSoft/britzyhub/src/vent/init.lua b/drivers/SinuxSoft/britzyhub/src/vent/init.lua new file mode 100644 index 0000000000..93e11f40c6 --- /dev/null +++ b/drivers/SinuxSoft/britzyhub/src/vent/init.lua @@ -0,0 +1,164 @@ +-- SinuxSoft (c) 2025 +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local log = require "log" + +local VENTILATOR_DEVICE_TYPE_ID = 0xFF03 + +local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" + +local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.fanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, +} + +local function map_enum_value(map, value, default) + return map[value] or default +end + +local function find_default_endpoint(device, cluster) + local eps = device:get_endpoints(cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then + return v + end + end + log.warn(string.format("No endpoint found, using default %d", device.MATTER_DEFAULT_ENDPOINT)) + return device.MATTER_DEFAULT_ENDPOINT +end + +local function component_to_endpoint(device, component_name, cluster_id) + local component_to_endpoint_map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) + if component_to_endpoint_map ~= nil and component_to_endpoint_map[component_name] ~= nil then + return component_to_endpoint_map[component_name] + end + if not cluster_id then return device.MATTER_DEFAULT_ENDPOINT end + return find_default_endpoint(device, cluster_id) +end + +local endpoint_to_component = function(device, endpoint_id) + local component_to_endpoint_map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) + if component_to_endpoint_map ~= nil then + for comp, ep in pairs(component_to_endpoint_map) do + if ep == endpoint_id then + return comp + end + end + end + return "main" +end + +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) + end +end + +local AC_FAN_MODE_MAP = { + [clusters.FanControl.attributes.FanMode.LOW] = capabilities.fanMode.fanMode.low(), + [clusters.FanControl.attributes.FanMode.MEDIUM] = capabilities.fanMode.fanMode.medium(), + [clusters.FanControl.attributes.FanMode.HIGH] = capabilities.fanMode.fanMode.high(), +} + +local function fan_mode_handler(driver, device, ib, response) + local cap = capabilities.fanMode + if device:supports_capability_by_id(cap.ID) then + local event = map_enum_value(AC_FAN_MODE_MAP, ib.data.value, cap.fanMode.low()) + device:emit_event_for_endpoint(ib.endpoint_id, event) + end +end + +local function handle_switch_on(driver, device, cmd) + local ep = component_to_endpoint(device, cmd.component, clusters.OnOff.ID) + device:send(clusters.OnOff.server.commands.On(device, ep)) +end + +local function handle_switch_off(driver, device, cmd) + local ep = component_to_endpoint(device, cmd.component, clusters.OnOff.ID) + device:send(clusters.OnOff.server.commands.Off(device, ep)) +end + +local function set_fan_mode(driver, device, cmd) + local args = cmd.args.fanMode + local mode_map = { + low = clusters.FanControl.attributes.FanMode.LOW, + medium = clusters.FanControl.attributes.FanMode.MEDIUM, + high = clusters.FanControl.attributes.FanMode.HIGH, + } + local ep = component_to_endpoint(device, cmd.component, clusters.FanControl.ID) + local mode = mode_map[args] or clusters.FanControl.attributes.FanMode.OFF + device:send(clusters.FanControl.attributes.FanMode:write(device, ep, mode)) +end + +local function device_init(driver, device) + device:subscribe() + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local function info_changed(driver, device, event, args) + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + device:subscribe() +end + +local function is_matter_ventilator(opts, driver, device) + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == VENTILATOR_DEVICE_TYPE_ID then + return true + end + end + end + return false +end + +local ventilator_handler = { + NAME = "Ventilator Handler", + can_handle = is_matter_ventilator, + lifecycle_handlers = { + init = device_init, + infoChanged = info_changed, + }, + matter_handlers = { + attr = { + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, + }, + [clusters.FanControl.ID] = { + [clusters.FanControl.attributes.FanMode.ID] = fan_mode_handler, + }, + } + }, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_switch_on, + [capabilities.switch.commands.off.NAME] = handle_switch_off, + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = set_fan_mode, + }, + }, + supported_capabilities = { + capabilities.switch, + capabilities.fanMode, + }, + subscribed_attributes = subscribed_attributes +} + +return ventilator_handler \ No newline at end of file diff --git a/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml b/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml index 8b3da53bde..7836421cf6 100644 --- a/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml +++ b/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml @@ -20,8 +20,6 @@ components: version: 1 - id: audioNotification version: 1 - - id: keypadInput - version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/harman-luxury/profiles/maX10.yaml b/drivers/SmartThings/harman-luxury/profiles/maX10.yaml index 97169a3d8d..b07963bd2c 100644 --- a/drivers/SmartThings/harman-luxury/profiles/maX10.yaml +++ b/drivers/SmartThings/harman-luxury/profiles/maX10.yaml @@ -10,8 +10,6 @@ components: version: 1 - id: audioTrackData version: 1 - - id: mediaPresets - version: 1 - id: mediaPlayback version: 1 - id: mediaTrackControl @@ -23,7 +21,7 @@ components: - id: refresh version: 1 categories: - - name: Speaker + - name: Receiver preferences: - preferenceId: certifiedpreferences.doNotDisturbPlayback explicit: true diff --git a/drivers/SmartThings/harman-luxury/src/api/apis.lua b/drivers/SmartThings/harman-luxury/src/api/apis.lua index 01b646d475..b068289a74 100644 --- a/drivers/SmartThings/harman-luxury/src/api/apis.lua +++ b/drivers/SmartThings/harman-luxury/src/api/apis.lua @@ -8,18 +8,12 @@ local invoke = require "api.invokes" --- system paths ----------------------------------------- -local UUID_PATH = "settings:/system/memberId" -local MAC_PATH = "settings:/system/primaryMacAddress" -local MEMBER_ID_PATH = "settings:/system/memberId" local MANUFACTURER_NAME_PATH = "settings:/system/manufacturer" local DEVICE_NAME_PATH = "settings:/deviceName" local MODEL_NAME_PATH = "settings:/system/modelName" local PRODUCT_NAME_PATH = "settings:/system/productName" - ---- SmartThings paths ---------------------------------- -local SMARTTHINGS_PATH = "smartthings:" -local SMARTTHINGS_AUDIO_PATH = "smartthings:audio/" -local SMARTTHINGS_MEDIA_PATH = "smartthings:media/" +local INIT_CREDENTIAL_PATH = "smartthings:initCredentialsToken" +local CREDENTIAL_PATH = "settings:/smartthings/userToken" ---------------------------------------------------------- --- APIs @@ -29,27 +23,6 @@ local APIs = {} --- system APIs ------------------------------------------ ---- get UUID from Harman Luxury on ip ----@param ip string ----@return string|nil, nil|string -function APIs.GetUUID(ip) - return get.String(ip, UUID_PATH) -end - ---- get MAC address from Harman Luxury on ip ----@param ip string ----@return string|nil, nil|string -function APIs.GetMAC(ip) - return get.String(ip, MAC_PATH) -end - ---- get Member ID from Harman Luxury on ip ----@param ip string ----@return string|nil, nil|string -function APIs.GetMemberId(ip) - return get.String(ip, MEMBER_ID_PATH) -end - --- get device manufacturer name from Harman Luxury on ip ---@param ip string ---@return string|nil, nil|string @@ -86,233 +59,28 @@ function APIs.SetDeviceName(ip, value) return set.String(ip, DEVICE_NAME_PATH, value) end ---- get active credential token from a Harman Luxury device on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InitCredentialsToken(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "initCredentialsToken") -end - ---- get active credential token from a Harman Luxury device on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetCredentialsToken(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "getCredentialsToken") -end - ---- get supported input sources from a Harman Luxury device on ip +--- initialise a new credential token from Harman Luxury on ip ---@param ip string ----@return table|nil, nil|string -function APIs.GetSupportedInputSources(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "getSupportedInputSources") -end - ---- power manager APIs ----------------------------------- - ---- invoke smartthings:setOn on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.SetOn(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "setOn") -end - ---- invoke smartthings:setOff on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.SetOff(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "setOff") -end - ---- get current power state Harman Luxury on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetPowerState(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "powerStatus") -end - ---- audio APIs ------------------------------------ - ---- set Mute value of Harman Luxury media player on ip ----@param ip string ----@param value boolean ----@return boolean|number|string|table|nil, nil|string -function APIs.SetMute(ip, value) - return set.Bool(ip, SMARTTHINGS_AUDIO_PATH .. "mute", value) -end - ---- get Mute value of Harman Luxury media player on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetMute(ip) - return get.Bool(ip, SMARTTHINGS_AUDIO_PATH .. "mute") -end - ---- set Volume value of Harman Luxury media player on ip ----@param ip string ----@param value integer ----@return boolean|number|string|table|nil, nil|string -function APIs.SetVol(ip, value) - return set.I32(ip, SMARTTHINGS_AUDIO_PATH .. "volume", value) -end - ---- get Volume value of Harman Luxury media player on ip ----@param ip string ----@return number|nil, nil|string -function APIs.GetVol(ip) - return get.I32(ip, SMARTTHINGS_AUDIO_PATH .. "volume") -end - ---- invoke smartthings:audio/getAudioTrackData on ip ----@class AudioTrackData ----@field trackdata table ----@field supportedPlaybackCommands table ----@field supportedTrackControlCommands table ----@field totalTime number ----@param ip string ----@return AudioTrackData|nil, nil|string -function APIs.getAudioTrackData(ip) - local val, err = invoke.Activate(ip, SMARTTHINGS_AUDIO_PATH .. "getAudioTrackData") - if val then - local audioTrackData = { - trackdata = { - title = val.title or "", - artist = val.artist or nil, - album = val.album or nil, - albumArtUrl = val.albumArtUrl or nil, - mediaSource = val.mediaSource or nil, - }, - supportedPlaybackCommands = val.supportedPlaybackCommands, - supportedTrackControlCommands = val.supportedTrackControlCommands, - totalTime = val.totalTime, - } - return audioTrackData, nil - else +---@return string|nil, nil|string +function APIs.init_credential_token(ip) + local val, err = invoke.Activate(ip, INIT_CREDENTIAL_PATH) + if err then return nil, err - end -end - ---- Audio Notification API ------------------------------------ - ---- invoke Audio Notification of Harman Luxury on ip ----@param ip string ----@param uri string ----@param level number ----@return boolean|number|string|table|nil, nil|string -function APIs.SendAudioNotification(ip, uri, level) - local value = { - smartthingsAudioNotification = { - uri = uri, - level = level, - }, - } - return invoke.ActivateValue(ip, SMARTTHINGS_PATH .. "playAudioNotification", value) -end - ---- media player APIs ------------------------------------ - ---- set Input Source value of Harman Luxury on ip ----@param ip string ----@param source string ----@return boolean|number|string|table|nil, nil|string -function APIs.SetInputSource(ip, source) - local value = { - string_ = source, - } - return invoke.ActivateValue(ip, SMARTTHINGS_MEDIA_PATH .. "setInputSource", value) -end - ---- get Input Source value of Harman Luxury on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetInputSource(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "getInputSource") -end - ---- play Media Preset with given id value on Harman Luxury on ip ----@param ip string ----@param id integer ----@return boolean|number|string|table|nil, nil|string -function APIs.PlayMediaPreset(ip, id) - local value = { - i32_ = id, - } - return invoke.ActivateValue(ip, SMARTTHINGS_MEDIA_PATH .. "playMediaPreset", value) -end - ---- get Media Preset list of Harman Luxury on ip ----@param ip string ----@return table|nil, nil|string -function APIs.GetMediaPresets(ip) - local val, err = invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "getMediaPresets") - if val then - return val.presets, nil else - return nil, err + if type(val) == "string" then + return val, nil + else + err = string.format("Device with IP:%s failed to generate a valid credential", ip) + return nil, err + end end end ---- invoke smartthings:media/setPlay on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokePlay(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setPlay") -end - ---- invoke smartthings:media/setPause on ip +--- get device current active token from Harman Luxury on ip ---@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokePause(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setPause") -end - ---- invoke smartthings:media/setNextTrack on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokeNext(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setNextTrack") -end - ---- invoke smartthings:media/setPrevTrack on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokePrevious(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setPrevTrack") -end - ---- invoke smartthings:media/setStop on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokeStop(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setStop") -end - ---- invoke smartthings:media/setStop on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetPlayerState(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "getPlayerState") -end - ---- key input APIs ------------------------------------ - ---- invoke smartthings:sendKey on ip ----@param ip string ----@param key string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokeSendKey(ip, key) - local value = { - NsdkSmartThingsKey = key, - } - return invoke.ActivateValue(ip, SMARTTHINGS_PATH .. "sendKey", value) -end - ---- check for values change APIs ------------------------------------ - ---- invoke smartthings:updateValues on ip ----@param ip string ----@return table|nil, nil|string -function APIs.InvokeGetUpdates(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "updateValues") +---@return string|nil, nil|string +function APIs.GetActiveCredentialToken(ip) + return get.String(ip, CREDENTIAL_PATH) end return APIs diff --git a/drivers/SmartThings/harman-luxury/src/api/nsdk.lua b/drivers/SmartThings/harman-luxury/src/api/nsdk.lua index c712408bac..e0dbe81bd5 100644 --- a/drivers/SmartThings/harman-luxury/src/api/nsdk.lua +++ b/drivers/SmartThings/harman-luxury/src/api/nsdk.lua @@ -64,7 +64,7 @@ end ---@param func_name string ---@param u string ---@param sink string ----@param code integer +---@param code integer|string ---@param valLocationFunc function ---@return boolean|number|string|table|nil, nil|string local function handleReply(func_name, u, sink, code, valLocationFunc) @@ -88,7 +88,12 @@ local function handleReply(func_name, u, sink, code, valLocationFunc) end return nil, err else -- UNKNOWN VALUE - local err = string.format("Error in %s: Unknown return value: code: %s, sink: %s", func_name, code, sink) + local err + if string.find(code, "timeout") then + err = string.format("Error in %s: Connection timeout", func_name) + else + err = string.format("Error in %s: Unknown return value: code: %s, sink: %s", func_name, code, sink) + end log.error(err) return nil, err end diff --git a/drivers/SmartThings/harman-luxury/src/constants.lua b/drivers/SmartThings/harman-luxury/src/constants.lua index d6524150e4..29e3cfab8c 100644 --- a/drivers/SmartThings/harman-luxury/src/constants.lua +++ b/drivers/SmartThings/harman-luxury/src/constants.lua @@ -3,14 +3,20 @@ local Constants = { IP = "device_ipv4", DEVICE_INFO = "device_info", CREDENTIAL = "credential", - STATUS = "status", - HEALTH_TIMER = "health_timer", - UPDATE_TIMER = "value_updates_timer", + INITIALISED = "initialised", + WEBSOCKET = "websocket", + + -- message fields + MESSAGE = "message", + CAPABILITY = "capability", + COMMAND = "command", + ARG = "arg", -- intervals constants (in seconds) - UPDATE_INTERVAL = 1, - HEALTH_CHEACK_INTERVAL = 10, - HTTP_TIMEOUT = 10, + WS_SOCKET_TIMEOUT = 10, + WS_IDLE_PING_PERIOD = 30, + WS_RECONNECT_PERIOD = 10, + HTTP_TIMEOUT = 5, -- discovery constants SERVICE_TYPE = "_sue-st._tcp", @@ -28,5 +34,6 @@ local Constants = { -- general consts VOL_STEP = 5, + WS_PORT = 50002, } return Constants diff --git a/drivers/SmartThings/harman-luxury/src/disco.lua b/drivers/SmartThings/harman-luxury/src/disco.lua index 41fc6a8395..73020d72d9 100644 --- a/drivers/SmartThings/harman-luxury/src/disco.lua +++ b/drivers/SmartThings/harman-luxury/src/disco.lua @@ -7,57 +7,63 @@ local disco_helper = require "disco_helper" local devices = require "devices" local const = require "constants" -local Discovery = { - joined_device = {}, -} +local Discovery = {} -local function update_device_discovery_cache(driver, dni, params, token) +local joined_device = {} + +function Discovery.set_device_field(driver, device) + log.info(string.format("set_device_field : dni=%s", device.device_network_id)) + local device_cache_value = driver.datastore.discovery_cache[device.device_network_id] + + -- persistent fields + device:set_field(const.IP, device_cache_value.ip, { + persist = true, + }) + device:set_field(const.CREDENTIAL, device_cache_value.credential, { + persist = true, + }) + device:set_field(const.DEVICE_INFO, device_cache_value.device_info, { + persist = true, + }) + + driver.datastore.discovery_cache[device.device_network_id] = nil +end + +local function update_device_discovery_cache(driver, dni, params, credential) log.info(string.format("update_device_discovery_cache for device dni: dni=%s, ip=%s", dni, params.ip)) local device_info = devices.get_device_info(dni, params) driver.datastore.discovery_cache[dni] = { ip = params.ip, + credential = credential, device_info = device_info, - credential = token, } end local function try_add_device(driver, device_dni, device_params) log.trace(string.format("try_add_device : dni=%s, ip=%s", device_dni, device_params.ip)) - local token, err = api.InitCredentialsToken(device_params.ip) + local credential, err = api.init_credential_token(device_params.ip) - if err then - log.error(string.format("failed to get credential token for dni=%s, ip=%s", device_dni, device_params.ip)) + if not credential or err then + log.error(string.format("failed to get credential. dni= %s, ip= %s. Error: %s", device_dni, device_params.ip, err)) + joined_device[device_dni] = nil return false end - update_device_discovery_cache(driver, device_dni, device_params, token) + update_device_discovery_cache(driver, device_dni, device_params, credential) driver:try_create_device(driver.datastore.discovery_cache[device_dni].device_info) return true end -function Discovery.set_device_field(driver, device) - log.info(string.format("set_device_field : dni=%s", device.device_network_id)) - local device_cache_value = driver.datastore.discovery_cache[device.device_network_id] +function Discovery.device_added(driver, device) + log.info(string.format("Device added: %s", device.label)) - -- persistent fields - device:set_field(const.STATUS, true, { - persist = true, - }) - device:set_field(const.IP, device_cache_value.ip, { - persist = true, - }) - device:set_field(const.DEVICE_INFO, device_cache_value.device_info, { - persist = true, - }) - if device_cache_value.credential then - device:set_field(const.CREDENTIAL, device_cache_value.credential, { - persist = true, - }) - end + Discovery.set_device_field(driver, device) + joined_device[device.device_network_id] = nil + driver.lifecycle_handlers.init(driver, device) end -local function find_params_table() +function Discovery.find_params_table() log.info("Discovery.find_params_table") local discovery_responses = mdns.discover(const.SERVICE_TYPE, const.DOMAIN) or {} @@ -68,8 +74,6 @@ local function find_params_table() end local function discovery_device(driver) - local unknown_discovered_devices = {} - local known_discovered_devices = {} local known_devices = {} log.debug("\n\n--- Initialising known devices list ---\n") @@ -78,54 +82,23 @@ local function discovery_device(driver) end log.debug("\n\n--- Creating the parameters table ---\n") - local params_table = find_params_table() + local params_table = Discovery.find_params_table() - log.debug("\n\n--- Checking if devices are known or not ---\n") + log.debug("\n\n--- Adding unknown devices ---\n") for dni, params in pairs(params_table) do - if next(known_devices) == nil or not known_devices[dni] then - unknown_discovered_devices[dni] = params + if not known_devices or not known_devices[dni] then log.info(string.format("discovery_device unknown dni=%s, ip=%s", dni, params.ip)) - else - known_discovered_devices[dni] = params - log.info(string.format("discovery_device known dni=%s, ip=%s", dni, params.ip)) - end - end - - log.debug("\n\n--- Update devices cache ---\n") - for dni, params in pairs(known_discovered_devices) do - log.trace(string.format("known dni=%s, ip=%s", dni, params.ip)) - if Discovery.joined_device[dni] then - update_device_discovery_cache(driver, dni, params) - Discovery.set_device_field(driver, known_devices[dni]) - end - end - - if unknown_discovered_devices then - log.debug("\n\n--- Try to create unkown devices ---\n") - for dni, ip in pairs(unknown_discovered_devices) do - log.trace(string.format("unknown dni=%s, ip=%s", dni, ip)) - if not Discovery.joined_device[dni] then + if not joined_device[dni] then if try_add_device(driver, dni, params_table[dni]) then - Discovery.joined_device[dni] = true + joined_device[dni] = true end end + else + log.info(string.format("discovery_device known dni=%s, ip=%s", dni, params.ip)) end end end -function Discovery.find_ip_table() - log.info("Discovery.find_ip_table") - - local dni_params_table = find_params_table() - - local dni_ip_table = {} - for dni, params in pairs(dni_params_table) do - dni_ip_table[dni] = params.ip - end - - return dni_ip_table -end - function Discovery.discovery_handler(driver, _, should_continue) log.info("Starting Harman Luxury discovery") @@ -136,4 +109,29 @@ function Discovery.discovery_handler(driver, _, should_continue) log.info("Ending Harman Luxury discovery") end +function Discovery.update_device_ip(device) + local dni = device.device_network_id + local ip = device:get_device_info(const.IP) + + -- collect current parameters + local params_table = Discovery.find_params_table() + + -- update device IPs + if params_table[dni] then + -- if device is still online + local current_ip = params_table[dni].ip + if ip ~= current_ip then + device:set_field(const.IP, current_ip, { + persist = true, + }) + log.info(string.format("%s updated IP from %s to %s", dni, ip, current_ip)) + end + return true + else + -- if device is no longer online + device:offline() + return false + end +end + return Discovery diff --git a/drivers/SmartThings/harman-luxury/src/handlers.lua b/drivers/SmartThings/harman-luxury/src/handlers.lua deleted file mode 100644 index 7401749be1..0000000000 --- a/drivers/SmartThings/harman-luxury/src/handlers.lua +++ /dev/null @@ -1,239 +0,0 @@ -local capabilities = require "st.capabilities" -local api = require "api.apis" -local log = require "log" -local const = require "constants" - -local Handler = {} - ---- handler of switch.on -function Handler.handle_on(_, device, _) - log.info("Starting handle_on") - -- send API switch on message - local ip = device:get_field(const.IP) - local val, err = api.SetOn(ip) - if val then - device:emit_event(capabilities.switch.switch.on()) - else - log.warn(string.format("Error during handle_on(): %s", err)) - end -end - ---- handler of switch.off -function Handler.handle_off(_, device, _) - log.info("Starting handle_off") - -- send API switch off message - local ip = device:get_field(const.IP) - local val, err = api.SetOff(ip) - if val then - device:emit_event(capabilities.switch.switch.off()) - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - else - log.warn(string.format("Error during handle_off(): %s", err)) - end -end - ---- internal function to set mute ----@param Device ----@param mute boolean ----@param func_name string -local function set_mute(device, mute, func_name) - local ip = device:get_field(const.IP) - local val, err = api.SetMute(ip, mute) - if val then - if mute then - device:emit_event(capabilities.audioMute.mute.muted()) - else - device:emit_event(capabilities.audioMute.mute.unmuted()) - end - else - log.warn(string.format("Error during %s(): %s", func_name, err)) - end -end - ---- handler of audioMute.mute -function Handler.handle_mute(_, device, _) - log.info("Starting handle_mute") - -- send API mute on message - set_mute(device, true, "handle_mute") -end - ---- handler of audioMute.unmute -function Handler.handle_unmute(_, device, _) - log.info("Starting handle_unmute") - -- send API mute off message - set_mute(device, false, "handle_unmute") -end - ---- handler of audioMute.setMute -function Handler.handle_set_mute(_, device, cmd) - log.info("Starting handle_set_mute") - -- send API mute set message - local mute = cmd.args and cmd.args.state == "muted" - set_mute(device, mute, "handle_set_mute") -end - ---- internal function to set volume ----@param device Device ----@param vol number|nil ----@param step number|nil ----@param func_name string -local function set_vol(device, vol, step, func_name) - local ip = device:get_field(const.IP) - local setVol - if vol then - setVol = vol - else - local currVol, err = api.GetVol(ip) - if err or type(currVol) ~= "number" then - currVol = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) - end - setVol = currVol + step - end - local val, err = api.SetVol(ip, setVol) - if val then - device:emit_event(capabilities.audioVolume.volume(setVol)) - else - log.warn(string.format("Error during %s(): %s", func_name, err)) - end -end - ---- handler of audioVolume.volumeUp -function Handler.handle_volume_up(_, device, _) - log.info("Starting handle_volume_up") - -- send API volume get message to know to what volume to raise - set_vol(device, nil, const.VOL_STEP, "handle_volume_up") -end - ---- handler of audioVolume.volumeDown -function Handler.handle_volume_down(_, device, _) - log.info("Starting handle_volume_down") - -- send API volume get message to know to what volume to decrease - set_vol(device, nil, -const.VOL_STEP, "handle_volume_down") -end - ---- handler of audioVolume.setVolume -function Handler.handle_set_volume(_, device, cmd) - log.info("Starting handle_set_volume") - -- send API volume set message - set_vol(device, cmd.args.volume, nil, "handle_set_volume") -end - ---- handler of mediaInputSource.setInputSource -function Handler.handle_setInputSource(_, device, cmd) - log.info("Starting handle_setInputSource") - -- send API input source set message - local ip = device:get_field(const.IP) - local val, err = api.SetInputSource(ip, cmd.args.mode) - if val then - device:emit_event(capabilities.mediaInputSource.inputSource(cmd.args.mode)) - else - log.warn(string.format("Error during handle_setInputSource(): %s", err)) - end -end - ---- handler of mediaPresets.playPreset -function Handler.handle_play_preset(_, device, cmd) - log.info("Starting handle_play_preset") - -- send API to play media preset - local ip = device:get_field(const.IP) - local presetId = cmd.args.presetId:lower():gsub("preset", ""):gsub("%W", "") - local mediaPresets = device:get_latest_state("main", capabilities.mediaPresets.ID, - capabilities.mediaPresets.presets.NAME) - for _, preset in pairs(mediaPresets) do - local id = preset.id - local name = preset.name:lower():gsub("preset", ""):gsub("%W", "") - if id == presetId or name == presetId then - local _, err = api.PlayMediaPreset(ip, id) - if err then - log.warn(string.format("Error during handle_play_preset(): %s", err)) - end - return - end - end - log.warn(string.format("Couldn't find provided Media Preset: %s", cmd.args.presetId)) -end - ---- handler of audioNotification.playTrack, audioNotification.playTrackAndResume, ---- and audioNotification.playTrackAndRestore -function Handler.handle_audio_notification(_, device, cmd) - log.info("Starting handle_audio_notification") - -- send API to play audio notification - local ip = device:get_field(const.IP) - local uri, level = cmd.args.uri, cmd.args.level - local _, err = api.SendAudioNotification(ip, uri, level) - if err then - log.warn(string.format("Error during handle_audio_notification(): %s", err)) - end -end - ---- internal function to handle set playback status ----@param device Device ----@param status string ----@param func_name string -local function set_playback_status(device, status, func_name) - local invokeFunc = { - pause = api.InvokePause, - play = api.InvokePlay, - stop = api.InvokeStop, - } - if invokeFunc[status] == nil then - log.warn(string.format("Error during %s(): unsupported status given - %s", func_name, status)) - return - else - local ip = device:get_field(const.IP) - local _, err = invokeFunc[status](ip) - if err then - log.warn(string.format("Error during %s(): %s", func_name, err)) - end - end -end - ---- handler of mediaPlayback.play -function Handler.handle_play(_, device, _) - log.info("Starting handle_play") - set_playback_status(device, "play", "handle_play") -end - ---- handler of mediaPlayback.pause -function Handler.handle_pause(_, device, _) - log.info("Starting handle_pause") - set_playback_status(device, "pause", "handle_pause") -end - ---- handler of mediaPlayback.stop -function Handler.handle_stop(_, device, _) - log.info("Starting handle_stop") - set_playback_status(device, "stop", "handle_stop") -end - ---- handler of mediaTrackControl.nextTrack -function Handler.handle_next_track(_, device, _) - log.info("Starting handle_next_track") - local ip = device:get_field(const.IP) - local _, err = api.InvokeNext(ip) - if err then - log.warn(string.format("Error during handle_next_track(): %s", err)) - end -end - ---- handler of mediaTrackControl.previousTrack -function Handler.handle_previous_track(_, device, _) - log.info("Starting handle_previous_track") - local ip = device:get_field(const.IP) - local _, err = api.InvokePrevious(ip) - if err then - log.warn(string.format("Error during handle_previous_track(): %s", err)) - end -end - ---- handler of keypadInput.sendKey -function Handler.handle_send_key(_, device, cmd) - log.info(string.format("Starting handle_send_key. Input key is: %s", cmd.args.keyCode)) - local ip = device:get_field(const.IP) - local _, err = api.InvokeSendKey(ip, cmd.args.keyCode) - if err then - log.warn(string.format("Error during handle_send_key(): %s", err)) - end -end - -return Handler diff --git a/drivers/SmartThings/harman-luxury/src/hl_websocket.lua b/drivers/SmartThings/harman-luxury/src/hl_websocket.lua new file mode 100644 index 0000000000..0d7b99d96b --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/hl_websocket.lua @@ -0,0 +1,298 @@ +local log = require "log" + +local CloseCode = require "lustre.frame.close".CloseCode +local Config = require "lustre".Config +local ws = require "lustre".WebSocket + +local cosock = require "cosock" +local socket = require "cosock.socket" +local capabilities = require "st.capabilities" +local json = require "st.json" +local st_utils = require "st.utils" + +local api = require "api.apis" +local const = require "constants" +local discovery = require "disco" + +--- a websocket to get updates from Harman Luxury devices +--- @class harman-luxury.HLWebsocket +--- @field driver table the driver the device is a memeber of +--- @field device table the device the websocket is connected to +--- @field websocket table|nil the websocket connection to the device +local HLWebsocket = {} +HLWebsocket.__index = HLWebsocket + +--- handles capabilities and sends the commands to the device +---@param msg any device that sends the command +function HLWebsocket:send_msg_handler(msg) + local dni = self.device.device_network_id + log.debug(string.format("Sending this message to %s: %s", dni, st_utils.stringify_table(msg))) + self.websocket:send_text(msg) +end + +--- handles listener event messages to update relevant SmartThings capbilities +---@param msg any|table +function HLWebsocket:received_msg_handler(msg) + if msg[const.CREDENTIAL] then + -- the device updates all WebSockets when it registers a new credential token. If this hub no longer holds the token + -- disconnect it + local currentToken = self.device:get_field(const.CREDENTIAL) + if msg[const.CREDENTIAL] ~= currentToken then + local dni = self.device.device_network_id + log.info(string.format("%s is connected to a different hub. Setting this device offline in this hub", dni)) + self:stop() + return + end + end + -- check for a power state change + if msg[capabilities.switch.ID] then + local powerState = msg[capabilities.switch.ID] + if powerState == capabilities.switch.commands.on.NAME then + self.device:emit_event(capabilities.switch.switch.on()) + elseif powerState == capabilities.switch.commands.off.NAME then + self.device:emit_event(capabilities.switch.switch.off()) + end + end + -- check for a player state change + if msg[capabilities.mediaPlayback.ID] then + local playerState = msg[capabilities.mediaPlayback.ID] + if playerState == capabilities.mediaPlayback.commands.play.NAME then + self.device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) + elseif playerState == capabilities.mediaPlayback.commands.pause.NAME then + self.device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) + else + self.device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) + local stopTrackData = {} + stopTrackData["title"] = "" + self.device:emit_event(capabilities.audioTrackData.audioTrackData(stopTrackData)) + self.device:emit_event(capabilities.audioTrackData.totalTime(0)) + end + end + -- check for an audio track data change + if msg[capabilities.audioTrackData.ID] then + local audioTrackData = msg[capabilities.audioTrackData.ID].audioTrackData + local totalTime = msg[capabilities.audioTrackData.ID].totalTime + local trackdata = {} + if type(audioTrackData.title) == "string" then + trackdata.title = audioTrackData.title + else + trackdata.title = "" + end + if type(audioTrackData.artist) == "string" then + trackdata.artist = audioTrackData.artist + end + if type(audioTrackData.album) == "string" then + trackdata.album = audioTrackData.album + end + if type(audioTrackData.albumArtUrl) == "string" then + trackdata.albumArtUrl = audioTrackData.albumArtUrl + end + if type(audioTrackData.mediaSource) == "string" then + trackdata.mediaSource = audioTrackData.mediaSource + end + self.device:emit_event(capabilities.audioTrackData.audioTrackData(trackdata)) + self.device:emit_event(capabilities.audioTrackData.totalTime(totalTime or 0)) + end + -- check for an elapsed time change + if msg[capabilities.audioTrackData.elapsedTime.NAME] then + self.device:emit_event(capabilities.audioTrackData.elapsedTime(msg[capabilities.audioTrackData.ID])) + end + -- check for a media presets change + if msg[capabilities.mediaPresets.ID] and type(msg[capabilities.mediaPresets.ID]) == "table" then + self.device:emit_event(capabilities.mediaPresets.presets(msg[capabilities.mediaPresets.ID])) + end + -- check for a supported input sources change + if msg["supportedInputSources"] then + self.device:emit_event(capabilities.mediaInputSource.supportedInputSources(msg["supportedInputSources"])) + end + -- check for a supportedInputSources change + if msg["supportedTrackControlCommands"] then + self.device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( + msg["supportedTrackControlCommands"]) or {}) + end + -- check for a supported playback commands change + if msg["supportedPlaybackCommands"] then + self.device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands(msg["supportedPlaybackCommands"]) or + {}) + end + -- check for a media input source change + if msg[capabilities.mediaInputSource.ID] then + self.device:emit_event(capabilities.mediaInputSource.inputSource(msg[capabilities.mediaInputSource.ID])) + end + -- check for a volume value change + if msg[capabilities.audioVolume.ID] then + self.device:emit_event(capabilities.audioVolume.volume(msg[capabilities.audioVolume.ID])) + end + -- check for a mute value change + if msg[capabilities.audioMute.ID] ~= nil then + if msg[capabilities.audioMute.ID] then + self.device:emit_event(capabilities.audioMute.mute.muted()) + else + self.device:emit_event(capabilities.audioMute.mute.unmuted()) + end + end +end + +--- socket listener +function HLWebsocket:listener() + local device_dni = self.device.device_network_id + while not self._stopped do + if self.websocket then + local msg, err + msg, err = self.websocket:receive() + if err ~= nil and err ~= "closed" then + -- unknown error. try reconnect and kill cosock task to avoid more than one listener task for the same device + log.err(string.format("%s Websocket error: %s", device_dni, err)) + self.device:offline() + cosock.spawn(self:try_reconnect(), string.format("%s try_reconnect", device_dni)) + return + elseif err == "closed" then + -- WebSocket closed. try reconnect and kill cosock task to avoid more than one listener task for the same device + log.info(string.format("%s Websocket closed: %s", device_dni, err)) + self.websocket = nil + cosock.spawn(self:try_reconnect(), string.format("%s try_reconnect", device_dni)) + return + else + -- handle received message + log.trace(string.format("%s received websocket message: %s", device_dni, st_utils.stringify_table(msg))) + local jsonMsg = json.decode(msg.data) + self:received_msg_handler(jsonMsg) + end + else + cosock.spawn(self:try_reconnect(), string.format("%s try_reconnect", device_dni)) + return + end + end +end + +--- try reconnect webclient +---@param attempts integer|nil reconnect attempt number (default=0) +---@return boolean has the reconnection succeeded +function HLWebsocket:try_reconnect(attempts) + attempts = 0 or attempts + local retries = 0 + local dni = self.device.device_network_id + local ip = self.device:get_field(const.IP) + local token = self.device:get_field(const.CREDENTIAL) + + if not ip then + log.warn(string.format("%s cannot reconnect because no device ip", dni)) + return false + end + + log.trace(string.format("%s checking if IP are still up to date", dni)) + local activeToken, err = api.GetActiveCredentialToken(ip) + if err then + -- device is either offline or changed IP. try to update IP then try reconnect again + log.warn(string.format("%s error while getting active credential: Error: ", dni, err)) + if attempts < 3 then + discovery.update_active_devices_ips(self.device) + return self:try_reconnect(attempts + 1) + else + return false + end + end + + log.trace(string.format("%s checking if hub is still active", dni)) + if token ~= activeToken then + -- hub no longer active. stop device onn this hub + self:stop() + return false + end + + log.info(string.format("%s attempting to reconnect websocket for speaker at %s", dni, ip)) + while true do + if self:start() then + return true + end + retries = retries + 1 + log.info(string.format("Reconnect attempt %s in %s seconds", retries, const.WS_RECONNECT_PERIOD)) + socket.sleep(const.WS_RECONNECT_PERIOD) + end +end + +--- functionto start the websocket connection +--- @return boolean boolean +function HLWebsocket:start() + local dni = self.device.device_network_id + local ip = self.device:get_field(const.IP) + if not ip then + log.error(string.format("Failed to start %s websocket connection, no ip address for device", dni)) + return false + end + + log.info(string.format("%s starting websocket client on %s", dni, ip)) + local sock, err = socket.tcp() + if not sock or err ~= nil then + log.error(string.format("%s Could not open TCP socket: %s", dni, err)) + return false + end + + local _ + _, err = sock:settimeout(const.WS_SOCKET_TIMEOUT) + if err ~= nil then + log.warn(string.format("%s Socket set timeout error: %s", dni, err)) + return false + end + + local config = Config.default():keep_alive(const.WS_IDLE_PING_PERIOD) + local websocket = ws.client(sock, "/", config) + _, err = websocket:connect(ip, const.WS_PORT) + if err then + log.error(string.format("%s failed to connect websocket: %s", dni, err)) + self.device:offline() + return false + end + + log.info(string.format("%s Connected websocket successfully", dni)) + self._stopped = false + self.websocket = websocket + self.device:online() + + log.trace(string.format("%s Refreshing all values after successful WebSocket connection", dni)) + self.driver:inject_capability_command(self.device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {}, + }) + + log.trace(string.format("%s Started websocket listener", dni)) + cosock.spawn(self:listener(), string.format("%s listener", dni)) + + return true +end + +--- creates a Harman Luxury websocket object for the device +---@param driver any +---@param device any +---@return HLWebsocket +function HLWebsocket.create_device_websocket(driver, device) + return setmetatable({ + device = device, + driver = driver, + _stopped = true, + }, HLWebsocket) +end + +--- stops webclient +function HLWebsocket:stop() + local dni = self.device.device_network_id + self.device:offline() + self._stopped = true + if not self.websocket then + log.warn(string.format("%s no websocket exists to close", dni)) + return + end + local suc, err = self.websocket:close(CloseCode.normal()) + if not suc then + log.error(string.format("%s failed to close websocket: %s", dni, err)) + end +end + +--- tests if the websocket connection is stopped or not +--- @return boolean isStopped +function HLWebsocket:is_stopped() + return self._stopped +end + +return HLWebsocket diff --git a/drivers/SmartThings/harman-luxury/src/init.lua b/drivers/SmartThings/harman-luxury/src/init.lua index d2519ef342..2bc354a1ca 100644 --- a/drivers/SmartThings/harman-luxury/src/init.lua +++ b/drivers/SmartThings/harman-luxury/src/init.lua @@ -4,12 +4,14 @@ -- SmartThings inclusions local Driver = require "st.driver" local capabilities = require "st.capabilities" -local st_utils = require "st.utils" +local json = require "st.json" local log = require "log" +local socket = require "cosock.socket" +local cosock = require "cosock" -- local Harman Luxury inclusions local discovery = require "disco" -local handlers = require "handlers" +local hlws = require "hl_websocket" local api = require "api.apis" local const = require "constants" @@ -17,310 +19,113 @@ local const = require "constants" -- Device Functions ---------------------------------------------------------- -local function stop_check_for_updates_thread(device) - local current_timer = device:get_field(const.UPDATE_TIMER) - if current_timer ~= nil then - log.info(string.format("create_check_for_updates_thread: dni=%s, remove old timer", device.device_network_id)) - device.thread:cancel_timer(current_timer) - end -end - -local function device_removed(_, device) - log.info("Device removed") - -- cancel timers - stop_check_for_updates_thread(device) -end +--- handler that builds the JSON to send to the device through the WebSocket +---@param device any +---@param cmd any +local function message_sender(_, device, cmd) + local msg, value = {}, {} + local device_ws = device:get_field(const.WEBSOCKET) + local token = device:get_field(const.CREDENTIAL) -local function goOffline(device) - stop_check_for_updates_thread(device) - if device:get_field(const.STATUS) then - device:emit_event(capabilities.switch.switch.off()) - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - device:emit_event(capabilities.audioTrackData.audioTrackData({ - title = "", - })) - end - device:set_field(const.STATUS, false, { - persist = true, - }) - device:offline() -end + value[const.CAPABILITY] = cmd.capability or nil + value[const.COMMAND] = cmd.command or nil + value[const.ARG] = cmd.args or nil + msg[const.MESSAGE] = value + msg[const.CREDENTIAL] = token -local function refresh(_, device) - local ip = device:get_field(const.IP) + msg = json.encode(msg) - -- check and update device status - local power_state - power_state, _ = api.GetPowerState(ip) - if power_state then - log.debug(string.format("Current power state: %s", power_state)) - - if power_state == "online" then - device:emit_event(capabilities.switch.switch.on()) - local player_state, audioTrackData - - -- get player state - player_state, _ = api.GetPlayerState(ip) - if player_state then - if player_state == "playing" then - device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) - elseif player_state == "paused" then - device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) - else - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - end - end - - -- get audio track data - audioTrackData, _ = api.getAudioTrackData(ip) - if audioTrackData then - device:emit_event(capabilities.audioTrackData.audioTrackData(audioTrackData.trackdata)) - device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands( - audioTrackData.supportedPlaybackCommands)) - device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( - audioTrackData.supportedTrackControlCommands)) - device:emit_event(capabilities.audioTrackData.totalTime(audioTrackData.totalTime or 0)) - end - elseif device:get_field(const.STATUS) then - device:emit_event(capabilities.switch.switch.off()) - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - end - end - - -- get media presets list - local presets - presets, _ = api.GetMediaPresets(ip) - if presets then - device:emit_event(capabilities.mediaPresets.presets(presets)) - end + device_ws:send_msg_handler(msg) +end - -- check and update device volume and mute status - local vol, mute - vol, _ = api.GetVol(ip) - if vol then - device:emit_event(capabilities.audioVolume.volume(vol)) - end - mute, _ = api.GetMute(ip) - if type(mute) == "boolean" then - if mute then - device:emit_event(capabilities.audioMute.mute.muted()) - else - device:emit_event(capabilities.audioMute.mute.unmuted()) +--- ensure used presetId is really the Preset ID. +-- When a user sets a preset selection from routines they can insert a custom string. +-- Here we try to guess if the string matches any of the existing presets +local function do_play_preset(_, device, cmd) + log.info(string.format("Starting do_play_preset: %s", device.label)) + -- send API to play media preset + local presetId = cmd.args.presetId:lower():gsub("preset", ""):gsub("%W", "") + local mediaPresets = device:get_latest_state("main", capabilities.mediaPresets.ID, + capabilities.mediaPresets.presets.NAME) + for _, preset in pairs(mediaPresets) do + local id = preset.id + local name = preset.name:lower():gsub("preset", ""):gsub("%W", "") + if id == presetId or name == presetId then + cmd.args.presetId = id + message_sender(_, device, cmd) + return end end - - -- check and update device media input source - local inputSource - inputSource, _ = api.GetInputSource(ip) - if inputSource then - device:emit_event(capabilities.mediaInputSource.inputSource(inputSource)) - end + log.warn(string.format("Couldn't find provided Media Preset: %s", cmd.args.presetId)) end -local function check_for_updates(device) - log.trace(string.format("%s, checking if device values changed", device.device_network_id)) - local ip = device:get_field(const.IP) - local changes, err = api.InvokeGetUpdates(ip) - -- check if changes is empty - if not err then - log.debug(string.format("changes: %s", st_utils.stringify_table(changes))) - if type(changes) ~= "table" then - log.warn("check_for_updates: Received value was not a table (JSON). Likely an error occured") - return - end - -- check if there are any changes - local next = next - if next(changes) ~= nil then - -- check for a power state change - if changes["powerState"] then - local powerState = changes["powerState"] - if powerState == "online" then - device:emit_event(capabilities.switch.switch.on()) - elseif powerState == "offline" then - device:emit_event(capabilities.switch.switch.off()) - end - end - -- check for a player state change - if changes["playerState"] then - local playerState = changes["playerState"] - if playerState == "playing" then - device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) - elseif playerState == "paused" then - log.debug("playerState - changed to paused") - device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) - else - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - end - end - -- check for a audio track data change - if changes["audioTrackData"] then - local audioTrackData = changes["audioTrackData"] - local trackdata = {} - if type(audioTrackData.title) == "string" then - trackdata.title = audioTrackData.title - else - trackdata.title = "" - end - if type(audioTrackData.artist) == "string" then - trackdata.artist = audioTrackData.artist - end - if type(audioTrackData.album) == "string" then - trackdata.album = audioTrackData.album - end - if type(audioTrackData.albumArtUrl) == "string" then - trackdata.albumArtUrl = audioTrackData.albumArtUrl - end - if type(audioTrackData.mediaSource) == "string" then - trackdata.mediaSource = audioTrackData.mediaSource - end - -- if track changed - device:emit_event(capabilities.audioTrackData.audioTrackData(trackdata)) +--- checks the health of the websocket connection and triggers the device to send all current values +local function do_refresh(_, device, cmd) + log.info(string.format("Starting do_refresh: %s", device.label)) - device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands( - audioTrackData.supportedPlaybackCommands) or {"play", "stop", "pause"}) - device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( - audioTrackData.supportedTrackControlCommands) or {"nextTrack", "previousTrack"}) - device:emit_event(capabilities.audioTrackData.totalTime(audioTrackData.totalTime or 0)) - end - -- check for a audio track data change - if changes["elapsedTime"] then - device:emit_event(capabilities.audioTrackData.elapsedTime(changes["elapsedTime"])) - end - -- check for a media presets change - if changes["mediaPresets"] and type(changes["mediaPresets"].presets) == "table" then - device:emit_event(capabilities.mediaPresets.presets(changes["mediaPresets"].presets)) - end - -- check for a media input source change - if changes["mediaInputSource"] then - device:emit_event(capabilities.mediaInputSource.inputSource(changes["mediaInputSource"])) - end - -- check for a volume value change - if changes["volume"] then - device:emit_event(capabilities.audioVolume.volume(changes["volume"])) - end - -- check for a mute value change - if changes["mute"] ~= nil then - if changes["mute"] then - device:emit_event(capabilities.audioMute.mute.muted()) - else - device:emit_event(capabilities.audioMute.mute.unmuted()) - end + -- restart websocket if needed + local device_ws = device:get_field(const.WEBSOCKET) + if device_ws then + if device_ws.websocket == nil then + device.log.info("Trying to restart websocket client for device updates") + device_ws:stop() + socket.sleep(1) -- give time for Lustre to close the websocket + if not device_ws:start() then + log.warn(string.format("%s failed to restart listening websocket client for device updates", + device.device_network_id)) + return end end + message_sender(_, device, cmd) end end -local function create_check_for_updates_thread(device) - -- stop old timer if one exists - stop_check_for_updates_thread(device) - - log.info(string.format("create_check_for_updates_thread: dni=%s", device.device_network_id)) - local new_timer = device.thread:call_on_schedule(const.UPDATE_INTERVAL, function() - check_for_updates(device) - end, "value_updates_timer") - device:set_field(const.UPDATE_TIMER, new_timer) -end - local function device_init(driver, device) log.info(string.format("Initiating device: %s", device.label)) - local device_ip = device:get_field(const.IP) + if device:get_field(const.INITIALISED) then + log.info(string.format("device_init : already initialized. dni = %s", device.device_network_id)) + return + end + local device_dni = device.device_network_id if driver.datastore.discovery_cache[device_dni] then - log.warn("set unsaved device field") + log.info("set unsaved device field") discovery.set_device_field(driver, device) end - -- set supported default media playback commands - device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands( - {capabilities.mediaPlayback.commands.play.NAME, capabilities.mediaPlayback.commands.pause.NAME, - capabilities.mediaPlayback.commands.stop.NAME})) - device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( - {capabilities.mediaTrackControl.commands.nextTrack.NAME, - capabilities.mediaTrackControl.commands.previousTrack.NAME})) - - -- set supported input sources - local supportedInputSources, _ = api.GetSupportedInputSources(device_ip) - device:emit_event(capabilities.mediaInputSource.supportedInputSources(supportedInputSources)) - - -- set supported keypad inputs - device:emit_event(capabilities.keypadInput.supportedKeyCodes( - {"UP", "DOWN", "LEFT", "RIGHT", "SELECT", "BACK", "EXIT", "MENU", "SETTINGS", "HOME", "NUMBER0", - "NUMBER1", "NUMBER2", "NUMBER3", "NUMBER4", "NUMBER5", "NUMBER6", "NUMBER7", "NUMBER8", - "NUMBER9"})) - - log.trace(string.format("device IP: %s", device_ip)) - - create_check_for_updates_thread(device) - - refresh(driver, device) -end - -local function update_connection(driver) - log.debug("Entered update_connection()...") - -- only test connections if there are registered devices - local devices = driver:get_devices() - if next(devices) ~= nil then - local devices_ip_table = discovery.find_ip_table() - for _, device in ipairs(devices) do - local device_dni = device.device_network_id - local device_ip = device:get_field(const.IP) - local current_ip = devices_ip_table[device_dni] - -- check if this device's dni appeared in the scan - if current_ip then - -- update IP associated to this device if changed - if current_ip ~= device_ip then - log.warn(string.format("Harman Luxury Driver updated %s IP to %s", device_dni, current_ip)) - device:set_field(const.IP, current_ip, { - persist = true, - }) - end - -- set device online if credentials still match and update device IP if it changed - local active_token, err = api.GetCredentialsToken(current_ip) - if active_token then - local device_token = device:get_field(const.CREDENTIAL) - if active_token == device_token then - -- if device is going back online after being offline we want to also reinitialize the device - local state = device:get_field(const.STATUS) - if state == false then - device_init(driver, device) - end - device:set_field(const.STATUS, true, { - persist = true, - }) - device:online() - else - log.warn(string.format("device with dni: %s no longer holds the credential token", device_dni)) - goOffline(device) - end - else - log.warn(string.format( - "device with dni: %s had issues while trying to read credentail token. Error message: %s", - device_dni, err)) - goOffline(device) - end + -- start websocket + cosock.spawn(function() + while true do + local device_ws = hlws.create_device_websocket(driver, device) + device:set_field(const.WEBSOCKET, device_ws) + if device_ws:start() then + log.info(string.format("%s successfully connected to websocket", device_dni)) + device:set_field(const.INITIALISED, true, { + persist = false, + }) + break else - -- set device offline if not detected - log.warn(string.format( - "Harman Luxury Driver set %s offline as it didn't appear on latest update connections scan", - device_dni)) - goOffline(device) + log.info(string.format("%s failed to connect to websocket. Trying again in %d seconds", device_dni, + const.WS_RECONNECT_PERIOD)) end + socket.sleep(const.WS_RECONNECT_PERIOD) end - end + end, string.format("%s device_init", device_dni)) end -local function device_added(driver, device) - log.info(string.format("Device added: %s", device.label)) - discovery.set_device_field(driver, device) +local function device_removed(_, device) local device_dni = device.device_network_id - discovery.joined_device[device_dni] = nil - -- ensuring device is initialised - device_init(driver, device) + log.info(string.format("Device removed - dni=\"%s\"", device_dni)) + -- close websocket + local device_ws = device:get_field(const.WEBSOCKET) + if device_ws then + device_ws:stop() + end end local function device_changeInfo(_, device, _, _) - log.info(string.format("Device added: %s", device.label)) + log.info(string.format("Device changed info: %s", device.label)) local ip = device:get_field(const.IP) local _, err = api.SetDeviceName(ip, device.label) if err then @@ -329,13 +134,6 @@ local function device_changeInfo(_, device, _, _) end end -local function do_refresh(driver, device, _) - log.info(string.format("Starting do_refresh: %s", device.label)) - - -- check and update device values - refresh(driver, device) -end - ---------------------------------------------------------- -- Driver Definition ---------------------------------------------------------- @@ -344,8 +142,8 @@ end local driver = Driver("Harman Luxury", { discovery = discovery.discovery_handler, lifecycle_handlers = { + added = discovery.device_added, init = device_init, - added = device_added, removed = device_removed, infoChanged = device_changeInfo, }, @@ -354,41 +152,38 @@ local driver = Driver("Harman Luxury", { [capabilities.refresh.commands.refresh.NAME] = do_refresh, }, [capabilities.switch.ID] = { - [capabilities.switch.commands.on.NAME] = handlers.handle_on, - [capabilities.switch.commands.off.NAME] = handlers.handle_off, + [capabilities.switch.commands.on.NAME] = message_sender, + [capabilities.switch.commands.off.NAME] = message_sender, }, [capabilities.audioMute.ID] = { - [capabilities.audioMute.commands.mute.NAME] = handlers.handle_mute, - [capabilities.audioMute.commands.unmute.NAME] = handlers.handle_unmute, - [capabilities.audioMute.commands.setMute.NAME] = handlers.handle_set_mute, + [capabilities.audioMute.commands.mute.NAME] = message_sender, + [capabilities.audioMute.commands.unmute.NAME] = message_sender, + [capabilities.audioMute.commands.setMute.NAME] = message_sender, }, [capabilities.audioVolume.ID] = { - [capabilities.audioVolume.commands.volumeUp.NAME] = handlers.handle_volume_up, - [capabilities.audioVolume.commands.volumeDown.NAME] = handlers.handle_volume_down, - [capabilities.audioVolume.commands.setVolume.NAME] = handlers.handle_set_volume, + [capabilities.audioVolume.commands.volumeUp.NAME] = message_sender, + [capabilities.audioVolume.commands.volumeDown.NAME] = message_sender, + [capabilities.audioVolume.commands.setVolume.NAME] = message_sender, }, [capabilities.mediaInputSource.ID] = { - [capabilities.mediaInputSource.commands.setInputSource.NAME] = handlers.handle_setInputSource, + [capabilities.mediaInputSource.commands.setInputSource.NAME] = message_sender, }, [capabilities.mediaPresets.ID] = { - [capabilities.mediaPresets.commands.playPreset.NAME] = handlers.handle_play_preset, + [capabilities.mediaPresets.commands.playPreset.NAME] = do_play_preset, }, [capabilities.audioNotification.ID] = { - [capabilities.audioNotification.commands.playTrack.NAME] = handlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndResume.NAME] = handlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndRestore.NAME] = handlers.handle_audio_notification, + [capabilities.audioNotification.commands.playTrack.NAME] = message_sender, + [capabilities.audioNotification.commands.playTrackAndResume.NAME] = message_sender, + [capabilities.audioNotification.commands.playTrackAndRestore.NAME] = message_sender, }, [capabilities.mediaPlayback.ID] = { - [capabilities.mediaPlayback.commands.pause.NAME] = handlers.handle_pause, - [capabilities.mediaPlayback.commands.play.NAME] = handlers.handle_play, - [capabilities.mediaPlayback.commands.stop.NAME] = handlers.handle_stop, + [capabilities.mediaPlayback.commands.pause.NAME] = message_sender, + [capabilities.mediaPlayback.commands.play.NAME] = message_sender, + [capabilities.mediaPlayback.commands.stop.NAME] = message_sender, }, [capabilities.mediaTrackControl.ID] = { - [capabilities.mediaTrackControl.commands.nextTrack.NAME] = handlers.handle_next_track, - [capabilities.mediaTrackControl.commands.previousTrack.NAME] = handlers.handle_previous_track, - }, - [capabilities.keypadInput.ID] = { - [capabilities.keypadInput.commands.sendKey.NAME] = handlers.handle_send_key, + [capabilities.mediaTrackControl.commands.nextTrack.NAME] = message_sender, + [capabilities.mediaTrackControl.commands.previousTrack.NAME] = message_sender, }, }, supported_capabilities = {capabilities.switch, capabilities.audioMute, capabilities.audioVolume, @@ -396,17 +191,6 @@ local driver = Driver("Harman Luxury", { capabilities.mediaTrackControl, capabilities.refresh}, }) ----------------------------------------------------------- --- Driver Routines ----------------------------------------------------------- - --- create driver IP update routine - -log.info("create health_check_timer for Harman Luxury devices") -driver:call_on_schedule(const.HEALTH_CHEACK_INTERVAL, function() - update_connection(driver) -end, const.HEALTH_TIMER) - ---------------------------------------------------------- -- main ---------------------------------------------------------- diff --git a/drivers/SmartThings/harman-luxury/src/lustre/ws.lua b/drivers/SmartThings/harman-luxury/src/lustre/ws.lua new file mode 100644 index 0000000000..e0b0cfa608 --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/lustre/ws.lua @@ -0,0 +1,655 @@ +local cosock = require"cosock" +local socket = require"cosock.socket" +local Request = require"luncheon.request" +local Response = require"luncheon.response" +local send_utils = require"luncheon.utils" +local Handshake = require"lustre.handshake" +local Key = require"lustre.handshake.key" +local Config = require"lustre.config" +local Frame = require"lustre.frame" +local FrameHeader = + require"lustre.frame.frame_header" +local OpCode = require"lustre.frame.opcode" +local CloseCode = + require"lustre.frame.close".CloseCode +local Message = require"lustre.message" +local log = require"log" +-- disable debugging logs +log.debug = function(...) end +log.trace = function(...) end + +local utils = require"lustre.utils" + +---@class WebSocket +--- +---@field public id number|string +---@field public url string the endpoint to hit +---@field public socket table lua socket +---@field public handshake_key string key used in the websocket handshake +---@field public config Config +---@field private handshake Handshake +---@field private _send_tx table +---@field private _send_rx table +---@field private _recv_tx table +---@field private _recv_rx table +---@field private is_client boolean +local WebSocket = {} +WebSocket.__index = WebSocket + +---Create new client object +---@param socket table connected tcp socket +---@param url string url to connect +---@param config Config +---@return WebSocket +---@return string|nil +function WebSocket.client(socket, url, config) + local _send_tx, _send_rx = cosock.channel.new() + local _recv_tx, _recv_rx = cosock.channel.new() + local config = config or Config.default() + local ret = setmetatable( + { + is_client = true, + socket = socket, + url = url or "/", + handshake = Handshake.client(nil, config._protocols, config._extensions, config._extra_headers), + config = config, + _send_tx = _send_tx, + _send_rx = _send_rx, + _recv_tx = _recv_tx, + _recv_rx = _recv_rx, + id = math.random(), + state = "Active", + }, WebSocket) + return ret +end + +---Create a server side websocket (NOT YET IMPLEMENTED) +---@param socket table the cosock.tcp socket to use +---@param config Config The websocket configuration +---@return WebSocket +---@return string|nil @If an error occurs, returns the error message +function WebSocket.server(socket, config) + return nil, "Not yet implemented" +end + +---Receive the next message from this websocket +---@return Message +---@return string|nil +function WebSocket:receive() + log.trace("WebSocket:receive") + self._waker = nil + local result = self._recv_rx:receive() + if result.err then + return nil, result.err + end + return result.msg +end + +---@param text string +---@return number, string|nil +function WebSocket:send_text(text) + if self.state ~= "Active" then + return nil, "closed" + end + local valid_utf8, utf8_err = + utils.validate_utf8(text) + if not valid_utf8 then + return nil, utf8_err + end + return self:send(Message.new("text", text)) +end + +---@param bytes string +---@return number +---@return number, string|nil +function WebSocket:send_bytes(bytes) + return self:send(Message.new("binary", bytes)) +end + +-- TODO remove the fragmentation code duplication in the `send_text` and `send_bytes` apis +-- TODO Could perhaps remove those apis entirely. +---@param message Message +---@return number, string|nil +function WebSocket:send(message) + log.trace("WebSocket:send", message.type) + local data_idx = 1 + local frames_sent = 0 + if self.state ~= "Active" then + return nil, "closed" + end + local opcode + if message.type == "text" then + opcode = OpCode.text() + else + opcode = OpCode.binary() + end + repeat + log.trace("send fragment top") + local header = FrameHeader.default() + local payload = "" + if (message.data:len() - data_idx + 1) + > self.config._max_frame_size then + header:set_fin(false) + end + payload = string.sub(message.data, data_idx, + data_idx + self.config._max_frame_size) + if data_idx ~= 1 then + header:set_opcode(OpCode.continue()) + else + header:set_opcode(opcode) + end + header:set_length(#payload) + local frame = + Frame.from_parts(header, payload) + frame:set_mask() -- todo handle client vs server + local tx, rx = cosock.channel.new() + local suc, err = self._send_tx:send( + {frame = frame, reply = tx}) + if err then + log.error("channel send error:", err) + end + local result = rx:receive() + if result.err then + return nil, result.err + end + data_idx = data_idx + frame:payload_len() + frames_sent = frames_sent + 1 + until message.data:len() <= data_idx + return 1 +end + +---@return number, string|nil +function WebSocket:client_handshake_and_start(host, port) + if not self.is_client then -- todo use metatables to enforce this + log.error(self.id, "Invalid client websocket") + return nil, "only a client can connect" + end + -- Do handshake + log.debug(self.id, "sending handshake") + local success, err = self.handshake:send( + self.socket, self.url, + string.format("%s:%d", host, port)) + log.debug(self.id, "handshake complete", + success or err) + if not success then + return nil, "invalid handshake: " .. err + end + cosock.spawn(function() + self:_receive_loop() + end, "Client receive loop") + return 1 +end + +---@return number, string|nil +function WebSocket:connect(host, port) + log.trace(self.id, "WebSocket:connect", host, + port) + if not self.is_client then -- todo use metatables to enforce this + log.error(self.id, "Invalid client websocket") + return nil, "only a client can connect" + end + if not host or not port then + return nil, "missing host or port" + end + log.debug(self.id, "calling socket.connect") + local r, err = self.socket:connect(host, port) + log.debug(self.id, "Socket connect completed", + r or err) + if not r then + return nil, "socket connect failure: " .. err + end + + return self:client_handshake_and_start(host, port) +end + +function WebSocket:accept() + return nil, "Not yet implemented" +end + +---@param close_code CloseCode +---@param reason string +---@return number 1 if success +---@return string|nil +function WebSocket:close(close_code, reason) + log.debug("sending close message", + close_code.type or close_code.value, reason) + if self.state == "Active" then + local close_frame = Frame.close(close_code, + reason):set_mask() -- TODO client vs server + local tx, reply = cosock.channel.new() + reply:settimeout(0.5) + log.debug("sending frame to socket task") + local suc, err = self._send_tx:send( + {frame = close_frame, reply = tx}) + log.debug("sent frame to socket task", suc, + err) + if not suc then + return nil, "channel error:" .. err + end + log.debug("waiting on reply") + local reply = reply:receive() + log.debug("reply received") + return reply + elseif self.state == "ClosedBySelf" then + self.state = "CloseAcknowledged" + end + + return 1, log.debug("closed websocket") +end + +---Cosock internal interface for using `cosock.socket.select` +---@param kind string +---@param waker fun() +function WebSocket:setwaker(kind, waker) + assert(kind == "recvr", + "unsupported wake kind: " .. tostring(kind)) + assert(self._waker == nil or waker == nil, + "waker already set, receive can't be waited on from multiple places at once") + self._waker = waker + + -- if messages waiting, immediately wake + if #self._recv_tx.link.queue > 0 and waker then + waker() + end +end + +---Spawn the receive loop +---@return string|nil +function WebSocket:_receive_loop() + log.trace(self.id, "starting receive loop") + local loop_state = { + partial_frames = nil, + received_bytes = 0, + frames_since_last_ping = 0, + pending_pongs = 0, + multiframe_message = false, + utf8_check_backward_idx = 0, + msg_type = nil, + } + local order = false + while self.state ~= "CloseAcknowledged" + and self.state ~= "Terminated" do + log.trace(self.id, "loop top") + local rs = (order + and {self._send_rx, self.socket}) + or {self.socket, self._send_rx} + order = not order + local recv, _, err = socket.select(rs, nil, + self.config._keep_alive) + log.debug((recv and "recv") or "~recv", + err or "") + if not recv then + if self:_handle_select_err(loop_state, err) then + return + end + elseif self:_handle_recvs(loop_state, recv, 1) then + break + end + end + log.debug("Closing socket") + self.socket:close() + log.debug("Closing channel") + self._send_rx:close() + log.debug("Channel closed") +end + +function WebSocket:_handle_recvs(state, recv, idx) + log.trace(self.id, "_handle_recvs") + if recv[idx] == self.socket then + return self:_handle_recv_ready(state) and 1 + end + if recv[idx] == self._send_rx then -- frames we need to send on the socket + return self:_handle_send_ready() + end +end + +function WebSocket:_handle_select_err(state, err) + log.debug(self.id, "selected err:", err) + if err == "timeout" then + if state.pending_pongs >= 2 then -- TODO max number of pings without a pong could be configurable + self._recv_tx:send({ + err = "no response to ping", + }) + self.state = "Terminated" + log.debug("Closing socket") + self.socket:close() + return 1 + end + local fm = Frame.ping():set_mask() + local sent_bytes, err = + send_utils.send_all(self.socket, fm:encode()) + if not err then + log.debug(self.id, "SENT FRAME: \n%s\n\n") + state.pending_pongs = + state.pending_pongs + 1 + else + self._recv_rx:send({err = err}) + self.state = "Terminated" + log.debug("Closing socket") + self.socket:close() + return 1 + end + end +end + +function WebSocket:_handle_recv_ready(state) + log.debug(self.id, "selected socket") + local frame, err = + Frame.from_stream(self.socket) + log.debug(self.id, "build frame", frame or err) + if not frame then + log.debug("error building frame", err) + if err == "invalid opcode" or err + == "invalid rsv bit" then + log.warn(self.id, + "PROTOCOL ERR: received frame with " .. err) + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol()), + }) + elseif err == "timeout" then + -- TODO retry receiving the frame, give partially received frame + self._recv_tx:send({err = err}) + if self._waker then + self._waker() + end + elseif err == "closed" then + log.debug("socket was closed", self.state) + if self.state == "Active" or self.state + == "ClosedBySelf" then + self._recv_tx:send({err = err}) + if self._waker then + self._waker() + end + self.state = "Terminated" + end + return 1 + else + self._recv_tx:send({err = err}) + if self._waker then + self._waker() + end + end + return + end + log.debug(self.id, + string.format("RECEIVED FRAME %s %s", + frame.header.opcode.type, + frame.header.opcode.sub)) + if frame:is_control() then + return self:_handle_recv_control_frame(frame, + state) + end + + -- Should we close because we have been waiting to long for a ping? + -- We might not need to do this, because it wasn't prioritized + -- with a test case in autobahn + if state.pending_pongs > 0 then + state.frames_since_last_ping = + state.frames_since_last_ping + 1 + if state.frames_since_last_ping + > self.config._max_frames_without_pong then + state.frames_since_last_ping = 0 + log.trace(self.id, + "PROTOCOL ERR: received too many frames while waiting for pong") + self._send_tx:send({ + frame = Frame.close(CloseCode.policy(), + "no pong after ping"), + }) + return + end + end + + -- handle fragmentation + if state.multiframe_message then + if frame.header.opcode.sub ~= "continue" then + log.warn("Expected continue frame found ", + frame.header.opcode.sub) + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol(), + "unexpected continue frame"), + }) + return + end + if state.msg_type == "text" then + if self:_handle_recv_text_frame(frame, state) then + return + end + end + elseif frame.header.opcode.sub == "continue" then + log.warn("Unexpected continue frame") + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol(), + "unexpected continue frame"), + }) + return + else + if frame.header.opcode.sub == "text" then + if self:_handle_recv_text_frame(frame, state) then + return + end + end + state.msg_type = frame.header.opcode.sub + state.multiframe_message = + not frame:is_final() + end + -- aggregate payloads + if not frame:is_final() then + state.received_bytes = + state.received_bytes + frame:payload_len() + -- TODO what should happen if we get message that is too big for the library? + -- We are currently truncating the message. + if state.received_bytes + <= self.config.max_message_size then + state.partial_frames = + (state.partial_frames or "") + .. frame.payload + else + log.warn(self.id, + "truncating message thats bigger than max config size") + end + return + else + state.multiframe_message = false + end + + -- coalesce frame payloads into single message payload + local full_payload = frame.payload + if state.partial_frames then + full_payload = state.partial_frames + .. frame.payload + state.partial_frames = nil + end + if state.msg_type == "text" then + log.debug("checking for valid utf8") + local valid_utf8, utf8_err = + utils.validate_utf8(full_payload) + log.trace("valid?", not not valid_utf8, + utf8_err) + if not valid_utf8 then + log.warn( + "Received invalid utf8 text message, closing", + utf8_err) + send_utils.send_all(self.socket, + Frame.close(CloseCode.protocol(), utf8_err):encode()) + self.socket:close() + self.state = "Terminated" + self._recv_tx:send({err = "closed"}) + if self._waker then + self._waker() + end + return + end + end + self._recv_tx:send({ + msg = Message.new(state.msg_type, full_payload), + }) + if self._waker then + self._waker() + end +end + +--- +---@param frame Frame +---@param state table +function WebSocket:_handle_recv_text_frame(frame, + state) + log.debug("checking for valid utf8") + local valid_utf8, utf8_err, err_idx = + utils.validate_utf8((state.partial_utf8_bytes + or "") .. frame.payload) + log.trace("valid?", not not valid_utf8, + utf8_err, err_idx) + if not valid_utf8 then + if utf8_err == "Invalid UTF-8 too short" then + state.partial_utf8_bytes = + ((state.partial_frames or "") + .. frame.payload):sub(err_idx) + log.debug( + "utf8 too short updated partial_utf8_bytes", + state.partial_utf8_bytes) + if not frame:is_final() then + return + end + else + state.partial_utf8_bytes = "" + end + + log.warn( + "Received invalid utf8 text frame, closing", + utf8_err) + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol(), + utf8_err), + }) + self._recv_tx:send({err = "closed"}) + self.state = "Terminated" + self.socket:close() + return + else + state.partial_utf8_bytes = "" + end +end + +function WebSocket:_handle_recv_control_frame( + frame, state) + if not frame:is_final() then + log.trace(self.id, + "PROTOCOL ERR: received non final control frame") + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol()), + }) + return + end + local control_type = frame.header.opcode.sub + if frame:payload_len() + > Frame.MAX_CONTROL_FRAME_LENGTH then + log.trace(self.id, + "PROTOCOL ERR: received control frame that is too big") + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol()), + }) + return + end + if control_type == "ping" then + local fm = + Frame.pong(frame.payload):set_mask() + local sent_bytes, err = + send_utils.send_all(self.socket, fm:encode()) + if not sent_bytes then + self._recv_tx:send({ + err = "failed to send pong in response to ping: " + .. err, + }) + end + return + elseif control_type == "pong" then + state.pending_pongs = + math.max(state.pending_pongs - 1, 0) -- TODO this functionality is not tested by the test framework + state.frames_since_last_ping = 0 + elseif control_type == "close" then + self._send_tx:send({ + frame = Frame.close( + CloseCode.decode(frame.payload)), + }) + end +end + +function WebSocket:_handle_send_ready() + log.debug(self.id, "selected channel") + local event, err = self._send_rx:receive() + log.debug("received from rx") + if not event then + log.error( + "error receiving event from _send_rx", err) + return + end + ---@type Frame, cosock.channel + local frame, reply = event.frame, event.reply + log.debug("encoding frame: ", + frame.header.opcode.type, + frame.header.opcode.sub) + local bytes = frame:encode() + log.debug("sending all bytes") + local sent_bytes, err = + send_utils.send_all(self.socket, bytes) + log.debug("sent bytes") + if not sent_bytes then + local closed = err:match("close") + if closed and self.state == "Active" then + log.debug("closed error", err) + if reply and reply.send then + reply:send({err = err}) + else + log.error(string.format("No reply channel in event for progating error: %s", err)) + end + end + if not closed then + if reply and reply.send then + reply:send({err = err}) + else + log.error(string.format("No reply channel in event for progating error: %s", err)) + end + end + return + end + log.debug(self.id, "SENT FRAME") + local ret + + if frame:is_close() then + return self:_handle_sent_close_frame() + end + if reply then + reply:send({ok = 1}) + end +end + +function WebSocket:_handle_sent_close_frame() + if self.state == "Active" then + self.state = "ClosedBySelf" + end + if self.state == "ClosedByPeer" then + self.state = "CloseAcknowledged" + self.socket:close() + if self._waker then + self._waker() + end + return 1 + end +end + +function WebSocket:_handle_recvd_close_frame() + if self.state == "Active" then + self.state = "ClosedByPeer" + end + if self.state == "ClosedBySelf" then + self.state = "CloseAcknowledged" + self.socket:close() + self._recv_tx:send({err = "closed"}) + if self._waker then + self._waker() + end + return 1 + end +end + +return WebSocket \ No newline at end of file diff --git a/drivers/SmartThings/hub/config.yml b/drivers/SmartThings/hub/config.yml new file mode 100644 index 0000000000..72b4519bca --- /dev/null +++ b/drivers/SmartThings/hub/config.yml @@ -0,0 +1,4 @@ +name: 'hub' +packageKey: 'hub' +description: "SmartThings driver for Hub devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/hub/fingerprints.yml b/drivers/SmartThings/hub/fingerprints.yml new file mode 100644 index 0000000000..317df13f8b --- /dev/null +++ b/drivers/SmartThings/hub/fingerprints.yml @@ -0,0 +1,29 @@ +hub: + - id: "tv-hub" + deviceLabel: SmartThings Hub + hardwareType: SAMSUNG_VD_TV_HUB + deviceProfileName: tv-hub + - id: "soundbar-hub" + deviceLabel: SmartThings Hub + hardwareType: SAMSUNG_SOUNDBAR_TIZEN_OPEN_HUB + deviceProfileName: soundbar-hub + - id: "refrigerator-hub" + deviceLabel: SmartThings Hub + hardwareType: SAMSUNG_DA_REFRIGERATOR_HUB + deviceProfileName: refrigerator-hub + - id: "washer-dryer-hub" + deviceLabel: SmartThings Hub + hardwareType: SAMSUNG_WASHER_DRYER_TIZEN_OPEN + deviceProfileName: washer-dryer-hub + - id: "cooktop-hub" + deviceLabel: SmartThings Hub + hardwareType: SAMSUNG_COOKTOP_TIZEN_OPEN + deviceProfileName: cooktop-hub + - id: "v4-hub" + deviceLabel: SmartThings Hub + hardwareType: V4_HUB + deviceProfileName: v4-hub +hubThing: + - id: "hub-thing" + deviceLabel: SmartThings Hub + deviceProfileName: hub-thing diff --git a/drivers/SmartThings/hub/profiles/cooktop-hub.yml b/drivers/SmartThings/hub/profiles/cooktop-hub.yml new file mode 100644 index 0000000000..78c6f3dfc9 --- /dev/null +++ b/drivers/SmartThings/hub/profiles/cooktop-hub.yml @@ -0,0 +1,33 @@ +name: cooktop-hub +components: + - id: main + label: main + capabilities: + - id: bridge + version: 1 + ephemeral: false + categories: + - name: Hub + categoryType: manufacturer +deviceConfig: + icons: + - group: main + badge: + - iconUrl: 'badge://cooktop' + dashboard: + states: + - component: main + capability: bridge + version: 1 + dpInfo: + - os: ios + dpUri: 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html' + - os: android + dpUri: 'plugin://com.samsung.android.plugin.stplugin' + - os: web + dpUri: 'wwst://com.samsung.one.plugin.stplugin' + arguments: + - key: int_hub + value: 'wwst://com.samsung.one.plugin.chargerplugin' +metadata: + ocfDeviceType: x.com.st.d.hub diff --git a/drivers/SmartThings/hub/profiles/hub-thing.yml b/drivers/SmartThings/hub/profiles/hub-thing.yml new file mode 100644 index 0000000000..b2d62272ef --- /dev/null +++ b/drivers/SmartThings/hub/profiles/hub-thing.yml @@ -0,0 +1,29 @@ +name: hub-thing +components: + - id: main + label: main + capabilities: + - id: bridge + version: 1 + ephemeral: false + categories: + - name: Hub + categoryType: manufacturer +deviceConfig: + dashboard: + states: + - component: main + capability: bridge + version: 1 + dpInfo: + - os: ios + dpUri: 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html' + - os: android + dpUri: 'plugin://com.samsung.android.plugin.stplugin' + - os: web + dpUri: 'wwst://com.samsung.one.plugin.stplugin' + arguments: + - key: int_hub + value: 'wwst://com.samsung.one.plugin.chargerplugin' +metadata: + ocfDeviceType: x.com.st.d.hub diff --git a/drivers/SmartThings/hub/profiles/refrigerator-hub.yml b/drivers/SmartThings/hub/profiles/refrigerator-hub.yml new file mode 100644 index 0000000000..0034b10d09 --- /dev/null +++ b/drivers/SmartThings/hub/profiles/refrigerator-hub.yml @@ -0,0 +1,33 @@ +name: refrigerator-hub +components: + - id: main + label: main + capabilities: + - id: bridge + version: 1 + ephemeral: false + categories: + - name: Hub + categoryType: manufacturer +deviceConfig: + icons: + - group: main + badge: + - iconUrl: 'badge://refrigerator' + dashboard: + states: + - component: main + capability: bridge + version: 1 + dpInfo: + - os: ios + dpUri: 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html' + - os: android + dpUri: 'plugin://com.samsung.android.plugin.stplugin' + - os: web + dpUri: 'wwst://com.samsung.one.plugin.stplugin' + arguments: + - key: int_hub + value: 'wwst://com.samsung.one.plugin.chargerplugin' +metadata: + ocfDeviceType: x.com.st.d.hub diff --git a/drivers/SmartThings/hub/profiles/soundbar-hub.yml b/drivers/SmartThings/hub/profiles/soundbar-hub.yml new file mode 100644 index 0000000000..1003961f20 --- /dev/null +++ b/drivers/SmartThings/hub/profiles/soundbar-hub.yml @@ -0,0 +1,33 @@ +name: soundbar-hub +components: + - id: main + label: main + capabilities: + - id: bridge + version: 1 + ephemeral: false + categories: + - name: Hub + categoryType: manufacturer +deviceConfig: + icons: + - group: main + badge: + - iconUrl: 'badge://soundbar' + dashboard: + states: + - component: main + capability: bridge + version: 1 + dpInfo: + - os: ios + dpUri: 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html' + - os: android + dpUri: 'plugin://com.samsung.android.plugin.stplugin' + - os: web + dpUri: 'wwst://com.samsung.one.plugin.stplugin' + arguments: + - key: int_hub + value: 'wwst://com.samsung.one.plugin.chargerplugin' +metadata: + ocfDeviceType: x.com.st.d.hub diff --git a/drivers/SmartThings/hub/profiles/tv-hub.yml b/drivers/SmartThings/hub/profiles/tv-hub.yml new file mode 100644 index 0000000000..7a622da659 --- /dev/null +++ b/drivers/SmartThings/hub/profiles/tv-hub.yml @@ -0,0 +1,33 @@ +name: tv-hub +components: + - id: main + label: main + capabilities: + - id: bridge + version: 1 + ephemeral: false + categories: + - name: Hub + categoryType: manufacturer +deviceConfig: + icons: + - group: main + badge: + - iconUrl: 'badge://tv' + dashboard: + states: + - component: main + capability: bridge + version: 1 + dpInfo: + - os: ios + dpUri: 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html' + - os: android + dpUri: 'plugin://com.samsung.android.plugin.stplugin' + - os: web + dpUri: 'wwst://com.samsung.one.plugin.stplugin' + arguments: + - key: int_hub + value: 'wwst://com.samsung.one.plugin.chargerplugin' +metadata: + ocfDeviceType: x.com.st.d.hub diff --git a/drivers/SmartThings/hub/profiles/v4-hub.yml b/drivers/SmartThings/hub/profiles/v4-hub.yml new file mode 100644 index 0000000000..b1b882c8b6 --- /dev/null +++ b/drivers/SmartThings/hub/profiles/v4-hub.yml @@ -0,0 +1,43 @@ +name: v4-hub +components: + - id: main + label: main + capabilities: + - id: bridge + version: 1 + ephemeral: false + - id: wifiInformation + version: 1 + - id: sec.diagnosticsInformation + version: 1 + - id: sec.networkConfiguration + version: 1 + - id: firmwareUpdate + version: 1 + - id: samsungim.devicestatus + version: 1 + - id: sec.wifiConfiguration + version: 1 + - id: sec.appliedHubGroupMemberState + version: 1 + categories: + - name: Hub + categoryType: manufacturer +deviceConfig: + dashboard: + states: + - component: main + capability: bridge + version: 1 + dpInfo: + - os: ios + dpUri: 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html' + - os: android + dpUri: 'plugin://com.samsung.android.plugin.stplugin' + - os: web + dpUri: 'wwst://com.samsung.one.plugin.stplugin' + arguments: + - key: int_hub + value: 'wwst://com.samsung.one.plugin.chargerplugin' +metadata: + ocfDeviceType: x.com.st.d.hub diff --git a/drivers/SmartThings/hub/profiles/washer-dryer-hub.yml b/drivers/SmartThings/hub/profiles/washer-dryer-hub.yml new file mode 100644 index 0000000000..a9982c9f82 --- /dev/null +++ b/drivers/SmartThings/hub/profiles/washer-dryer-hub.yml @@ -0,0 +1,33 @@ +name: washer-dryer-hub +components: + - id: main + label: main + capabilities: + - id: bridge + version: 1 + ephemeral: false + categories: + - name: Hub + categoryType: manufacturer +deviceConfig: + icons: + - group: main + badge: + - iconUrl: 'badge://washer_dryer' + dashboard: + states: + - component: main + capability: bridge + version: 1 + dpInfo: + - os: ios + dpUri: 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html' + - os: android + dpUri: 'plugin://com.samsung.android.plugin.stplugin' + - os: web + dpUri: 'wwst://com.samsung.one.plugin.stplugin' + arguments: + - key: int_hub + value: 'wwst://com.samsung.one.plugin.chargerplugin' +metadata: + ocfDeviceType: x.com.st.d.hub diff --git a/drivers/SmartThings/hub/src/init.lua b/drivers/SmartThings/hub/src/init.lua new file mode 100644 index 0000000000..e69de29bb2 diff --git a/drivers/SmartThings/jbl/profiles/jbl.yml b/drivers/SmartThings/jbl/profiles/jbl.yml index c60823fed7..143c90634f 100644 --- a/drivers/SmartThings/jbl/profiles/jbl.yml +++ b/drivers/SmartThings/jbl/profiles/jbl.yml @@ -10,12 +10,10 @@ components: enabledValues: - 'playing' - 'paused' - - 'stopped' - key: "{{enumCommands}}" enabledValues: - 'play' - 'pause' - - 'stop' - id: mediaTrackControl version: 1 - id: audioMute diff --git a/drivers/SmartThings/jbl/src/init.lua b/drivers/SmartThings/jbl/src/init.lua index d707c27376..830a98a181 100644 --- a/drivers/SmartThings/jbl/src/init.lua +++ b/drivers/SmartThings/jbl/src/init.lua @@ -82,6 +82,11 @@ local function update_connection(driver, device, device_ip, device_info) local conn_info = driver.discovery_helper.get_connection_info(driver, device_dni, device_ip, device_info) local credential = device:get_field(fields.CREDENTIAL) + if not credential then + log.error("update_connection : failed to find credential, dni = " .. tostring(device_dni)) + device:offline() + return + end conn_info:add_header(CREDENTIAL_KEY_HEADER, credential) @@ -179,6 +184,16 @@ local function device_init(driver, device) refresh(driver, device, nil) device:set_field(fields._INIT, true, { persist = false }) + + device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands({ + capabilities.mediaPlayback.commands.play.NAME, + capabilities.mediaPlayback.commands.pause.NAME, + })) + + device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands({ + capabilities.mediaTrackControl.commands.nextTrack.NAME, + capabilities.mediaTrackControl.commands.previousTrack.NAME, + })) end local lan_driver = Driver("jbl", @@ -204,7 +219,6 @@ local lan_driver = Driver("jbl", [capabilities.mediaPlayback.ID] = { [capabilities.mediaPlayback.commands.play.NAME] = jbl_capability_handler.playback_play_handler, [capabilities.mediaPlayback.commands.pause.NAME] = jbl_capability_handler.playback_pause_handler, - [capabilities.mediaPlayback.commands.stop.NAME] = jbl_capability_handler.playback_stop_handler, }, [capabilities.audioNotification.ID] = { [capabilities.audioNotification.commands.playTrack.NAME] = jbl_capability_handler.audioNotification_handler, diff --git a/drivers/SmartThings/jbl/src/jbl/api.lua b/drivers/SmartThings/jbl/src/jbl/api.lua index 3cebabce55..7479289549 100644 --- a/drivers/SmartThings/jbl/src/jbl/api.lua +++ b/drivers/SmartThings/jbl/src/jbl/api.lua @@ -2,6 +2,7 @@ local log = require "log" local json = require "st.json" local RestClient = require "lunchbox.rest" local utils = require "utils" +local st_utils = require "st.utils" local cosock = require "cosock" local jbl_api = {} @@ -68,7 +69,7 @@ function jbl_api.new_device_manager(bridge_ip, bridge_info, socket_builder) return setmetatable( { - headers = ADDITIONAL_HEADERS, + headers = st_utils.deep_copy(ADDITIONAL_HEADERS), client = RestClient.new(base_url, socket_builder), base_url = base_url, }, jbl_api @@ -76,7 +77,7 @@ function jbl_api.new_device_manager(bridge_ip, bridge_info, socket_builder) end function jbl_api:add_header(key, value) - log.info("add_header : " .. key .. ", " .. value) + log.info("add_header : " .. tostring(key) .. ", " .. tostring(value)) self.headers[key] = value end diff --git a/drivers/SmartThings/jbl/src/jbl/capability_handler.lua b/drivers/SmartThings/jbl/src/jbl/capability_handler.lua index 1ad5259c01..75f05266ba 100644 --- a/drivers/SmartThings/jbl/src/jbl/capability_handler.lua +++ b/drivers/SmartThings/jbl/src/jbl/capability_handler.lua @@ -9,7 +9,6 @@ local function smartthings_playback_capability_handler(driver, device, capabilit local st_status_to_jbl_playback_status_table = { paused = "pause", playing = "play", - stopped = "stop", } local conn_info = device:get_field(fields.CONN_INFO) @@ -34,10 +33,6 @@ function capability_handler.playback_pause_handler(driver, device, args) smartthings_playback_capability_handler(driver, device, "paused") end -function capability_handler.playback_stop_handler(driver, device, args) - smartthings_playback_capability_handler(driver, device, "stopped") -end - function capability_handler.next_track_handler(driver, device, args) local conn_info = device:get_field(fields.CONN_INFO) log.info(string.format("media_track_control.next_track_handler : dni = %s", device.device_network_id)) diff --git a/drivers/SmartThings/jbl/src/jbl/device_manager.lua b/drivers/SmartThings/jbl/src/jbl/device_manager.lua index 436b16266e..c7ca9c6092 100644 --- a/drivers/SmartThings/jbl/src/jbl/device_manager.lua +++ b/drivers/SmartThings/jbl/src/jbl/device_manager.lua @@ -28,7 +28,6 @@ end local jbl_playback_state_to_smartthings_playback_status_table = { paused = "paused", playing = "playing", - stopped = "stopped", } function device_manager.handle_status(driver, device, status) diff --git a/drivers/SmartThings/matter-appliance/config.yml b/drivers/SmartThings/matter-appliance/config.yml new file mode 100644 index 0000000000..c68c93a521 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/config.yml @@ -0,0 +1,4 @@ +name: 'Matter Appliance' +packageKey: 'matter-appliance' +permissions: + matter: {} diff --git a/drivers/SmartThings/matter-appliance/fingerprints.yml b/drivers/SmartThings/matter-appliance/fingerprints.yml new file mode 100644 index 0000000000..d743a0fd58 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/fingerprints.yml @@ -0,0 +1,72 @@ +matterManufacturer: + - id: "virtual/matter/refrigerator" + deviceLabel: Matter Refrigerator + vendorId: 0x10e1 + productId: 0x1021 + deviceProfileName: refrigerator-freezer + - id: "virtual/matter/laundry-washer" + deviceLabel: Matter Laundry Washer + vendorId: 0x10e1 + productId: 0x1024 + deviceProfileName: laundry-washer-tn-tl + - id: "virtual/matter/dishwasher" + deviceLabel: Matter Dishwasher + vendorId: 0x10e1 + productId: 0x1026 + deviceProfileName: dishwasher-tn-tl + +matterGeneric: + - id: "matter/refrigerator" + deviceLabel: Matter Refrigerator + deviceTypes: + - id: 0x0070 + - id: 0x0071 + deviceProfileName: refrigerator-freezer + - id: "matter/laundry-washer" + deviceLabel: Matter Laundry Washer + deviceTypes: + - id: 0x0073 + deviceProfileName: laundry-washer-tn-tl + - id: "matter/dishwasher" + deviceLabel: Matter Dishwasher + deviceTypes: + - id: 0x0075 + deviceProfileName: dishwasher-tn-tl + - id: "matter/extractor-hood" + deviceLabel: Matter Extractor Hood + deviceTypes: + - id: 0x007A + deviceProfileName: extractor-hood + - id: "matter/extractor-hood/light" + deviceLabel: Matter Extractor Hood/Light + deviceTypes: + - id: 0x007A + - id: 0x0100 + deviceProfileName: extractor-hood-light + - id: "matter/extractor-hood/light/switch" + deviceLabel: Matter Extractor Hood/Light Switch + deviceTypes: + - id: 0x007A + - id: 0x0103 + deviceProfileName: extractor-hood-light + - id: "matter/laundry-dryer" + deviceLabel: Matter Laundry Dryer + deviceTypes: + - id: 0x007C + deviceProfileName: laundry-dryer + - id: "matter/cook-top" + deviceLabel: Matter Cook Top + deviceTypes: + - id: 0x0078 + deviceProfileName: cook-top + - id: "matter/microwave-oven" + deviceLabel: Matter Microwave Oven + deviceTypes: + - id: 0x0079 + deviceProfileName: microwave-oven + - id: "matter/oven" + deviceLabel: Matter Oven + deviceTypes: + - id: 0x007B + - id: 0x0071 + deviceProfileName: oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl diff --git a/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two-tl.yml b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two-tl.yml new file mode 100644 index 0000000000..b36eccc624 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two-tl.yml @@ -0,0 +1,24 @@ +name: cook-surface-one-cook-surface-two-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Cooktop +- id: cookSurfaceOne + label: Cook Surface 1 + capabilities: + - id: temperatureMeasurement + version: 1 +- id: cookSurfaceTwo + label: Cook Surface 2 + capabilities: + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 diff --git a/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two.yml b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two.yml new file mode 100644 index 0000000000..83d6298bb1 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-cook-surface-two.yml @@ -0,0 +1,22 @@ +name: cook-surface-one-cook-surface-two +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Cooktop +- id: cookSurfaceOne + label: Cook Surface 1 + capabilities: + - id: temperatureMeasurement + version: 1 +- id: cookSurfaceTwo + label: Cook Surface 2 + capabilities: + - id: temperatureMeasurement + version: 1 diff --git a/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two-tl.yml b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two-tl.yml new file mode 100644 index 0000000000..4d73638a7d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two-tl.yml @@ -0,0 +1,26 @@ +name: cook-surface-one-tl-cook-surface-two-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Cooktop +- id: cookSurfaceOne + label: Cook Surface 1 + capabilities: + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 +- id: cookSurfaceTwo + label: Cook Surface 2 + capabilities: + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 diff --git a/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two.yml b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two.yml new file mode 100644 index 0000000000..4a6464030d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl-cook-surface-two.yml @@ -0,0 +1,24 @@ +name: cook-surface-one-tl-cook-surface-two +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Cooktop +- id: cookSurfaceOne + label: Cook Surface 1 + capabilities: + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 +- id: cookSurfaceTwo + label: Cook Surface 2 + capabilities: + - id: temperatureMeasurement + version: 1 diff --git a/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl.yml b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl.yml new file mode 100644 index 0000000000..bb206ac8d3 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one-tl.yml @@ -0,0 +1,19 @@ +name: cook-surface-one-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Cooktop +- id: cookSurfaceOne + label: Cook Surface 1 + capabilities: + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 diff --git a/drivers/SmartThings/matter-appliance/profiles/cook-surface-one.yml b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one.yml new file mode 100644 index 0000000000..44fee23b77 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/cook-surface-one.yml @@ -0,0 +1,17 @@ +name: cook-surface-one +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Cooktop +- id: cookSurfaceOne + label: Cook Surface 1 + capabilities: + - id: temperatureMeasurement + version: 1 diff --git a/drivers/SmartThings/matter-appliance/profiles/cook-top.yml b/drivers/SmartThings/matter-appliance/profiles/cook-top.yml new file mode 100644 index 0000000000..9ecb6d9566 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/cook-top.yml @@ -0,0 +1,13 @@ +name: cook-top +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Cooktop + \ No newline at end of file diff --git a/drivers/SmartThings/matter-appliance/profiles/dishwasher-tl.yml b/drivers/SmartThings/matter-appliance/profiles/dishwasher-tl.yml new file mode 100644 index 0000000000..cbe7f0bc10 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/dishwasher-tl.yml @@ -0,0 +1,24 @@ +name: dishwasher-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureLevel + version: 1 + - id: mode + version: 1 + - id: waterFlowAlarm + version: 1 + - id: contactSensor + version: 1 + - id: temperatureAlarm + version: 1 + - id: operationalState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dishwasher diff --git a/drivers/SmartThings/matter-appliance/profiles/dishwasher-tn-tl.yml b/drivers/SmartThings/matter-appliance/profiles/dishwasher-tn-tl.yml new file mode 100644 index 0000000000..de82f0cd79 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/dishwasher-tn-tl.yml @@ -0,0 +1,29 @@ +name: dishwasher-tn-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureSetpoint + version: 1 + - id: temperatureLevel + version: 1 + - id: mode + version: 1 + - id: waterFlowAlarm + version: 1 + - id: contactSensor + version: 1 + - id: temperatureAlarm + version: 1 + - id: operationalState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dishwasher +metadata: + mnmn: SmartThingsEdge + vid: generic-dishwasher diff --git a/drivers/SmartThings/matter-appliance/profiles/dishwasher-tn.yml b/drivers/SmartThings/matter-appliance/profiles/dishwasher-tn.yml new file mode 100644 index 0000000000..bf94068115 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/dishwasher-tn.yml @@ -0,0 +1,27 @@ +name: dishwasher-tn +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureSetpoint + version: 1 + - id: mode + version: 1 + - id: waterFlowAlarm + version: 1 + - id: contactSensor + version: 1 + - id: temperatureAlarm + version: 1 + - id: operationalState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dishwasher +metadata: + mnmn: SmartThingsEdge + vid: generic-dishwasher diff --git a/drivers/SmartThings/matter-appliance/profiles/dishwasher.yml b/drivers/SmartThings/matter-appliance/profiles/dishwasher.yml new file mode 100644 index 0000000000..5028869055 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/dishwasher.yml @@ -0,0 +1,25 @@ +name: dishwasher +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: waterFlowAlarm + version: 1 + - id: contactSensor + version: 1 + - id: temperatureAlarm + version: 1 + - id: operationalState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dishwasher +metadata: + mnmn: SmartThingsEdge + vid: generic-dishwasher diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-light.yml new file mode 100644 index 0000000000..9f4b756455 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-light.yml @@ -0,0 +1,30 @@ +name: extractor-hood-ac-light +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind-light.yml new file mode 100644 index 0000000000..0e4d264541 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind-light.yml @@ -0,0 +1,32 @@ +name: extractor-hood-ac-wind-light +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind.yml new file mode 100644 index 0000000000..34aadcb458 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac-wind.yml @@ -0,0 +1,26 @@ +name: extractor-hood-ac-wind +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac.yml new file mode 100644 index 0000000000..23f3aedfe5 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-ac.yml @@ -0,0 +1,24 @@ +name: extractor-hood-ac +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-light.yml new file mode 100644 index 0000000000..441c940408 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-light.yml @@ -0,0 +1,39 @@ +name: extractor-hood-hepa-ac-light +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind-light.yml new file mode 100644 index 0000000000..bb2338e809 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind-light.yml @@ -0,0 +1,41 @@ +name: extractor-hood-hepa-ac-wind-light +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind.yml new file mode 100644 index 0000000000..53a143569d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac-wind.yml @@ -0,0 +1,35 @@ +name: extractor-hood-hepa-ac-wind +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac.yml new file mode 100644 index 0000000000..a605ca2013 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-ac.yml @@ -0,0 +1,33 @@ +name: extractor-hood-hepa-ac +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-light.yml new file mode 100644 index 0000000000..7ad53c115c --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-light.yml @@ -0,0 +1,30 @@ +name: extractor-hood-hepa-light +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind-light.yml new file mode 100644 index 0000000000..c4fc07511c --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind-light.yml @@ -0,0 +1,32 @@ +name: extractor-hood-hepa-wind-light +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind.yml new file mode 100644 index 0000000000..af88b84ee2 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa-wind.yml @@ -0,0 +1,26 @@ +name: extractor-hood-hepa-wind +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa.yml new file mode 100644 index 0000000000..a69cb17507 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-hepa.yml @@ -0,0 +1,24 @@ +name: extractor-hood-hepa +components: +- id: main + label: Main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-light.yml new file mode 100644 index 0000000000..a063d448ba --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-light.yml @@ -0,0 +1,20 @@ +name: extractor-hood-light +components: +- id: main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind-light.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind-light.yml new file mode 100644 index 0000000000..da85cc5dd7 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind-light.yml @@ -0,0 +1,22 @@ +name: extractor-hood-wind-light +components: +- id: main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood +- id: light + capabilities: + - id: switch + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind.yml new file mode 100644 index 0000000000..074a20c78e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood-wind.yml @@ -0,0 +1,16 @@ +name: extractor-hood-wind +components: +- id: main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/extractor-hood.yml b/drivers/SmartThings/matter-appliance/profiles/extractor-hood.yml new file mode 100644 index 0000000000..c07813afcf --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/extractor-hood.yml @@ -0,0 +1,14 @@ +name: extractor-hood +components: +- id: main + capabilities: + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: KitchenHood diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tl.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tl.yml new file mode 100644 index 0000000000..6edd6edc01 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tl.yml @@ -0,0 +1,21 @@ +name: laundry-dryer-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: operationalState + version: 1 + - id: temperatureLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dryer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-dryer diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn-tl.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn-tl.yml new file mode 100644 index 0000000000..ea54dfe097 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn-tl.yml @@ -0,0 +1,23 @@ +name: laundry-dryer-tn-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: operationalState + version: 1 + - id: temperatureLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dryer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-dryer diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn.yml new file mode 100644 index 0000000000..8182eded36 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer-tn.yml @@ -0,0 +1,21 @@ +name: laundry-dryer-tn +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: operationalState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dryer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-dryer diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-dryer.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer.yml new file mode 100644 index 0000000000..d04fe0f74b --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-dryer.yml @@ -0,0 +1,19 @@ +name: laundry-dryer +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: operationalState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Dryer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-dryer \ No newline at end of file diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tl.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tl.yml new file mode 100644 index 0000000000..259c4b37fb --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tl.yml @@ -0,0 +1,25 @@ +name: laundry-washer-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: laundryWasherRinseMode + version: 1 + - id: operationalState + version: 1 + - id: temperatureLevel + version: 1 + - id: laundryWasherSpinSpeed + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Washer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-washer diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn-tl.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn-tl.yml new file mode 100644 index 0000000000..ae1525bef8 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn-tl.yml @@ -0,0 +1,27 @@ +name: laundry-washer-tn-tl +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: laundryWasherRinseMode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: operationalState + version: 1 + - id: temperatureLevel + version: 1 + - id: laundryWasherSpinSpeed + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Washer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-washer diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn.yml new file mode 100644 index 0000000000..44bf29dd6a --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-washer-tn.yml @@ -0,0 +1,25 @@ +name: laundry-washer-tn +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: laundryWasherRinseMode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: operationalState + version: 1 + - id: laundryWasherSpinSpeed + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Washer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-washer diff --git a/drivers/SmartThings/matter-appliance/profiles/laundry-washer.yml b/drivers/SmartThings/matter-appliance/profiles/laundry-washer.yml new file mode 100644 index 0000000000..62b69cfbe6 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/laundry-washer.yml @@ -0,0 +1,23 @@ +name: laundry-washer +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: laundryWasherRinseMode + version: 1 + - id: operationalState + version: 1 + - id: laundryWasherSpinSpeed + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Washer +metadata: + mnmn: SmartThingsEdge + vid: generic-laundry-washer diff --git a/drivers/SmartThings/matter-appliance/profiles/microwave-oven.yml b/drivers/SmartThings/matter-appliance/profiles/microwave-oven.yml new file mode 100644 index 0000000000..5f15869b65 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/microwave-oven.yml @@ -0,0 +1,19 @@ +name: microwave-oven +components: +- id: main + capabilities: + - id: mode + version: 1 + - id: operationalState + version: 1 + - id: cookTime + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Microwave +metadata: + mnmn: SmartThingsEdge + vid: generic-microwave-oven diff --git a/drivers/SmartThings/matter-appliance/profiles/oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl.yml b/drivers/SmartThings/matter-appliance/profiles/oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl.yml new file mode 100644 index 0000000000..b5a17e129f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl.yml @@ -0,0 +1,50 @@ +name: oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl +components: +- id: main + capabilities: + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Oven +- id: tccOne + label: Oven Cabinet 1 + capabilities: + - id: mode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: temperatureSetpoint + version: 1 +- id: tccTwo + label: Oven Cabinet 2 + capabilities: + - id: mode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: temperatureLevel + version: 1 +- id: cookTop + label: Cook Top + capabilities: + - id: switch + version: 1 +- id: cookSurfaceOne + label: Cook Surface 1 + capabilities: + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 +- id: cookSurfaceTwo + label: Cook Surface 2 + capabilities: + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 +metadata: + mnmn: SmartThingsEdge + vid: generic-oven diff --git a/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tl.yml b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tl.yml new file mode 100644 index 0000000000..a3a503ac04 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tl.yml @@ -0,0 +1,40 @@ +name: refrigerator-freezer-tl +components: +- id: main + label: Main + capabilities: + - id: mode + version: 1 + - id: contactSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Refrigerator +- id: refrigerator + label: Refrigerator + capabilities: + - id: mode + version: 1 + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: Refrigerator +- id: freezer + label: Freezer + capabilities: + - id: mode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: temperatureLevel + version: 1 + categories: + - name: Refrigerator +metadata: + mnmn: SmartThingsEdge + vid: generic-refrigerator diff --git a/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn-tl.yml b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn-tl.yml new file mode 100644 index 0000000000..be761c666d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn-tl.yml @@ -0,0 +1,40 @@ +name: refrigerator-freezer-tn-tl +components: +- id: main + label: Main + capabilities: + - id: mode + version: 1 + - id: contactSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Refrigerator +- id: refrigerator + label: Refrigerator + capabilities: + - id: mode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: Refrigerator +- id: freezer + label: Freezer + capabilities: + - id: mode + version: 1 + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: Refrigerator +metadata: + mnmn: SmartThingsEdge + vid: generic-refrigerator diff --git a/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn.yml b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn.yml new file mode 100644 index 0000000000..37c3bdfbb3 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer-tn.yml @@ -0,0 +1,40 @@ +name: refrigerator-freezer-tn +components: +- id: main + label: Main + capabilities: + - id: mode + version: 1 + - id: contactSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Refrigerator +- id: refrigerator + label: Refrigerator + capabilities: + - id: mode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: Refrigerator +- id: freezer + label: Freezer + capabilities: + - id: mode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: Refrigerator +metadata: + mnmn: SmartThingsEdge + vid: generic-refrigerator diff --git a/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer.yml b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer.yml new file mode 100644 index 0000000000..0cf1949ef5 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/profiles/refrigerator-freezer.yml @@ -0,0 +1,44 @@ +name: refrigerator-freezer +components: +- id: main + label: Main + capabilities: + - id: mode + version: 1 + - id: contactSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Refrigerator +- id: refrigerator + label: Refrigerator + capabilities: + - id: mode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: Refrigerator +- id: freezer + label: Freezer + capabilities: + - id: mode + version: 1 + - id: temperatureSetpoint + version: 1 + - id: temperatureLevel + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: Refrigerator +metadata: + mnmn: SmartThingsEdge + vid: generic-refrigerator diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua new file mode 100644 index 0000000000..fff466fea1 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua @@ -0,0 +1,83 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local ActivatedCarbonFilterMonitoringServerAttributes = require "ActivatedCarbonFilterMonitoring.server.attributes" +local ActivatedCarbonFilterMonitoringTypes = require "ActivatedCarbonFilterMonitoring.types" + +local ActivatedCarbonFilterMonitoring = {} + +ActivatedCarbonFilterMonitoring.ID = 0x0072 +ActivatedCarbonFilterMonitoring.NAME = "ActivatedCarbonFilterMonitoring" +ActivatedCarbonFilterMonitoring.server = {} +ActivatedCarbonFilterMonitoring.client = {} +ActivatedCarbonFilterMonitoring.server.attributes = ActivatedCarbonFilterMonitoringServerAttributes:set_parent_cluster(ActivatedCarbonFilterMonitoring) +ActivatedCarbonFilterMonitoring.types = ActivatedCarbonFilterMonitoringTypes + +function ActivatedCarbonFilterMonitoring:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Condition", + [0x0001] = "DegradationDirection", + [0x0002] = "ChangeIndication", + [0x0003] = "InPlaceIndicator", + [0x0004] = "LastChangedTime", + [0x0005] = "ReplacementProductList", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ActivatedCarbonFilterMonitoring:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ResetCondition", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ActivatedCarbonFilterMonitoring.attribute_direction_map = { + ["Condition"] = "server", + ["DegradationDirection"] = "server", + ["ChangeIndication"] = "server", + ["InPlaceIndicator"] = "server", + ["LastChangedTime"] = "server", + ["ReplacementProductList"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + + +ActivatedCarbonFilterMonitoring.FeatureMap = ActivatedCarbonFilterMonitoring.types.Feature + +function ActivatedCarbonFilterMonitoring.are_features_supported(feature, feature_map) + if (ActivatedCarbonFilterMonitoring.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ActivatedCarbonFilterMonitoring.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ActivatedCarbonFilterMonitoring.NAME)) + end + return ActivatedCarbonFilterMonitoring[direction].attributes[key] +end +ActivatedCarbonFilterMonitoring.attributes = {} +setmetatable(ActivatedCarbonFilterMonitoring.attributes, attribute_helper_mt) + +setmetatable(ActivatedCarbonFilterMonitoring, {__index = cluster_base}) + +return ActivatedCarbonFilterMonitoring + diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..9f34e51dbf --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua @@ -0,0 +1,115 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +--- @class st.matter.clusters.ActivatedCarbonFilterMonitoring.AttributeList +--- @alias AttributeList +--- +--- @field public ID number 0xFFFB the ID of this attribute +--- @field public NAME string "AttributeList" the name of this attribute +--- @field public data_type st.matter.data_types.Array the data type of this attribute + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +--- Add additional functionality to the base type object +--- +--- @param base_type_obj st.matter.data_types.Array the base data type object to add functionality to +function AttributeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AttributeList.element_type) + end +end + +--- Create a Array object of this attribute with any additional features provided for the attribute +--- This is also usable with the AttributeList(...) syntax +--- +--- @vararg vararg the values needed to construct a Array +--- @return st.matter.data_types.Array +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +--- Constructs an st.matter.interaction_model.InteractionRequest to read +--- this attribute from a device +--- @param device st.matter.Device +--- @param endpoint_id number|nil +--- @return st.matter.interaction_model.InteractionRequest containing an Interaction Request +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + + +--- Reporting policy: AttributeList => true => mandatory + +--- Sets up a Subscribe Interaction +--- +--- @param device any +--- @param endpoint_id number|nil +--- @return any +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +--- Builds an AttributeList test attribute reponse for the driver integration testing framework +--- +--- @param device st.matter.Device the device to build this message for +--- @param endpoint_id number|nil +--- @param value any +--- @param status string Interaction status associated with the path +--- @return st.matter.interaction_model.InteractionResponse of type REPORT_DATA +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) +return AttributeList diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua new file mode 100644 index 0000000000..71170e772f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua @@ -0,0 +1,105 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +--- @class st.matter.clusters.ActivatedCarbonFilterMonitoring.ChangeIndication +--- @alias ChangeIndication +--- +--- @field public ID number 0x0002 the ID of this attribute +--- @field public NAME string "ChangeIndication" the name of this attribute +--- @field public data_type ActivatedCarbonFilterMonitoring.types.ChangeIndicationEnum the data type of this attribute + +local ChangeIndication = { + ID = 0x0002, + NAME = "ChangeIndication", + base_type = require "ActivatedCarbonFilterMonitoring.types.ChangeIndicationEnum", +} + +--- Create a ChangeIndicationEnum object of this attribute with any additional features provided for the attribute +--- This is also usable with the ChangeIndication(...) syntax +--- +--- @vararg vararg the values needed to construct a ChangeIndicationEnum +--- @return ActivatedCarbonFilterMonitoring.types.ChangeIndicationEnum +function ChangeIndication:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +--- Constructs an st.matter.interaction_model.InteractionRequest to read +--- this attribute from a device +--- @param device st.matter.Device +--- @param endpoint_id number|nil +--- @return st.matter.interaction_model.InteractionRequest containing an Interaction Request +function ChangeIndication:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + + +--- Reporting policy: ChangeIndication => true => mandatory + +--- Sets up a Subscribe Interaction +--- +--- @param device any +--- @param endpoint_id number|nil +--- @return any +function ChangeIndication:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChangeIndication:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +--- Builds an ChangeIndication test attribute reponse for the driver integration testing framework +--- +--- @param device st.matter.Device the device to build this message for +--- @param endpoint_id number|nil +--- @param value any +--- @param status string Interaction status associated with the path +--- @return st.matter.interaction_model.InteractionResponse of type REPORT_DATA +function ChangeIndication:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ChangeIndication:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeIndication, {__call = ChangeIndication.new_value, __index = ChangeIndication.base_type}) +return ChangeIndication diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua new file mode 100644 index 0000000000..e668aa4c48 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Condition = { + ID = 0x0000, + NAME = "Condition", + base_type = require "st.matter.data_types.Uint8", +} + +function Condition:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Condition:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Condition:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Condition:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Condition, {__call = Condition.new_value, __index = Condition.base_type}) +return Condition + diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/init.lua new file mode 100644 index 0000000000..a02378a50d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/init.lua @@ -0,0 +1,24 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("ActivatedCarbonFilterMonitoring.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ActivatedCarbonFilterMonitoringServerAttributes = {} + +function ActivatedCarbonFilterMonitoringServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ActivatedCarbonFilterMonitoringServerAttributes, attr_mt) + +return ActivatedCarbonFilterMonitoringServerAttributes + diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua new file mode 100644 index 0000000000..438de24c94 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ChangeIndicationEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.OK] = "OK", + [self.WARNING] = "WARNING", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.OK = 0x00 +new_mt.__index.WARNING = 0x01 +new_mt.__index.CRITICAL = 0x02 + +ChangeIndicationEnum.OK = 0x00 +ChangeIndicationEnum.WARNING = 0x01 +ChangeIndicationEnum.CRITICAL = 0x02 + +ChangeIndicationEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ChangeIndicationEnum, new_mt) + +return ChangeIndicationEnum + diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/Feature.lua new file mode 100644 index 0000000000..88474d1b0f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/Feature.lua @@ -0,0 +1,98 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.CONDITION = 0x0001 +Feature.WARNING = 0x0002 +Feature.REPLACEMENT_PRODUCT_LIST = 0x0004 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + CONDITION = 0x0001, + WARNING = 0x0002, + REPLACEMENT_PRODUCT_LIST = 0x0004, +} + +Feature.is_condition_set = function(self) + return (self.value & self.CONDITION) ~= 0 +end + +Feature.set_condition = function(self) + if self.value ~= nil then + self.value = self.value | self.CONDITION + else + self.value = self.CONDITION + end +end + +Feature.unset_condition = function(self) + self.value = self.value & (~self.CONDITION & self.BASE_MASK) +end + +Feature.is_warning_set = function(self) + return (self.value & self.WARNING) ~= 0 +end + +Feature.set_warning = function(self) + if self.value ~= nil then + self.value = self.value | self.WARNING + else + self.value = self.WARNING + end +end + +Feature.unset_warning = function(self) + self.value = self.value & (~self.WARNING & self.BASE_MASK) +end + +Feature.is_replacement_product_list_set = function(self) + return (self.value & self.REPLACEMENT_PRODUCT_LIST) ~= 0 +end + +Feature.set_replacement_product_list = function(self) + if self.value ~= nil then + self.value = self.value | self.REPLACEMENT_PRODUCT_LIST + else + self.value = self.REPLACEMENT_PRODUCT_LIST + end +end + +Feature.unset_replacement_product_list = function(self) + self.value = self.value & (~self.REPLACEMENT_PRODUCT_LIST & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.CONDITION | + Feature.WARNING | + Feature.REPLACEMENT_PRODUCT_LIST + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_condition_set = Feature.is_condition_set, + set_condition = Feature.set_condition, + unset_condition = Feature.unset_condition, + is_warning_set = Feature.is_warning_set, + set_warning = Feature.set_warning, + unset_warning = Feature.unset_warning, + is_replacement_product_list_set = Feature.is_replacement_product_list_set, + set_replacement_product_list = Feature.set_replacement_product_list, + unset_replacement_product_list = Feature.unset_replacement_product_list, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua new file mode 100644 index 0000000000..80e6e6b86a --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("ActivatedCarbonFilterMonitoring.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ActivatedCarbonFilterMonitoringTypes = {} + +setmetatable(ActivatedCarbonFilterMonitoringTypes, types_mt) + +return ActivatedCarbonFilterMonitoringTypes + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua new file mode 100644 index 0000000000..382197bc60 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua @@ -0,0 +1,94 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local DishwasherAlarmServerAttributes = require "DishwasherAlarm.server.attributes" +local DishwasherAlarmTypes = require "DishwasherAlarm.types" + +local DishwasherAlarm = {} + +DishwasherAlarm.ID = 0x005D +DishwasherAlarm.NAME = "DishwasherAlarm" +DishwasherAlarm.server = {} +DishwasherAlarm.client = {} +DishwasherAlarm.server.attributes = DishwasherAlarmServerAttributes:set_parent_cluster(DishwasherAlarm) +DishwasherAlarm.types = DishwasherAlarmTypes + +function DishwasherAlarm:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Mask", + [0x0001] = "Latch", + [0x0002] = "State", + [0x0003] = "Supported", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function DishwasherAlarm:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "Reset", + [0x0001] = "ModifyEnabledAlarms", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function DishwasherAlarm:get_event_by_id(event_id) + local event_id_map = { + [0x0000] = "Notify", + } + if event_id_map[event_id] ~= nil then + return self.server.events[event_id_map[event_id]] + end + return nil +end + +DishwasherAlarm.attribute_direction_map = { + ["Mask"] = "server", + ["Latch"] = "server", + ["State"] = "server", + ["Supported"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +DishwasherAlarm.command_direction_map = { + ["Reset"] = "server", + ["ModifyEnabledAlarms"] = "server", +} + +DishwasherAlarm.FeatureMap = DishwasherAlarm.types.Feature + +function DishwasherAlarm.are_features_supported(feature, feature_map) + if (DishwasherAlarm.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = DishwasherAlarm.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, DishwasherAlarm.NAME)) + end + return DishwasherAlarm[direction].attributes[key] +end +DishwasherAlarm.attributes = {} +setmetatable(DishwasherAlarm.attributes, attribute_helper_mt) + +setmetatable(DishwasherAlarm, {__index = cluster_base}) + +return DishwasherAlarm + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..6a8d95df1d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,75 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Mask.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Mask.lua new file mode 100644 index 0000000000..d8b20ce495 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Mask.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Mask = { + ID = 0x0000, + NAME = "Mask", + base_type = require "DishwasherAlarm.types.AlarmMap", +} + +function Mask:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function Mask:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Mask:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Mask:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Mask:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Mask:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Mask, {__call = Mask.new_value, __index = Mask.base_type}) +return Mask + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/State.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/State.lua new file mode 100644 index 0000000000..b90fad4882 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/State.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local State = { + ID = 0x0002, + NAME = "State", + base_type = require "DishwasherAlarm.types.AlarmMap", +} + +function State:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function State:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function State:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function State:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function State:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function State:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(State, {__call = State.new_value, __index = State.base_type}) +return State + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Supported.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Supported.lua new file mode 100644 index 0000000000..862016a3cd --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/Supported.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Supported = { + ID = 0x0003, + NAME = "Supported", + base_type = require "DishwasherAlarm.types.AlarmMap", +} + +function Supported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function Supported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Supported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Supported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Supported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Supported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Supported, {__call = Supported.new_value, __index = Supported.base_type}) +return Supported + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/init.lua new file mode 100644 index 0000000000..28f2ef1dcc --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("DishwasherAlarm.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local DishwasherAlarmServerAttributes = {} + +function DishwasherAlarmServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DishwasherAlarmServerAttributes, attr_mt) + +return DishwasherAlarmServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/commands/ModifyEnabledAlarms.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/commands/ModifyEnabledAlarms.lua new file mode 100644 index 0000000000..95e17ba731 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/server/commands/ModifyEnabledAlarms.lua @@ -0,0 +1,98 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ModifyEnabledAlarms = {} + +ModifyEnabledAlarms.NAME = "ModifyEnabledAlarms" +ModifyEnabledAlarms.ID = 0x0001 +ModifyEnabledAlarms.field_defs = { + { + name = "mask", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DishwasherAlarm.types.AlarmMap", + }, +} + +function ModifyEnabledAlarms:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ModifyEnabledAlarms:init(device, endpoint_id, mask) + local out = {} + local args = {mask} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ModifyEnabledAlarms, + __tostring = ModifyEnabledAlarms.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ModifyEnabledAlarms:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ModifyEnabledAlarms:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ModifyEnabledAlarms:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ModifyEnabledAlarms, {__call = ModifyEnabledAlarms.init}) + +return ModifyEnabledAlarms + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/AlarmMap.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/AlarmMap.lua new file mode 100644 index 0000000000..92146348a1 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/AlarmMap.lua @@ -0,0 +1,149 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local AlarmMap = {} +local new_mt = UintABC.new_mt({NAME = "AlarmMap", ID = data_types.name_to_id_map["Uint32"]}, 4) + +AlarmMap.BASE_MASK = 0xFFFF +AlarmMap.INFLOW_ERROR = 0x0001 +AlarmMap.DRAIN_ERROR = 0x0002 +AlarmMap.DOOR_ERROR = 0x0004 +AlarmMap.TEMP_TOO_LOW = 0x0008 +AlarmMap.TEMP_TOO_HIGH = 0x0010 +AlarmMap.WATER_LEVEL_ERROR = 0x0020 + +AlarmMap.mask_fields = { + BASE_MASK = 0xFFFF, + INFLOW_ERROR = 0x0001, + DRAIN_ERROR = 0x0002, + DOOR_ERROR = 0x0004, + TEMP_TOO_LOW = 0x0008, + TEMP_TOO_HIGH = 0x0010, + WATER_LEVEL_ERROR = 0x0020, +} + +AlarmMap.is_inflow_error_set = function(self) + return (self.value & self.INFLOW_ERROR) ~= 0 +end + +AlarmMap.set_inflow_error = function(self) + if self.value ~= nil then + self.value = self.value | self.INFLOW_ERROR + else + self.value = self.INFLOW_ERROR + end +end + +AlarmMap.unset_inflow_error = function(self) + self.value = self.value & (~self.INFLOW_ERROR & self.BASE_MASK) +end + +AlarmMap.is_drain_error_set = function(self) + return (self.value & self.DRAIN_ERROR) ~= 0 +end + +AlarmMap.set_drain_error = function(self) + if self.value ~= nil then + self.value = self.value | self.DRAIN_ERROR + else + self.value = self.DRAIN_ERROR + end +end + +AlarmMap.unset_drain_error = function(self) + self.value = self.value & (~self.DRAIN_ERROR & self.BASE_MASK) +end + +AlarmMap.is_door_error_set = function(self) + return (self.value & self.DOOR_ERROR) ~= 0 +end + +AlarmMap.set_door_error = function(self) + if self.value ~= nil then + self.value = self.value | self.DOOR_ERROR + else + self.value = self.DOOR_ERROR + end +end + +AlarmMap.unset_door_error = function(self) + self.value = self.value & (~self.DOOR_ERROR & self.BASE_MASK) +end +AlarmMap.is_temp_too_low_set = function(self) + return (self.value & self.TEMP_TOO_LOW) ~= 0 +end + +AlarmMap.set_temp_too_low = function(self) + if self.value ~= nil then + self.value = self.value | self.TEMP_TOO_LOW + else + self.value = self.TEMP_TOO_LOW + end +end + +AlarmMap.unset_temp_too_low = function(self) + self.value = self.value & (~self.TEMP_TOO_LOW & self.BASE_MASK) +end + +AlarmMap.is_temp_too_high_set = function(self) + return (self.value & self.TEMP_TOO_HIGH) ~= 0 +end + +AlarmMap.set_temp_too_high = function(self) + if self.value ~= nil then + self.value = self.value | self.TEMP_TOO_HIGH + else + self.value = self.TEMP_TOO_HIGH + end +end + +AlarmMap.unset_temp_too_high = function(self) + self.value = self.value & (~self.TEMP_TOO_HIGH & self.BASE_MASK) +end + +AlarmMap.is_water_level_error_set = function(self) + return (self.value & self.WATER_LEVEL_ERROR) ~= 0 +end + +AlarmMap.set_water_level_error = function(self) + if self.value ~= nil then + self.value = self.value | self.WATER_LEVEL_ERROR + else + self.value = self.WATER_LEVEL_ERROR + end +end + +AlarmMap.unset_water_level_error = function(self) + self.value = self.value & (~self.WATER_LEVEL_ERROR & self.BASE_MASK) +end + + +AlarmMap.mask_methods = { + is_inflow_error_set = AlarmMap.is_inflow_error_set, + set_inflow_error = AlarmMap.set_inflow_error, + unset_inflow_error = AlarmMap.unset_inflow_error, + is_drain_error_set = AlarmMap.is_drain_error_set, + set_drain_error = AlarmMap.set_drain_error, + unset_drain_error = AlarmMap.unset_drain_error, + is_door_error_set = AlarmMap.is_door_error_set, + set_door_error = AlarmMap.set_door_error, + unset_door_error = AlarmMap.unset_door_error, + is_temp_too_low_set = AlarmMap.is_temp_too_low_set, + set_temp_too_low = AlarmMap.set_temp_too_low, + unset_temp_too_low = AlarmMap.unset_temp_too_low, + is_temp_too_high_set = AlarmMap.is_temp_too_high_set, + set_temp_too_high = AlarmMap.set_temp_too_high, + unset_temp_too_high = AlarmMap.unset_temp_too_high, + is_water_level_error_set = AlarmMap.is_water_level_error_set, + set_water_level_error = AlarmMap.set_water_level_error, + unset_water_level_error = AlarmMap.unset_water_level_error, +} + +AlarmMap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(AlarmMap, new_mt) + +return AlarmMap + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/Feature.lua new file mode 100644 index 0000000000..54ed561192 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/Feature.lua @@ -0,0 +1,54 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.RESET = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + RESET = 0x0001, +} + +Feature.is_reset_set = function(self) + return (self.value & self.RESET) ~= 0 +end + +Feature.set_reset = function(self) + if self.value ~= nil then + self.value = self.value | self.RESET + else + self.value = self.RESET + end +end + +Feature.unset_reset = function(self) + self.value = self.value & (~self.RESET & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.RESET + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_reset_set = Feature.is_reset_set, + set_reset = Feature.set_reset, + unset_reset = Feature.unset_reset, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua new file mode 100644 index 0000000000..3ddec58dcc --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("DishwasherAlarm.types." .. key) + end + return types_mt.__types_cache[key] +end + +local DishwasherAlarmTypes = {} + +setmetatable(DishwasherAlarmTypes, types_mt) + +return DishwasherAlarmTypes + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua new file mode 100644 index 0000000000..0bb22e1884 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua @@ -0,0 +1,96 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local DishwasherModeServerAttributes = require "DishwasherMode.server.attributes" +local DishwasherModeServerCommands = require "DishwasherMode.server.commands" +local DishwasherModeTypes = require "DishwasherMode.types" + +local DishwasherMode = {} + +DishwasherMode.ID = 0x0059 +DishwasherMode.NAME = "DishwasherMode" +DishwasherMode.server = {} +DishwasherMode.client = {} +DishwasherMode.server.attributes = DishwasherModeServerAttributes:set_parent_cluster(DishwasherMode) +DishwasherMode.server.commands = DishwasherModeServerCommands:set_parent_cluster(DishwasherMode) +DishwasherMode.types = DishwasherModeTypes + +function DishwasherMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0x0002] = "StartUpMode", + [0x0003] = "OnMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function DishwasherMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +DishwasherMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["StartUpMode"] = "server", + ["OnMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +DishwasherMode.command_direction_map = { + ["ChangeToMode"] = "server", + ["ChangeToModeResponse"] = "client", +} + +DishwasherMode.FeatureMap = DishwasherMode.types.Feature + +function DishwasherMode.are_features_supported(feature, feature_map) + if (DishwasherMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = DishwasherMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, DishwasherMode.NAME)) + end + return DishwasherMode[direction].attributes[key] +end +DishwasherMode.attributes = {} +setmetatable(DishwasherMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = DishwasherMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, DishwasherMode.NAME)) + end + return DishwasherMode[direction].commands[key] +end +DishwasherMode.commands = {} +setmetatable(DishwasherMode.commands, command_helper_mt) + +setmetatable(DishwasherMode, {__index = cluster_base}) + +return DishwasherMode + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..6a8d95df1d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,75 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..aa20156f74 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/CurrentMode.lua @@ -0,0 +1,69 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..a6facbd1c3 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/SupportedModes.lua @@ -0,0 +1,75 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "DishwasherMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/init.lua new file mode 100644 index 0000000000..fe74030809 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/attributes/init.lua @@ -0,0 +1,24 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("DishwasherMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local DishwasherModeServerAttributes = {} + +function DishwasherModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DishwasherModeServerAttributes, attr_mt) + +return DishwasherModeServerAttributes + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..73ddbd1029 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/ChangeToMode.lua @@ -0,0 +1,87 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/init.lua new file mode 100644 index 0000000000..d5530e1643 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/server/commands/init.lua @@ -0,0 +1,23 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("DishwasherMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local DishwasherModeServerCommands = {} + +function DishwasherModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DishwasherModeServerCommands, command_mt) + +return DishwasherModeServerCommands + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/Feature.lua new file mode 100644 index 0000000000..da49bf4115 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/Feature.lua @@ -0,0 +1,54 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..73024b5e29 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeOptionStruct.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "DishwasherMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTag.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTag.lua new file mode 100644 index 0000000000..009e70a40e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTag.lua @@ -0,0 +1,31 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NORMAL] = "NORMAL", + [self.HEAVY] = "HEAVY", + [self.LIGHT] = "LIGHT", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NORMAL = 0x4000 +new_mt.__index.HEAVY = 0x4001 +new_mt.__index.LIGHT = 0x4002 + +ModeTag.NORMAL = 0x4000 +ModeTag.HEAVY = 0x4001 +ModeTag.LIGHT = 0x4002 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..1c41eb320e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/ModeTagStruct.lua @@ -0,0 +1,78 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct + diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua new file mode 100644 index 0000000000..34d510160e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("DishwasherMode.types." .. key) + end + return types_mt.__types_cache[key] +end + +local DishwasherModeTypes = {} + +setmetatable(DishwasherModeTypes, types_mt) + +return DishwasherModeTypes + diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua new file mode 100644 index 0000000000..b6df5d554c --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua @@ -0,0 +1,83 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local HepaFilterMonitoringServerAttributes = require "HepaFilterMonitoring.server.attributes" +local HepaFilterMonitoringTypes = require "HepaFilterMonitoring.types" + +local HepaFilterMonitoring = {} + +HepaFilterMonitoring.ID = 0x0071 +HepaFilterMonitoring.NAME = "HepaFilterMonitoring" +HepaFilterMonitoring.server = {} +HepaFilterMonitoring.client = {} +HepaFilterMonitoring.server.attributes = HepaFilterMonitoringServerAttributes:set_parent_cluster(HepaFilterMonitoring) +HepaFilterMonitoring.types = HepaFilterMonitoringTypes + +function HepaFilterMonitoring:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Condition", + [0x0001] = "DegradationDirection", + [0x0002] = "ChangeIndication", + [0x0003] = "InPlaceIndicator", + [0x0004] = "LastChangedTime", + [0x0005] = "ReplacementProductList", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function HepaFilterMonitoring:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ResetCondition", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +HepaFilterMonitoring.attribute_direction_map = { + ["Condition"] = "server", + ["DegradationDirection"] = "server", + ["ChangeIndication"] = "server", + ["InPlaceIndicator"] = "server", + ["LastChangedTime"] = "server", + ["ReplacementProductList"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + + +HepaFilterMonitoring.FeatureMap = HepaFilterMonitoring.types.Feature + +function HepaFilterMonitoring.are_features_supported(feature, feature_map) + if (HepaFilterMonitoring.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = HepaFilterMonitoring.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, HepaFilterMonitoring.NAME)) + end + return HepaFilterMonitoring[direction].attributes[key] +end +HepaFilterMonitoring.attributes = {} +setmetatable(HepaFilterMonitoring.attributes, attribute_helper_mt) + +setmetatable(HepaFilterMonitoring, {__index = cluster_base}) + +return HepaFilterMonitoring + diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..6fdd2e5350 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua @@ -0,0 +1,115 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +--- @class st.matter.clusters.HepaFilterMonitoring.AttributeList +--- @alias AttributeList +--- +--- @field public ID number 0xFFFB the ID of this attribute +--- @field public NAME string "AttributeList" the name of this attribute +--- @field public data_type st.matter.data_types.Array the data type of this attribute + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +--- Add additional functionality to the base type object +--- +--- @param base_type_obj st.matter.data_types.Array the base data type object to add functionality to +function AttributeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AttributeList.element_type) + end +end + +--- Create a Array object of this attribute with any additional features provided for the attribute +--- This is also usable with the AttributeList(...) syntax +--- +--- @vararg vararg the values needed to construct a Array +--- @return st.matter.data_types.Array +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +--- Constructs an st.matter.interaction_model.InteractionRequest to read +--- this attribute from a device +--- @param device st.matter.Device +--- @param endpoint_id number|nil +--- @return st.matter.interaction_model.InteractionRequest containing an Interaction Request +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + + +--- Reporting policy: AttributeList => true => mandatory + +--- Sets up a Subscribe Interaction +--- +--- @param device any +--- @param endpoint_id number|nil +--- @return any +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +--- Builds an AttributeList test attribute reponse for the driver integration testing framework +--- +--- @param device st.matter.Device the device to build this message for +--- @param endpoint_id number|nil +--- @param value any +--- @param status string Interaction status associated with the path +--- @return st.matter.interaction_model.InteractionResponse of type REPORT_DATA +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) +return AttributeList diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua new file mode 100644 index 0000000000..b205026e1b --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua @@ -0,0 +1,105 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +--- @class st.matter.clusters.HepaFilterMonitoring.ChangeIndication +--- @alias ChangeIndication +--- +--- @field public ID number 0x0002 the ID of this attribute +--- @field public NAME string "ChangeIndication" the name of this attribute +--- @field public data_type HepaFilterMonitoring.types.ChangeIndicationEnum the data type of this attribute + +local ChangeIndication = { + ID = 0x0002, + NAME = "ChangeIndication", + base_type = require "HepaFilterMonitoring.types.ChangeIndicationEnum", +} + +--- Create a ChangeIndicationEnum object of this attribute with any additional features provided for the attribute +--- This is also usable with the ChangeIndication(...) syntax +--- +--- @vararg vararg the values needed to construct a ChangeIndicationEnum +--- @return HepaFilterMonitoring.types.ChangeIndicationEnum +function ChangeIndication:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +--- Constructs an st.matter.interaction_model.InteractionRequest to read +--- this attribute from a device +--- @param device st.matter.Device +--- @param endpoint_id number|nil +--- @return st.matter.interaction_model.InteractionRequest containing an Interaction Request +function ChangeIndication:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + + +--- Reporting policy: ChangeIndication => true => mandatory + +--- Sets up a Subscribe Interaction +--- +--- @param device any +--- @param endpoint_id number|nil +--- @return any +function ChangeIndication:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChangeIndication:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +--- Builds an ChangeIndication test attribute reponse for the driver integration testing framework +--- +--- @param device st.matter.Device the device to build this message for +--- @param endpoint_id number|nil +--- @param value any +--- @param status string Interaction status associated with the path +--- @return st.matter.interaction_model.InteractionResponse of type REPORT_DATA +function ChangeIndication:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ChangeIndication:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeIndication, {__call = ChangeIndication.new_value, __index = ChangeIndication.base_type}) +return ChangeIndication diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/Condition.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/Condition.lua new file mode 100644 index 0000000000..e668aa4c48 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/Condition.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Condition = { + ID = 0x0000, + NAME = "Condition", + base_type = require "st.matter.data_types.Uint8", +} + +function Condition:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Condition:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Condition:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Condition:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Condition, {__call = Condition.new_value, __index = Condition.base_type}) +return Condition + diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/init.lua new file mode 100644 index 0000000000..8d7ffe6c00 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/init.lua @@ -0,0 +1,24 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("HepaFilterMonitoring.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local HepaFilterMonitoringServerAttributes = {} + +function HepaFilterMonitoringServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(HepaFilterMonitoringServerAttributes, attr_mt) + +return HepaFilterMonitoringServerAttributes + diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/ChangeIndicationEnum.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/ChangeIndicationEnum.lua new file mode 100644 index 0000000000..438de24c94 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/ChangeIndicationEnum.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ChangeIndicationEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.OK] = "OK", + [self.WARNING] = "WARNING", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.OK = 0x00 +new_mt.__index.WARNING = 0x01 +new_mt.__index.CRITICAL = 0x02 + +ChangeIndicationEnum.OK = 0x00 +ChangeIndicationEnum.WARNING = 0x01 +ChangeIndicationEnum.CRITICAL = 0x02 + +ChangeIndicationEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ChangeIndicationEnum, new_mt) + +return ChangeIndicationEnum + diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/Feature.lua new file mode 100644 index 0000000000..88474d1b0f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/Feature.lua @@ -0,0 +1,98 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.CONDITION = 0x0001 +Feature.WARNING = 0x0002 +Feature.REPLACEMENT_PRODUCT_LIST = 0x0004 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + CONDITION = 0x0001, + WARNING = 0x0002, + REPLACEMENT_PRODUCT_LIST = 0x0004, +} + +Feature.is_condition_set = function(self) + return (self.value & self.CONDITION) ~= 0 +end + +Feature.set_condition = function(self) + if self.value ~= nil then + self.value = self.value | self.CONDITION + else + self.value = self.CONDITION + end +end + +Feature.unset_condition = function(self) + self.value = self.value & (~self.CONDITION & self.BASE_MASK) +end + +Feature.is_warning_set = function(self) + return (self.value & self.WARNING) ~= 0 +end + +Feature.set_warning = function(self) + if self.value ~= nil then + self.value = self.value | self.WARNING + else + self.value = self.WARNING + end +end + +Feature.unset_warning = function(self) + self.value = self.value & (~self.WARNING & self.BASE_MASK) +end + +Feature.is_replacement_product_list_set = function(self) + return (self.value & self.REPLACEMENT_PRODUCT_LIST) ~= 0 +end + +Feature.set_replacement_product_list = function(self) + if self.value ~= nil then + self.value = self.value | self.REPLACEMENT_PRODUCT_LIST + else + self.value = self.REPLACEMENT_PRODUCT_LIST + end +end + +Feature.unset_replacement_product_list = function(self) + self.value = self.value & (~self.REPLACEMENT_PRODUCT_LIST & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.CONDITION | + Feature.WARNING | + Feature.REPLACEMENT_PRODUCT_LIST + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_condition_set = Feature.is_condition_set, + set_condition = Feature.set_condition, + unset_condition = Feature.unset_condition, + is_warning_set = Feature.is_warning_set, + set_warning = Feature.set_warning, + unset_warning = Feature.unset_warning, + is_replacement_product_list_set = Feature.is_replacement_product_list_set, + set_replacement_product_list = Feature.set_replacement_product_list, + unset_replacement_product_list = Feature.unset_replacement_product_list, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua new file mode 100644 index 0000000000..f4eea9ee26 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("HepaFilterMonitoring.types." .. key) + end + return types_mt.__types_cache[key] +end + +local HepaFilterMonitoringTypes = {} + +setmetatable(HepaFilterMonitoringTypes, types_mt) + +return HepaFilterMonitoringTypes + diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua new file mode 100644 index 0000000000..c6ff01850b --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua @@ -0,0 +1,76 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local LaundryWasherControlsServerAttributes = require "LaundryWasherControls.server.attributes" +local LaundryWasherControlsTypes = require "LaundryWasherControls.types" + +local LaundryWasherControls = {} + +LaundryWasherControls.ID = 0x0053 +LaundryWasherControls.NAME = "LaundryWasherControls" +LaundryWasherControls.server = {} +LaundryWasherControls.client = {} +LaundryWasherControls.server.attributes = LaundryWasherControlsServerAttributes:set_parent_cluster(LaundryWasherControls) +LaundryWasherControls.types = LaundryWasherControlsTypes + +function LaundryWasherControls:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SpinSpeeds", + [0x0001] = "SpinSpeedCurrent", + [0x0002] = "NumberOfRinses", + [0x0003] = "SupportedRinses", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function LaundryWasherControls:get_server_command_by_id(command_id) + local server_id_map = { + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +LaundryWasherControls.attribute_direction_map = { + ["SpinSpeeds"] = "server", + ["SpinSpeedCurrent"] = "server", + ["NumberOfRinses"] = "server", + ["SupportedRinses"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +LaundryWasherControls.command_direction_map = { +} + +LaundryWasherControls.FeatureMap = LaundryWasherControls.types.Feature + +function LaundryWasherControls.are_features_supported(feature, feature_map) + if (LaundryWasherControls.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = LaundryWasherControls.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, LaundryWasherControls.NAME)) + end + return LaundryWasherControls[direction].attributes[key] +end +LaundryWasherControls.attributes = {} +setmetatable(LaundryWasherControls.attributes, attribute_helper_mt) + +return LaundryWasherControls diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/NumberOfRinses.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/NumberOfRinses.lua new file mode 100644 index 0000000000..651a2b1f16 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/NumberOfRinses.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfRinses = { + ID = 0x0002, + NAME = "NumberOfRinses", + base_type = require "LaundryWasherControls.types.NumberOfRinsesEnum", +} + +function NumberOfRinses:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function NumberOfRinses:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfRinses:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function NumberOfRinses:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfRinses:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfRinses:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfRinses:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(NumberOfRinses, {__call = NumberOfRinses.new_value, __index = NumberOfRinses.base_type}) +return NumberOfRinses diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeedCurrent.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeedCurrent.lua new file mode 100644 index 0000000000..f472b3e30e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeedCurrent.lua @@ -0,0 +1,87 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SpinSpeedCurrent = { + ID = 0x0001, + NAME = "SpinSpeedCurrent", + base_type = require "st.matter.data_types.Uint8", +} + +function SpinSpeedCurrent:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SpinSpeedCurrent:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SpinSpeedCurrent:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function SpinSpeedCurrent:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SpinSpeedCurrent:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + + + + + + + + +function SpinSpeedCurrent:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SpinSpeedCurrent:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SpinSpeedCurrent, {__call = SpinSpeedCurrent.new_value, __index = SpinSpeedCurrent.base_type}) +return SpinSpeedCurrent diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeeds.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeeds.lua new file mode 100644 index 0000000000..97fea492b8 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SpinSpeeds.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SpinSpeeds = { + ID = 0x0000, + NAME = "SpinSpeeds", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.UTF8String1", +} + +function SpinSpeeds:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SpinSpeeds.element_type) + end +end + +function SpinSpeeds:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SpinSpeeds:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SpinSpeeds:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SpinSpeeds:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SpinSpeeds:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SpinSpeeds:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SpinSpeeds, {__call = SpinSpeeds.new_value, __index = SpinSpeeds.base_type}) +return SpinSpeeds diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SupportedRinses.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SupportedRinses.lua new file mode 100644 index 0000000000..c9c702f5d8 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/SupportedRinses.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedRinses = { + ID = 0x0003, + NAME = "SupportedRinses", + base_type = require "st.matter.data_types.Array", + element_type = require "LaundryWasherControls.types.NumberOfRinsesEnum", +} + +function SupportedRinses:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedRinses.element_type) + end +end + +function SupportedRinses:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedRinses:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedRinses:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedRinses:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedRinses:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedRinses:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedRinses, {__call = SupportedRinses.new_value, __index = SupportedRinses.base_type}) +return SupportedRinses diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/init.lua new file mode 100644 index 0000000000..ffe42e005b --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("LaundryWasherControls.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local LaundryWasherControlsServerAttributes = {} + +function LaundryWasherControlsServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(LaundryWasherControlsServerAttributes, attr_mt) + +return LaundryWasherControlsServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/Feature.lua new file mode 100644 index 0000000000..80a82b1238 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/Feature.lua @@ -0,0 +1,75 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.SPIN = 0x0001 +Feature.RINSE = 0x0002 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + SPIN = 0x0001, + RINSE = 0x0002, +} + +Feature.is_spin_set = function(self) + return (self.value & self.SPIN) ~= 0 +end + +Feature.set_spin = function(self) + if self.value ~= nil then + self.value = self.value | self.SPIN + else + self.value = self.SPIN + end +end + +Feature.unset_spin = function(self) + self.value = self.value & (~self.SPIN & self.BASE_MASK) +end + +Feature.is_rinse_set = function(self) + return (self.value & self.RINSE) ~= 0 +end + +Feature.set_rinse = function(self) + if self.value ~= nil then + self.value = self.value | self.RINSE + else + self.value = self.RINSE + end +end + +Feature.unset_rinse = function(self) + self.value = self.value & (~self.RINSE & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.SPIN | + Feature.RINSE + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_spin_set = Feature.is_spin_set, + set_spin = Feature.set_spin, + unset_spin = Feature.unset_spin, + is_rinse_set = Feature.is_rinse_set, + set_rinse = Feature.set_rinse, + unset_rinse = Feature.unset_rinse, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/NumberOfRinsesEnum.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/NumberOfRinsesEnum.lua new file mode 100644 index 0000000000..41abe4876f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/NumberOfRinsesEnum.lua @@ -0,0 +1,35 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local NumberOfRinsesEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NONE] = "NONE", + [self.NORMAL] = "NORMAL", + [self.EXTRA] = "EXTRA", + [self.MAX] = "MAX", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NONE = 0x00 +new_mt.__index.NORMAL = 0x01 +new_mt.__index.EXTRA = 0x02 +new_mt.__index.MAX = 0x03 + +NumberOfRinsesEnum.NONE = 0x00 +NumberOfRinsesEnum.NORMAL = 0x01 +NumberOfRinsesEnum.EXTRA = 0x02 +NumberOfRinsesEnum.MAX = 0x03 + +NumberOfRinsesEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(NumberOfRinsesEnum, new_mt) + +return NumberOfRinsesEnum diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua new file mode 100644 index 0000000000..4ba9f25726 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("LaundryWasherControls.types." .. key) + end + return types_mt.__types_cache[key] +end + +local LaundryWasherControlsTypes = {} + +setmetatable(LaundryWasherControlsTypes, types_mt) + +return LaundryWasherControlsTypes diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua new file mode 100644 index 0000000000..e64bde94a0 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua @@ -0,0 +1,96 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local LaundryWasherModeServerAttributes = require "LaundryWasherMode.server.attributes" +local LaundryWasherModeServerCommands = require "LaundryWasherMode.server.commands" +local LaundryWasherModeTypes = require "LaundryWasherMode.types" + +local LaundryWasherMode = {} + +LaundryWasherMode.ID = 0x0051 +LaundryWasherMode.NAME = "LaundryWasherMode" +LaundryWasherMode.server = {} +LaundryWasherMode.client = {} +LaundryWasherMode.server.attributes = LaundryWasherModeServerAttributes:set_parent_cluster(LaundryWasherMode) +LaundryWasherMode.server.commands = LaundryWasherModeServerCommands:set_parent_cluster(LaundryWasherMode) +LaundryWasherMode.types = LaundryWasherModeTypes + +function LaundryWasherMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0x0002] = "StartUpMode", + [0x0003] = "OnMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function LaundryWasherMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +LaundryWasherMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["StartUpMode"] = "server", + ["OnMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +LaundryWasherMode.command_direction_map = { + ["ChangeToMode"] = "server", + ["ChangeToModeResponse"] = "client", +} + +LaundryWasherMode.FeatureMap = LaundryWasherMode.types.Feature + +function LaundryWasherMode.are_features_supported(feature, feature_map) + if (LaundryWasherMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = LaundryWasherMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, LaundryWasherMode.NAME)) + end + return LaundryWasherMode[direction].attributes[key] +end +LaundryWasherMode.attributes = {} +setmetatable(LaundryWasherMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = LaundryWasherMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, LaundryWasherMode.NAME)) + end + return LaundryWasherMode[direction].commands[key] +end +LaundryWasherMode.commands = {} +setmetatable(LaundryWasherMode.commands, command_helper_mt) + +setmetatable(LaundryWasherMode, {__index = cluster_base}) + +return LaundryWasherMode diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..f2987e294d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/CurrentMode.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/OnMode.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/OnMode.lua new file mode 100644 index 0000000000..76fb4b2bc2 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/OnMode.lua @@ -0,0 +1,87 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OnMode = { + ID = 0x0003, + NAME = "OnMode", + base_type = require "st.matter.data_types.Uint8", +} + +function OnMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function OnMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OnMode:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function OnMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OnMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + + + + + + + + +function OnMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OnMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(OnMode, {__call = OnMode.new_value, __index = OnMode.base_type}) +return OnMode diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/StartUpMode.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/StartUpMode.lua new file mode 100644 index 0000000000..b95c70d359 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/StartUpMode.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local StartUpMode = { + ID = 0x0002, + NAME = "StartUpMode", + base_type = require "st.matter.data_types.Uint8", +} + +function StartUpMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function StartUpMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StartUpMode:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function StartUpMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StartUpMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function StartUpMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function StartUpMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(StartUpMode, {__call = StartUpMode.new_value, __index = StartUpMode.base_type}) +return StartUpMode diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..61bcd10ee8 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/SupportedModes.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "LaundryWasherMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/init.lua new file mode 100644 index 0000000000..1a1d87f71b --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("LaundryWasherMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local LaundryWasherModeServerAttributes = {} + +function LaundryWasherModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(LaundryWasherModeServerAttributes, attr_mt) + +return LaundryWasherModeServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..4e492640a9 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/ChangeToMode.lua @@ -0,0 +1,86 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/init.lua new file mode 100644 index 0000000000..c3c0e92a1f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("LaundryWasherMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local LaundryWasherModeServerCommands = {} + +function LaundryWasherModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(LaundryWasherModeServerCommands, command_mt) + +return LaundryWasherModeServerCommands diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/Feature.lua new file mode 100644 index 0000000000..7f38a4994f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/Feature.lua @@ -0,0 +1,53 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..659791d290 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeOptionStruct.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "LaundryWasherMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTag.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTag.lua new file mode 100644 index 0000000000..3f39383a88 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTag.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NORMAL] = "NORMAL", + [self.DELICATE] = "DELICATE", + [self.HEAVY] = "HEAVY", + [self.WHITES] = "WHITES", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NORMAL = 0x4000 +new_mt.__index.DELICATE = 0x4001 +new_mt.__index.HEAVY = 0x4002 +new_mt.__index.WHITES = 0x4003 + +ModeTag.NORMAL = 0x4000 +ModeTag.DELICATE = 0x4001 +ModeTag.HEAVY = 0x4002 +ModeTag.WHITES = 0x4003 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..610fb90ad7 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/ModeTagStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua new file mode 100644 index 0000000000..d201d4ba36 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("LaundryWasherMode.types." .. key) + end + return types_mt.__types_cache[key] +end + +local LaundryWasherModeTypes = {} + +setmetatable(LaundryWasherModeTypes, types_mt) + +return LaundryWasherModeTypes diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua new file mode 100644 index 0000000000..5c333dea1e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua @@ -0,0 +1,88 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local MicrowaveOvenControlServerAttributes = require "MicrowaveOvenControl.server.attributes" +local MicrowaveOvenControlServerCommands = require "MicrowaveOvenControl.server.commands" +local MicrowaveOvenControlTypes = require "MicrowaveOvenControl.types" + +local MicrowaveOvenControl = {} + +MicrowaveOvenControl.ID = 0x005F +MicrowaveOvenControl.NAME = "MicrowaveOvenControl" +MicrowaveOvenControl.server = {} +MicrowaveOvenControl.client = {} +MicrowaveOvenControl.server.attributes = MicrowaveOvenControlServerAttributes:set_parent_cluster(MicrowaveOvenControl) +MicrowaveOvenControl.server.commands = MicrowaveOvenControlServerCommands:set_parent_cluster(MicrowaveOvenControl) +MicrowaveOvenControl.types = MicrowaveOvenControlTypes + +function MicrowaveOvenControl:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "CookTime", + [0x0001] = "MaxCookTime", + [0xFFF9] = "AcceptedCommandList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function MicrowaveOvenControl:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "SetCookingParameters", + [0x0001] = "AddMoreTime", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +MicrowaveOvenControl.attribute_direction_map = { + ["CookTime"] = "server", + ["MaxCookTime"] = "server", + ["AcceptedCommandList"] = "server", +} + +MicrowaveOvenControl.command_direction_map = { + ["SetCookingParameters"] = "server", + ["AddMoreTime"] = "server", +} + +MicrowaveOvenControl.FeatureMap = MicrowaveOvenControl.types.Feature + +function MicrowaveOvenControl.are_features_supported(feature, feature_map) + if (MicrowaveOvenControl.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = MicrowaveOvenControl.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, MicrowaveOvenControl.NAME)) + end + return MicrowaveOvenControl[direction].attributes[key] +end +MicrowaveOvenControl.attributes = {} +setmetatable(MicrowaveOvenControl.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = MicrowaveOvenControl.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, MicrowaveOvenControl.NAME)) + end + return MicrowaveOvenControl[direction].commands[key] +end +MicrowaveOvenControl.commands = {} +setmetatable(MicrowaveOvenControl.commands, command_helper_mt) + +setmetatable(MicrowaveOvenControl, {__index = cluster_base}) + +return MicrowaveOvenControl diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..1c41ddafa1 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/CookTime.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/CookTime.lua new file mode 100644 index 0000000000..7af3f4a3da --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/CookTime.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CookTime = { + ID = 0x0000, + NAME = "CookTime", + base_type = require "st.matter.data_types.Uint32", +} + +function CookTime:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CookTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CookTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CookTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CookTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CookTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CookTime, {__call = CookTime.new_value, __index = CookTime.base_type}) +return CookTime + diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/MaxCookTime.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/MaxCookTime.lua new file mode 100644 index 0000000000..1eba1d229f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/MaxCookTime.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MaxCookTime = { + ID = 0x0001, + NAME = "MaxCookTime", + base_type = require "st.matter.data_types.Uint32", +} + +function MaxCookTime:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function MaxCookTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MaxCookTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MaxCookTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MaxCookTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MaxCookTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MaxCookTime, {__call = MaxCookTime.new_value, __index = MaxCookTime.base_type}) +return MaxCookTime + diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/init.lua new file mode 100644 index 0000000000..7dadc923c6 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("MicrowaveOvenControl.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local MicrowaveOvenControlServerAttributes = {} + +function MicrowaveOvenControlServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(MicrowaveOvenControlServerAttributes, attr_mt) + +return MicrowaveOvenControlServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/AddMoreTime.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/AddMoreTime.lua new file mode 100644 index 0000000000..d9372ff1ec --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/AddMoreTime.lua @@ -0,0 +1,97 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AddMoreTime = {} + +AddMoreTime.NAME = "AddMoreTime" +AddMoreTime.ID = 0x0001 +AddMoreTime.field_defs = { + { + name = "time_to_add", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, +} + +function AddMoreTime:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function AddMoreTime:init(device, endpoint_id, time_to_add) + local out = {} + local args = {time_to_add} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = AddMoreTime, + __tostring = AddMoreTime.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function AddMoreTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AddMoreTime:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function AddMoreTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AddMoreTime, {__call = AddMoreTime.init}) + +return AddMoreTime \ No newline at end of file diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/SetCookingParameters.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/SetCookingParameters.lua new file mode 100644 index 0000000000..5ef5e16190 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/SetCookingParameters.lua @@ -0,0 +1,125 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetCookingParameters = {} + +SetCookingParameters.NAME = "SetCookingParameters" +SetCookingParameters.ID = 0x0000 +SetCookingParameters.field_defs = { + { + name = "cook_mode", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "cook_time", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "power_setting", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "watt_setting_index", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "start_after_setting", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, +} + +function SetCookingParameters:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function SetCookingParameters:init(device, endpoint_id, cook_mode, cook_time, power_setting, watt_setting_index, start_after_setting) + local out = {} + local args = {cook_mode, cook_time, power_setting, watt_setting_index, start_after_setting} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetCookingParameters, + __tostring = SetCookingParameters.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function SetCookingParameters:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetCookingParameters:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetCookingParameters:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetCookingParameters, {__call = SetCookingParameters.init}) + +return SetCookingParameters \ No newline at end of file diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/init.lua new file mode 100644 index 0000000000..1b345a90d6 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("MicrowaveOvenControl.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local MicrowaveOvenControlServerCommands = {} + +function MicrowaveOvenControlServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(MicrowaveOvenControlServerCommands, command_mt) + +return MicrowaveOvenControlServerCommands diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/Feature.lua new file mode 100644 index 0000000000..31a881aff6 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/Feature.lua @@ -0,0 +1,97 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.POWER_AS_NUMBER = 0x0001 +Feature.POWER_IN_WATTS = 0x0002 +Feature.POWER_NUMBER_LIMITS = 0x0004 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + POWER_AS_NUMBER = 0x0001, + POWER_IN_WATTS = 0x0002, + POWER_NUMBER_LIMITS = 0x0004, +} + +Feature.is_power_as_number_set = function(self) + return (self.value & self.POWER_AS_NUMBER) ~= 0 +end + +Feature.set_power_as_number = function(self) + if self.value ~= nil then + self.value = self.value | self.POWER_AS_NUMBER + else + self.value = self.POWER_AS_NUMBER + end +end + +Feature.unset_power_as_number = function(self) + self.value = self.value & (~self.POWER_AS_NUMBER & self.BASE_MASK) +end + +Feature.is_power_in_watts_set = function(self) + return (self.value & self.POWER_IN_WATTS) ~= 0 +end + +Feature.set_power_in_watts = function(self) + if self.value ~= nil then + self.value = self.value | self.POWER_IN_WATTS + else + self.value = self.POWER_IN_WATTS + end +end + +Feature.unset_power_in_watts = function(self) + self.value = self.value & (~self.POWER_IN_WATTS & self.BASE_MASK) +end + +Feature.is_power_number_limits_set = function(self) + return (self.value & self.POWER_NUMBER_LIMITS) ~= 0 +end + +Feature.set_power_number_limits = function(self) + if self.value ~= nil then + self.value = self.value | self.POWER_NUMBER_LIMITS + else + self.value = self.POWER_NUMBER_LIMITS + end +end + +Feature.unset_power_number_limits = function(self) + self.value = self.value & (~self.POWER_NUMBER_LIMITS & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.POWER_AS_NUMBER | + Feature.POWER_IN_WATTS | + Feature.POWER_NUMBER_LIMITS + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_power_as_number_set = Feature.is_power_as_number_set, + set_power_as_number = Feature.set_power_as_number, + unset_power_as_number = Feature.unset_power_as_number, + is_power_in_watts_set = Feature.is_power_in_watts_set, + set_power_in_watts = Feature.set_power_in_watts, + unset_power_in_watts = Feature.unset_power_in_watts, + is_power_number_limits_set = Feature.is_power_number_limits_set, + set_power_number_limits = Feature.set_power_number_limits, + unset_power_number_limits = Feature.unset_power_number_limits, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature \ No newline at end of file diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua new file mode 100644 index 0000000000..1d11195ee2 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("MicrowaveOvenControl.types." .. key) + end + return types_mt.__types_cache[key] +end + +local MicrowaveOvenControlTypes = {} + +setmetatable(MicrowaveOvenControlTypes, types_mt) + +return MicrowaveOvenControlTypes + diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua new file mode 100644 index 0000000000..dca8ff3ed5 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua @@ -0,0 +1,72 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local MicrowaveOvenModeServerAttributes = require "MicrowaveOvenMode.server.attributes" +local MicrowaveOvenModeTypes = require "MicrowaveOvenMode.types" + +local MicrowaveOvenMode = {} + +MicrowaveOvenMode.ID = 0x005E +MicrowaveOvenMode.NAME = "MicrowaveOvenMode" +MicrowaveOvenMode.server = {} +MicrowaveOvenMode.client = {} +MicrowaveOvenMode.server.attributes = MicrowaveOvenModeServerAttributes:set_parent_cluster(MicrowaveOvenMode) +MicrowaveOvenMode.types = MicrowaveOvenModeTypes + +function MicrowaveOvenMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function MicrowaveOvenMode:get_server_command_by_id(command_id) + local server_id_map = { + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +MicrowaveOvenMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +MicrowaveOvenMode.FeatureMap = MicrowaveOvenMode.types.Feature + +function MicrowaveOvenMode.are_features_supported(feature, feature_map) + if (MicrowaveOvenMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = MicrowaveOvenMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, MicrowaveOvenMode.NAME)) + end + return MicrowaveOvenMode[direction].attributes[key] +end +MicrowaveOvenMode.attributes = {} +setmetatable(MicrowaveOvenMode.attributes, attribute_helper_mt) + +setmetatable(MicrowaveOvenMode, {__index = cluster_base}) + +return MicrowaveOvenMode diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..1c41ddafa1 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..242a598bfd --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/CurrentMode.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode + diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..c6c19b685e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/SupportedModes.lua @@ -0,0 +1,75 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "MicrowaveOvenMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes + diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/init.lua new file mode 100644 index 0000000000..a7fd9d169c --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("MicrowaveOvenMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local MicrowaveOvenModeServerAttributes = {} + +function MicrowaveOvenModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(MicrowaveOvenModeServerAttributes, attr_mt) + +return MicrowaveOvenModeServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/Feature.lua new file mode 100644 index 0000000000..16f8292349 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/Feature.lua @@ -0,0 +1,53 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature \ No newline at end of file diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..f834d4be6e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeOptionStruct.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "MicrowaveOvenMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct + diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTag.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTag.lua new file mode 100644 index 0000000000..1ede9e638a --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTag.lua @@ -0,0 +1,27 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NORMAL] = "NORMAL", + [self.DEFROST] = "DEFROST", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NORMAL = 0x4000 +new_mt.__index.DEFROST = 0x4001 + +ModeTag.NORMAL = 0x4000 +ModeTag.DEFROST = 0x4001 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..700ad7f597 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/ModeTagStruct.lua @@ -0,0 +1,78 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct + diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua new file mode 100644 index 0000000000..e67593141b --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + local req_loc = string.format("MicrowaveOvenMode.types.%s", key) + local cluster_type = require(req_loc) + types_mt.__types_cache[key] = cluster_type + end + return types_mt.__types_cache[key] +end + +local MicrowaveOvenModeTypes = {} + +setmetatable(MicrowaveOvenModeTypes, types_mt) + +return MicrowaveOvenModeTypes + diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/init.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/init.lua new file mode 100644 index 0000000000..7be274fb8d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/init.lua @@ -0,0 +1,126 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local OperationalStateServerAttributes = require "OperationalState.server.attributes" +local OperationalStateServerCommands = require "OperationalState.server.commands" +local OperationalStateEvents = require "OperationalState.server.events" +local OperationalStateTypes = require "OperationalState.types" + +local OperationalState = {} + +OperationalState.ID = 0x0060 +OperationalState.NAME = "OperationalState" +OperationalState.server = {} +OperationalState.client = {} +OperationalState.server.attributes = OperationalStateServerAttributes:set_parent_cluster(OperationalState) +OperationalState.server.commands = OperationalStateServerCommands:set_parent_cluster(OperationalState) +OperationalState.server.events = OperationalStateEvents:set_parent_cluster(OperationalState) +OperationalState.types = OperationalStateTypes + +function OperationalState:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "PhaseList", + [0x0001] = "CurrentPhase", + [0x0002] = "CountdownTime", + [0x0003] = "OperationalStateList", + [0x0004] = "OperationalState", + [0x0005] = "OperationalError", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function OperationalState:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "Pause", + [0x0001] = "Stop", + [0x0002] = "Start", + [0x0003] = "Resume", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function OperationalState:get_client_command_by_id(command_id) + local client_id_map = { + [0x0004] = "OperationalCommandResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +function OperationalState:get_event_by_id(event_id) + local event_id_map = { + [0x0000] = "OperationalError", + [0x0001] = "OperationCompletion", + } + if event_id_map[event_id] ~= nil then + return self.server.events[event_id_map[event_id]] + end + return nil +end + +OperationalState.attribute_direction_map = { + ["PhaseList"] = "server", + ["CurrentPhase"] = "server", + ["CountdownTime"] = "server", + ["OperationalStateList"] = "server", + ["OperationalState"] = "server", + ["OperationalError"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +OperationalState.command_direction_map = { + ["Pause"] = "server", + ["Stop"] = "server", + ["Start"] = "server", + ["Resume"] = "server", + ["OperationalCommandResponse"] = "client", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = OperationalState.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, OperationalState.NAME)) + end + return OperationalState[direction].attributes[key] +end +OperationalState.attributes = {} +setmetatable(OperationalState.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = OperationalState.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, OperationalState.NAME)) + end + return OperationalState[direction].commands[key] +end +OperationalState.commands = {} +setmetatable(OperationalState.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return OperationalState.server.events[key] +end +OperationalState.events = {} +setmetatable(OperationalState.events, event_helper_mt) + +setmetatable(OperationalState, {__index = cluster_base}) + +return OperationalState diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..f86ac60dbe --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CountdownTime.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CountdownTime.lua new file mode 100644 index 0000000000..d8cb7add45 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CountdownTime.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CountdownTime = { + ID = 0x0002, + NAME = "CountdownTime", + base_type = require "st.matter.data_types.Uint32", +} + +function CountdownTime:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CountdownTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CountdownTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CountdownTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CountdownTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CountdownTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CountdownTime, {__call = CountdownTime.new_value, __index = CountdownTime.base_type}) +return CountdownTime diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CurrentPhase.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CurrentPhase.lua new file mode 100644 index 0000000000..206463b1c4 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/CurrentPhase.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentPhase = { + ID = 0x0001, + NAME = "CurrentPhase", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentPhase:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentPhase:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentPhase:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentPhase:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentPhase:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentPhase:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentPhase, {__call = CurrentPhase.new_value, __index = CurrentPhase.base_type}) +return CurrentPhase diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalError.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalError.lua new file mode 100644 index 0000000000..c0dd2b9604 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalError.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperationalError = { + ID = 0x0005, + NAME = "OperationalError", + base_type = require "OperationalState.types.ErrorStateStruct", +} + +function OperationalError:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OperationalError:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalError:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalError:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperationalError:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalError:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OperationalError, {__call = OperationalError.new_value, __index = OperationalError.base_type}) +return OperationalError diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalState.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalState.lua new file mode 100644 index 0000000000..97493ede3f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperationalState = { + ID = 0x0004, + NAME = "OperationalState", + base_type = require "OperationalState.types.OperationalStateEnum", +} + +function OperationalState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OperationalState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperationalState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OperationalState, {__call = OperationalState.new_value, __index = OperationalState.base_type}) +return OperationalState diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalStateList.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalStateList.lua new file mode 100644 index 0000000000..e39dc333bd --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/OperationalStateList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperationalStateList = { + ID = 0x0003, + NAME = "OperationalStateList", + base_type = require "st.matter.data_types.Array", + element_type = require "OperationalState.types.OperationalStateStruct", +} + +function OperationalStateList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, OperationalStateList.element_type) + end +end + +function OperationalStateList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OperationalStateList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalStateList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalStateList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperationalStateList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalStateList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OperationalStateList, {__call = OperationalStateList.new_value, __index = OperationalStateList.base_type}) +return OperationalStateList diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/PhaseList.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/PhaseList.lua new file mode 100644 index 0000000000..9e38dac371 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/PhaseList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PhaseList = { + ID = 0x0000, + NAME = "PhaseList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.UTF8String1", +} + +function PhaseList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, PhaseList.element_type) + end +end + +function PhaseList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function PhaseList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PhaseList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PhaseList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PhaseList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PhaseList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(PhaseList, {__call = PhaseList.new_value, __index = PhaseList.base_type}) +return PhaseList diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/init.lua new file mode 100644 index 0000000000..0dc88c6056 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("OperationalState.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local OperationalStateServerAttributes = {} + +function OperationalStateServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(OperationalStateServerAttributes, attr_mt) + +return OperationalStateServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Pause.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Pause.lua new file mode 100644 index 0000000000..c9ee879783 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Pause.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Pause = {} + +Pause.NAME = "Pause" +Pause.ID = 0x0000 +Pause.field_defs = { +} + +function Pause:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Pause, + __tostring = Pause.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Pause:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Pause:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Pause:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Pause, {__call = Pause.init}) + +return Pause diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Resume.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Resume.lua new file mode 100644 index 0000000000..fa397af871 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Resume.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Resume = {} + +Resume.NAME = "Resume" +Resume.ID = 0x0003 +Resume.field_defs = { +} + +function Resume:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Resume, + __tostring = Resume.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Resume:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Resume:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Resume:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Resume, {__call = Resume.init}) + +return Resume diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Start.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Start.lua new file mode 100644 index 0000000000..297ba1b4c2 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Start.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Start = {} + +Start.NAME = "Start" +Start.ID = 0x0002 +Start.field_defs = { +} + +function Start:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Start, + __tostring = Start.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Start:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Start:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Start:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Start, {__call = Start.init}) + +return Start diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Stop.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Stop.lua new file mode 100644 index 0000000000..67ea96d5bc --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/Stop.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Stop = {} + +Stop.NAME = "Stop" +Stop.ID = 0x0001 +Stop.field_defs = { +} + +function Stop:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Stop, + __tostring = Stop.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Stop:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Stop:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Stop:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Stop, {__call = Stop.init}) + +return Stop diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/init.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/init.lua new file mode 100644 index 0000000000..eb4d418fd4 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("OperationalState.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local OperationalStateServerCommands = {} + +function OperationalStateServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(OperationalStateServerCommands, command_mt) + +return OperationalStateServerCommands diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/events/OperationalError.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/events/OperationalError.lua new file mode 100644 index 0000000000..47b11f9792 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/events/OperationalError.lua @@ -0,0 +1,100 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OperationalError = { + ID = 0x0000, + NAME = "OperationalError", + base_type = data_types.Structure, +} + +OperationalError.field_defs = { + { + name = "error_state", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "OperationalState.types.ErrorStateStruct", + }, +} + +function OperationalError:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function OperationalError:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function OperationalError:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function OperationalError:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "OperationalErrorEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalError:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function OperationalError:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return OperationalError diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/server/events/init.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/server/events/init.lua new file mode 100644 index 0000000000..f33dc43140 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/server/events/init.lua @@ -0,0 +1,23 @@ +local event_mt = {} +event_mt.__event_cache = {} +event_mt.__index = function(self, key) + if event_mt.__event_cache[key] == nil then + local req_loc = string.format("OperationalState.server.events.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + event_mt.__event_cache[key] = raw_def + end + return event_mt.__event_cache[key] +end + +local OperationalStateEvents = {} + +function OperationalStateEvents:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(OperationalStateEvents, event_mt) + +return OperationalStateEvents diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateEnum.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateEnum.lua new file mode 100644 index 0000000000..df4c3f72e0 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateEnum.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ErrorStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "ErrorStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NO_ERROR] = "NO_ERROR", + [self.UNABLE_TO_START_OR_RESUME] = "UNABLE_TO_START_OR_RESUME", + [self.UNABLE_TO_COMPLETE_OPERATION] = "UNABLE_TO_COMPLETE_OPERATION", + [self.COMMAND_INVALID_IN_STATE] = "COMMAND_INVALID_IN_STATE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NO_ERROR = 0x00 +new_mt.__index.UNABLE_TO_START_OR_RESUME = 0x01 +new_mt.__index.UNABLE_TO_COMPLETE_OPERATION = 0x02 +new_mt.__index.COMMAND_INVALID_IN_STATE = 0x03 + +ErrorStateEnum.NO_ERROR = 0x00 +ErrorStateEnum.UNABLE_TO_START_OR_RESUME = 0x01 +ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION = 0x02 +ErrorStateEnum.COMMAND_INVALID_IN_STATE = 0x03 + +ErrorStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ErrorStateEnum, new_mt) + +return ErrorStateEnum diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateStruct.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateStruct.lua new file mode 100644 index 0000000000..5b3f3a0105 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/types/ErrorStateStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ErrorStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ErrorStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +ErrorStateStruct.field_defs = { + { + name = "error_state_id", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "error_state_label", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "error_state_details", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +ErrorStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ErrorStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ErrorStateStruct.init +new_mt.__index.serialize = ErrorStateStruct.serialize + +ErrorStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ErrorStateStruct, new_mt) + +return ErrorStateStruct diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateEnum.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateEnum.lua new file mode 100644 index 0000000000..1c8ae04208 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateEnum.lua @@ -0,0 +1,35 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local OperationalStateEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.STOPPED] = "STOPPED", + [self.RUNNING] = "RUNNING", + [self.PAUSED] = "PAUSED", + [self.ERROR] = "ERROR", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.STOPPED = 0x00 +new_mt.__index.RUNNING = 0x01 +new_mt.__index.PAUSED = 0x02 +new_mt.__index.ERROR = 0x03 + +OperationalStateEnum.STOPPED = 0x00 +OperationalStateEnum.RUNNING = 0x01 +OperationalStateEnum.PAUSED = 0x02 +OperationalStateEnum.ERROR = 0x03 + +OperationalStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(OperationalStateEnum, new_mt) + +return OperationalStateEnum diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateStruct.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateStruct.lua new file mode 100644 index 0000000000..f421d8c796 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/types/OperationalStateStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OperationalStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "OperationalStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +OperationalStateStruct.field_defs = { + { + name = "operational_state_id", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "operational_state_label", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +OperationalStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +OperationalStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = OperationalStateStruct.init +new_mt.__index.serialize = OperationalStateStruct.serialize + +OperationalStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(OperationalStateStruct, new_mt) + +return OperationalStateStruct diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua new file mode 100644 index 0000000000..18bbca0824 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("OperationalState.types." .. key) + end + return types_mt.__types_cache[key] +end + +local OperationalStateTypes = {} + +setmetatable(OperationalStateTypes, types_mt) + +return OperationalStateTypes diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/init.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/init.lua new file mode 100644 index 0000000000..4fc7c11376 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/init.lua @@ -0,0 +1,84 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local OvenModeServerAttributes = require "OvenMode.server.attributes" +local OvenModeServerCommands = require "OvenMode.server.commands" +local OvenModeTypes = require "OvenMode.types" + +local OvenMode = {} + +OvenMode.ID = 0x0049 +OvenMode.NAME = "OvenMode" +OvenMode.server = {} +OvenMode.client = {} +OvenMode.server.attributes = OvenModeServerAttributes:set_parent_cluster(OvenMode) +OvenMode.server.commands = OvenModeServerCommands:set_parent_cluster(OvenMode) +OvenMode.types = OvenModeTypes + +function OvenMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function OvenMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +OvenMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", +} + +OvenMode.command_direction_map = { + ["ChangeToMode"] = "server", +} + +OvenMode.FeatureMap = OvenMode.types.Feature + +function OvenMode.are_features_supported(feature, feature_map) + if (OvenMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = OvenMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, OvenMode.NAME)) + end + return OvenMode[direction].attributes[key] +end +OvenMode.attributes = {} +setmetatable(OvenMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = OvenMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, OvenMode.NAME)) + end + return OvenMode[direction].commands[key] +end +OvenMode.commands = {} +setmetatable(OvenMode.commands, command_helper_mt) + +setmetatable(OvenMode, {__index = cluster_base}) + +return OvenMode diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..f2987e294d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/CurrentMode.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..3803e1cd01 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/SupportedModes.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "OvenMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/init.lua new file mode 100644 index 0000000000..6527039dfc --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("OvenMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local OvenModeServerAttributes = {} + +function OvenModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(OvenModeServerAttributes, attr_mt) + +return OvenModeServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..4e492640a9 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/ChangeToMode.lua @@ -0,0 +1,86 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/init.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/init.lua new file mode 100644 index 0000000000..78434b8acd --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("OvenMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local OvenModeServerCommands = {} + +function OvenModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(OvenModeServerCommands, command_mt) + +return OvenModeServerCommands diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/types/Feature.lua new file mode 100644 index 0000000000..da49bf4115 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/types/Feature.lua @@ -0,0 +1,54 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..5278300221 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeOptionStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "OvenMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTag.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTag.lua new file mode 100644 index 0000000000..233b2a0905 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTag.lua @@ -0,0 +1,48 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.BAKE] = "BAKE", + [self.CONVECTION] = "CONVECTION", + [self.GRILL] = "GRILL", + [self.ROAST] = "ROAST", + [self.CLEAN] = "CLEAN", + [self.CONVECTION_BAKE] = "CONVECTION_BAKE", + [self.CONVECTION_ROAST] = "CONVECTION_ROAST", + [self.WARMING] = "WARMING", + [self.PROOFING] = "PROOFING", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.BAKE = 0x4000 +new_mt.__index.CONVECTION = 0x4001 +new_mt.__index.GRILL = 0x4002 +new_mt.__index.ROAST = 0x4003 +new_mt.__index.CLEAN = 0x4004 +new_mt.__index.CONVECTION_BAKE = 0x4005 +new_mt.__index.CONVECTION_ROAST = 0x4006 +new_mt.__index.WARMING = 0x4007 +new_mt.__index.PROOFING = 0x4008 + +ModeTag.BAKE = 0x4000 +ModeTag.CONVECTION = 0x4001 +ModeTag.GRILL = 0x4002 +ModeTag.ROAST = 0x4003 +ModeTag.CLEAN = 0x4004 +ModeTag.CONVECTION_BAKE = 0x4005 +ModeTag.CONVECTION_ROAST = 0x4006 +ModeTag.WARMING = 0x4007 +ModeTag.PROOFING = 0x4008 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..610fb90ad7 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/types/ModeTagStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua new file mode 100644 index 0000000000..b3565aaa2b --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("OvenMode.types." .. key) + end + return types_mt.__types_cache[key] +end + +local OvenModeTypes = {} + +setmetatable(OvenModeTypes, types_mt) + +return OvenModeTypes + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua new file mode 100644 index 0000000000..1c9610b520 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua @@ -0,0 +1,60 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local RefrigeratorAlarmServerAttributes = require "RefrigeratorAlarm.server.attributes" +local RefrigeratorAlarmTypes = require "RefrigeratorAlarm.types" + +local RefrigeratorAlarm = {} + +RefrigeratorAlarm.ID = 0x0057 +RefrigeratorAlarm.NAME = "RefrigeratorAlarm" +RefrigeratorAlarm.server = {} +RefrigeratorAlarm.client = {} +RefrigeratorAlarm.server.attributes = RefrigeratorAlarmServerAttributes:set_parent_cluster(RefrigeratorAlarm) +RefrigeratorAlarm.types = RefrigeratorAlarmTypes + +function RefrigeratorAlarm:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Mask", + [0x0002] = "State", + [0x0003] = "Supported", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +-- Attribute Mapping +RefrigeratorAlarm.attribute_direction_map = { + ["Mask"] = "server", + ["State"] = "server", + ["Supported"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +-- Command Mapping +RefrigeratorAlarm.command_direction_map = { +} + +-- Cluster Completion +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = RefrigeratorAlarm.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, RefrigeratorAlarm.NAME)) + end + return RefrigeratorAlarm[direction].attributes[key] +end +RefrigeratorAlarm.attributes = {} +setmetatable(RefrigeratorAlarm.attributes, attribute_helper_mt) + +return RefrigeratorAlarm + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Mask.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Mask.lua new file mode 100644 index 0000000000..fa89f0064e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Mask.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Mask = { + ID = 0x0000, + NAME = "Mask", + base_type = require "RefrigeratorAlarm.types.AlarmMap", +} + +function Mask:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function Mask:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Mask:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Mask:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Mask:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Mask:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Mask, {__call = Mask.new_value, __index = Mask.base_type}) +return Mask + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/State.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/State.lua new file mode 100644 index 0000000000..b5c7b393ff --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/State.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local State = { + ID = 0x0002, + NAME = "State", + base_type = require "RefrigeratorAlarm.types.AlarmMap", +} + +function State:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function State:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function State:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function State:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function State:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function State:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(State, {__call = State.new_value, __index = State.base_type}) +return State + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Supported.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Supported.lua new file mode 100644 index 0000000000..2014a4a7b7 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/Supported.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Supported = { + ID = 0x0003, + NAME = "Supported", + base_type = require "RefrigeratorAlarm.types.AlarmMap", +} + +function Supported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function Supported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Supported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Supported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Supported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Supported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Supported, {__call = Supported.new_value, __index = Supported.base_type}) +return Supported + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/init.lua new file mode 100644 index 0000000000..53cab62ef0 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/server/attributes/init.lua @@ -0,0 +1,24 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("RefrigeratorAlarm.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local RefrigeratorAlarmServerAttributes = {} + +function RefrigeratorAlarmServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RefrigeratorAlarmServerAttributes, attr_mt) + +return RefrigeratorAlarmServerAttributes + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/AlarmMap.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/AlarmMap.lua new file mode 100644 index 0000000000..e92e81f3c3 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/AlarmMap.lua @@ -0,0 +1,47 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local AlarmMap = {} +-- Note: the name here is intentionally set to Uint32 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint32", ID = data_types.name_to_id_map["Uint32"]}, 4) + +AlarmMap.BASE_MASK = 0xFFFF +AlarmMap.DOOR_OPEN = 0x0001 + +AlarmMap.mask_fields = { + BASE_MASK = 0xFFFF, + DOOR_OPEN = 0x0001, +} + +AlarmMap.is_door_open_set = function(self) + return (self.value & self.DOOR_OPEN) ~= 0 +end + +AlarmMap.set_door_open = function(self) + if self.value ~= nil then + self.value = self.value | self.DOOR_OPEN + else + self.value = self.DOOR_OPEN + end +end + +AlarmMap.unset_door_open = function(self) + self.value = self.value & (~self.DOOR_OPEN & self.BASE_MASK) +end + + +AlarmMap.mask_methods = { + is_door_open_set = AlarmMap.is_door_open_set, + set_door_open = AlarmMap.set_door_open, + unset_door_open = AlarmMap.unset_door_open, +} + +AlarmMap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(AlarmMap, new_mt) + +return AlarmMap + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua new file mode 100644 index 0000000000..d8ebdc1ed3 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("RefrigeratorAlarm.types." .. key) + end + return types_mt.__types_cache[key] +end + +local RefrigeratorAlarmTypes = {} + +setmetatable(RefrigeratorAlarmTypes, types_mt) + +return RefrigeratorAlarmTypes + diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua new file mode 100644 index 0000000000..345b7b57b1 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua @@ -0,0 +1,105 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local RefrigeratorAndTemperatureControlledCabinetModeServerAttributes = require "RefrigeratorAndTemperatureControlledCabinetMode.server.attributes" +local RefrigeratorAndTemperatureControlledCabinetModeServerCommands = require "RefrigeratorAndTemperatureControlledCabinetMode.server.commands" +local RefrigeratorAndTemperatureControlledCabinetModeTypes = require "RefrigeratorAndTemperatureControlledCabinetMode.types" + +local RefrigeratorAndTemperatureControlledCabinetMode = {} + +RefrigeratorAndTemperatureControlledCabinetMode.ID = 0x0052 +RefrigeratorAndTemperatureControlledCabinetMode.NAME = "RefrigeratorAndTemperatureControlledCabinetMode" +RefrigeratorAndTemperatureControlledCabinetMode.server = {} +RefrigeratorAndTemperatureControlledCabinetMode.client = {} +RefrigeratorAndTemperatureControlledCabinetMode.server.attributes = RefrigeratorAndTemperatureControlledCabinetModeServerAttributes:set_parent_cluster(RefrigeratorAndTemperatureControlledCabinetMode) +RefrigeratorAndTemperatureControlledCabinetMode.server.commands = RefrigeratorAndTemperatureControlledCabinetModeServerCommands:set_parent_cluster(RefrigeratorAndTemperatureControlledCabinetMode) +RefrigeratorAndTemperatureControlledCabinetMode.types = RefrigeratorAndTemperatureControlledCabinetModeTypes + +function RefrigeratorAndTemperatureControlledCabinetMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0x0002] = "StartUpMode", + [0x0003] = "OnMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function RefrigeratorAndTemperatureControlledCabinetMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function RefrigeratorAndTemperatureControlledCabinetMode:get_client_command_by_id(command_id) + local client_id_map = { + [0x0001] = "ChangeToModeResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +RefrigeratorAndTemperatureControlledCabinetMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["StartUpMode"] = "server", + ["OnMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +RefrigeratorAndTemperatureControlledCabinetMode.command_direction_map = { + ["ChangeToMode"] = "server", + ["ChangeToModeResponse"] = "client", +} + +RefrigeratorAndTemperatureControlledCabinetMode.FeatureMap = RefrigeratorAndTemperatureControlledCabinetMode.types.Feature + +function RefrigeratorAndTemperatureControlledCabinetMode.are_features_supported(feature, feature_map) + if (RefrigeratorAndTemperatureControlledCabinetMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = RefrigeratorAndTemperatureControlledCabinetMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, RefrigeratorAndTemperatureControlledCabinetMode.NAME)) + end + return RefrigeratorAndTemperatureControlledCabinetMode[direction].attributes[key] +end +RefrigeratorAndTemperatureControlledCabinetMode.attributes = {} +setmetatable(RefrigeratorAndTemperatureControlledCabinetMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = RefrigeratorAndTemperatureControlledCabinetMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, RefrigeratorAndTemperatureControlledCabinetMode.NAME)) + end + return RefrigeratorAndTemperatureControlledCabinetMode[direction].commands[key] +end +RefrigeratorAndTemperatureControlledCabinetMode.commands = {} +setmetatable(RefrigeratorAndTemperatureControlledCabinetMode.commands, command_helper_mt) + +setmetatable(RefrigeratorAndTemperatureControlledCabinetMode, {__index = cluster_base}) + +return RefrigeratorAndTemperatureControlledCabinetMode diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..f2987e294d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/CurrentMode.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..2b2a20c60f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/SupportedModes.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "RefrigeratorAndTemperatureControlledCabinetMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/init.lua new file mode 100644 index 0000000000..a794eb6c08 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("RefrigeratorAndTemperatureControlledCabinetMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local RefrigeratorAndTemperatureControlledCabinetModeServerAttributes = {} + +function RefrigeratorAndTemperatureControlledCabinetModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RefrigeratorAndTemperatureControlledCabinetModeServerAttributes, attr_mt) + +return RefrigeratorAndTemperatureControlledCabinetModeServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..4e492640a9 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/ChangeToMode.lua @@ -0,0 +1,86 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/init.lua new file mode 100644 index 0000000000..54005471ca --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("RefrigeratorAndTemperatureControlledCabinetMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local RefrigeratorAndTemperatureControlledCabinetModeServerCommands = {} + +function RefrigeratorAndTemperatureControlledCabinetModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RefrigeratorAndTemperatureControlledCabinetModeServerCommands, command_mt) + +return RefrigeratorAndTemperatureControlledCabinetModeServerCommands diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/Feature.lua new file mode 100644 index 0000000000..5f7522e7e3 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/Feature.lua @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + + + + + + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + + + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + + + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + + + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..8acbd99115 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeOptionStruct.lua @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + + + + + + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "RefrigeratorAndTemperatureControlledCabinetMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTag.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTag.lua new file mode 100644 index 0000000000..70ad8df5d8 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTag.lua @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + + + + + + + + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.RAPID_COOL] = "RAPID_COOL", + [self.RAPID_FREEZE] = "RAPID_FREEZE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.RAPID_COOL = 0x4000 +new_mt.__index.RAPID_FREEZE = 0x4001 + +ModeTag.RAPID_COOL = 0x4000 +ModeTag.RAPID_FREEZE = 0x4001 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..8f69f3ffab --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/ModeTagStruct.lua @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + + + + + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua new file mode 100644 index 0000000000..0079c8e25e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("RefrigeratorAndTemperatureControlledCabinetMode.types." .. key) + end + return types_mt.__types_cache[key] +end + +local RefrigeratorAndTemperatureControlledCabinetModeTypes = {} + +setmetatable(RefrigeratorAndTemperatureControlledCabinetModeTypes, types_mt) + +return RefrigeratorAndTemperatureControlledCabinetModeTypes diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua new file mode 100644 index 0000000000..2ab1c94056 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua @@ -0,0 +1,99 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local TemperatureControlServerAttributes = require "TemperatureControl.server.attributes" +local TemperatureControlServerCommands = require "TemperatureControl.server.commands" +local TemperatureControlTypes = require "TemperatureControl.types" + +local TemperatureControl = {} + +TemperatureControl.ID = 0x0056 +TemperatureControl.NAME = "TemperatureControl" +TemperatureControl.server = {} +TemperatureControl.client = {} +TemperatureControl.server.attributes = TemperatureControlServerAttributes:set_parent_cluster(TemperatureControl) +TemperatureControl.server.commands = TemperatureControlServerCommands:set_parent_cluster(TemperatureControl) +TemperatureControl.types = TemperatureControlTypes + +function TemperatureControl:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "TemperatureSetpoint", + [0x0001] = "MinTemperature", + [0x0002] = "MaxTemperature", + [0x0003] = "Step", + [0x0004] = "SelectedTemperatureLevel", + [0x0005] = "SupportedTemperatureLevels", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function TemperatureControl:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "SetTemperature", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +TemperatureControl.attribute_direction_map = { + ["TemperatureSetpoint"] = "server", + ["MinTemperature"] = "server", + ["MaxTemperature"] = "server", + ["Step"] = "server", + ["SelectedTemperatureLevel"] = "server", + ["SupportedTemperatureLevels"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +TemperatureControl.command_direction_map = { + ["SetTemperature"] = "server", +} + +TemperatureControl.FeatureMap = TemperatureControl.types.Feature + +function TemperatureControl.are_features_supported(feature, feature_map) + if (TemperatureControl.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = TemperatureControl.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, TemperatureControl.NAME)) + end + return TemperatureControl[direction].attributes[key] +end +TemperatureControl.attributes = {} +setmetatable(TemperatureControl.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = TemperatureControl.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, TemperatureControl.NAME)) + end + return TemperatureControl[direction].commands[key] +end +TemperatureControl.commands = {} +setmetatable(TemperatureControl.commands, command_helper_mt) + +setmetatable(TemperatureControl, {__index = cluster_base}) + +return TemperatureControl diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MaxTemperature.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MaxTemperature.lua new file mode 100644 index 0000000000..12cf0070e5 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MaxTemperature.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MaxTemperature = { + ID = 0x0002, + NAME = "MaxTemperature", + base_type = require "st.matter.data_types.Int16", +} + +function MaxTemperature:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function MaxTemperature:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MaxTemperature:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MaxTemperature:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MaxTemperature:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MaxTemperature:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MaxTemperature, {__call = MaxTemperature.new_value, __index = MaxTemperature.base_type}) +return MaxTemperature diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MinTemperature.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MinTemperature.lua new file mode 100644 index 0000000000..f81a6a3741 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/MinTemperature.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MinTemperature = { + ID = 0x0001, + NAME = "MinTemperature", + base_type = require "st.matter.data_types.Int16", +} + +function MinTemperature:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function MinTemperature:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MinTemperature:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MinTemperature:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MinTemperature:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MinTemperature:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MinTemperature, {__call = MinTemperature.new_value, __index = MinTemperature.base_type}) +return MinTemperature diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SelectedTemperatureLevel.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SelectedTemperatureLevel.lua new file mode 100644 index 0000000000..04b3d90571 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SelectedTemperatureLevel.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SelectedTemperatureLevel = { + ID = 0x0004, + NAME = "SelectedTemperatureLevel", + base_type = require "st.matter.data_types.Uint8", +} + +function SelectedTemperatureLevel:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SelectedTemperatureLevel:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SelectedTemperatureLevel:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SelectedTemperatureLevel:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SelectedTemperatureLevel:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SelectedTemperatureLevel:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SelectedTemperatureLevel, {__call = SelectedTemperatureLevel.new_value, __index = SelectedTemperatureLevel.base_type}) +return SelectedTemperatureLevel diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/Step.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/Step.lua new file mode 100644 index 0000000000..f01f66e3f2 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/Step.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Step = { + ID = 0x0003, + NAME = "Step", + base_type = require "st.matter.data_types.Int16", +} + +function Step:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Step:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Step:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Step:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Step:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Step:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Step, {__call = Step.new_value, __index = Step.base_type}) +return Step diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SupportedTemperatureLevels.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SupportedTemperatureLevels.lua new file mode 100644 index 0000000000..276a2d21e7 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/SupportedTemperatureLevels.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedTemperatureLevels = { + ID = 0x0005, + NAME = "SupportedTemperatureLevels", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.UTF8String1", +} + +function SupportedTemperatureLevels:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedTemperatureLevels.element_type) + end +end + +function SupportedTemperatureLevels:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SupportedTemperatureLevels:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedTemperatureLevels:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedTemperatureLevels:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedTemperatureLevels:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedTemperatureLevels:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SupportedTemperatureLevels, {__call = SupportedTemperatureLevels.new_value, __index = SupportedTemperatureLevels.base_type}) +return SupportedTemperatureLevels diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/TemperatureSetpoint.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/TemperatureSetpoint.lua new file mode 100644 index 0000000000..0ff770e543 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/TemperatureSetpoint.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local TemperatureSetpoint = { + ID = 0x0000, + NAME = "TemperatureSetpoint", + base_type = require "st.matter.data_types.Int16", +} + +function TemperatureSetpoint:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function TemperatureSetpoint:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TemperatureSetpoint:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TemperatureSetpoint:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function TemperatureSetpoint:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function TemperatureSetpoint:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(TemperatureSetpoint, {__call = TemperatureSetpoint.new_value, __index = TemperatureSetpoint.base_type}) +return TemperatureSetpoint diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/init.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/init.lua new file mode 100644 index 0000000000..2a38f12bd4 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("TemperatureControl.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local TemperatureControlServerAttributes = {} + +function TemperatureControlServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(TemperatureControlServerAttributes, attr_mt) + +return TemperatureControlServerAttributes diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/SetTemperature.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/SetTemperature.lua new file mode 100644 index 0000000000..78fc680be2 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/SetTemperature.lua @@ -0,0 +1,104 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetTemperature = {} + +SetTemperature.NAME = "SetTemperature" +SetTemperature.ID = 0x0000 +SetTemperature.field_defs = { + { + name = "target_temperature", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Int16", + }, + { + name = "target_temperature_level", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function SetTemperature:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SetTemperature:init(device, endpoint_id, target_temperature, target_temperature_level) + local out = {} + local args = {target_temperature, target_temperature_level} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetTemperature, + __tostring = SetTemperature.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function SetTemperature:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetTemperature:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetTemperature:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetTemperature, {__call = SetTemperature.init}) + +return SetTemperature diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/init.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/init.lua new file mode 100644 index 0000000000..b8aae164eb --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("TemperatureControl.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local TemperatureControlServerCommands = {} + +function TemperatureControlServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(TemperatureControlServerCommands, command_mt) + +return TemperatureControlServerCommands diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/Feature.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/Feature.lua new file mode 100644 index 0000000000..b60644ba93 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/Feature.lua @@ -0,0 +1,97 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.TEMPERATURE_NUMBER = 0x0001 +Feature.TEMPERATURE_LEVEL = 0x0002 +Feature.TEMPERATURE_STEP = 0x0004 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + TEMPERATURE_NUMBER = 0x0001, + TEMPERATURE_LEVEL = 0x0002, + TEMPERATURE_STEP = 0x0004, +} + +Feature.is_temperature_number_set = function(self) + return (self.value & self.TEMPERATURE_NUMBER) ~= 0 +end + +Feature.set_temperature_number = function(self) + if self.value ~= nil then + self.value = self.value | self.TEMPERATURE_NUMBER + else + self.value = self.TEMPERATURE_NUMBER + end +end + +Feature.unset_temperature_number = function(self) + self.value = self.value & (~self.TEMPERATURE_NUMBER & self.BASE_MASK) +end + +Feature.is_temperature_level_set = function(self) + return (self.value & self.TEMPERATURE_LEVEL) ~= 0 +end + +Feature.set_temperature_level = function(self) + if self.value ~= nil then + self.value = self.value | self.TEMPERATURE_LEVEL + else + self.value = self.TEMPERATURE_LEVEL + end +end + +Feature.unset_temperature_level = function(self) + self.value = self.value & (~self.TEMPERATURE_LEVEL & self.BASE_MASK) +end + +Feature.is_temperature_step_set = function(self) + return (self.value & self.TEMPERATURE_STEP) ~= 0 +end + +Feature.set_temperature_step = function(self) + if self.value ~= nil then + self.value = self.value | self.TEMPERATURE_STEP + else + self.value = self.TEMPERATURE_STEP + end +end + +Feature.unset_temperature_step = function(self) + self.value = self.value & (~self.TEMPERATURE_STEP & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.TEMPERATURE_NUMBER | + Feature.TEMPERATURE_LEVEL | + Feature.TEMPERATURE_STEP + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_temperature_number_set = Feature.is_temperature_number_set, + set_temperature_number = Feature.set_temperature_number, + unset_temperature_number = Feature.unset_temperature_number, + is_temperature_level_set = Feature.is_temperature_level_set, + set_temperature_level = Feature.set_temperature_level, + unset_temperature_level = Feature.unset_temperature_level, + is_temperature_step_set = Feature.is_temperature_step_set, + set_temperature_step = Feature.set_temperature_step, + unset_temperature_step = Feature.unset_temperature_step, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua new file mode 100644 index 0000000000..53c77d6ff9 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("TemperatureControl.types." .. key) + end + return types_mt.__types_cache[key] +end + +local TemperatureControlTypes = {} + +setmetatable(TemperatureControlTypes, types_mt) + +return TemperatureControlTypes diff --git a/drivers/SmartThings/matter-appliance/src/common-utils.lua b/drivers/SmartThings/matter-appliance/src/common-utils.lua new file mode 100644 index 0000000000..d35ee2b6fc --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/common-utils.lua @@ -0,0 +1,169 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "embedded-cluster-utils" +local im = require "st.matter.interaction_model" +local utils = require "st.utils" +local version = require "version" + +local extended_range_support = version.rpc >= 6 + +-- For RPC version <= 5, temperature conversion for setpoint ranges is not supported. When units are switched, +-- the units of the received command are unknown as the arguments don't contain the unit. To handle this, we +-- use a smaller range so that the temperature ranges for Celsius and Fahrenheit are separate. Take the laundry +-- washer range for example: +-- if the received setpoint command value is in range 13 ~ 55, it is inferred as *C +-- if the received setpoint command value is in range 55.4 ~ 131, it is inferred as *F +-- For RPC version >= 6, we can always assume that the values received from temperatureSetpoint are in Celsius, +-- so we can support a larger setpoint range, but we still limit the range to reasonable values. +local default_min_and_max_temp_by_device_type = { + ["dishwasher"] = { min_temp = extended_range_support and 0.0 or 33.0, max_temp = extended_range_support and 100.0 or 90.0 }, + ["dryer"] = { min_temp = extended_range_support and 0.0 or 27.0, max_temp = extended_range_support and 100.0 or 80.0 }, + ["washer"] = { min_temp = extended_range_support and 0.0 or 13.0, max_temp = extended_range_support and 100.0 or 55.0 }, + ["oven"] = { min_temp = extended_range_support and 0.0 or 127.0, max_temp = extended_range_support and 400.0 or 260.0 }, + ["refrigerator"] = { min_temp = extended_range_support and -10.0 or -6.0, max_temp = extended_range_support and 30.0 or 20.0 }, + ["freezer"] = { min_temp = extended_range_support and -30.0 or -24.0, max_temp = extended_range_support and 0.0 or -12.0 }, + ["default"] = { min_temp = 0.0, max_temp = extended_range_support and 100.0 or 40.0 } +} + +local common_utils = {} + +common_utils.COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" +common_utils.SUPPORTED_TEMPERATURE_LEVELS_MAP = "__supported_temperature_levels_map" + +common_utils.updated_fields = { + { current_field_name = "__supported_temperature_levels", updated_field_name = common_utils.SUPPORTED_TEMPERATURE_LEVELS_MAP } +} + +common_utils.setpoint_limit_device_field = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + +function common_utils.check_field_name_updates(device) + for _, field in ipairs(common_utils.updated_fields) do + if device:get_field(field.current_field_name) then + if field.updated_field_name ~= nil then + device:set_field(field.updated_field_name, device:get_field(field.current_field_name), {persist = true}) + end + device:set_field(field.current_field_name, nil) + end + end +end + +function common_utils.get_endpoints_for_dt(device, device_type) + local endpoints = {} + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == device_type then + table.insert(endpoints, ep.endpoint_id) + break + end + end + end + table.sort(endpoints) + return endpoints +end + +function common_utils.query_setpoint_limits(device) + local setpoint_limit_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + if device:get_field(common_utils.setpoint_limit_device_field.MIN_TEMP) == nil then + setpoint_limit_read:merge(clusters.TemperatureControl.attributes.MinTemperature:read()) + end + if device:get_field(common_utils.setpoint_limit_device_field.MAX_TEMP) == nil then + setpoint_limit_read:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + end + if #setpoint_limit_read.info_blocks ~= 0 then + device:send(setpoint_limit_read) + end +end + +function common_utils.supports_temperature_level_endpoint(device, endpoint) + local feature = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL + local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = feature}) + if #tl_eps == 0 then + device.log.warn(string.format("Device does not support TEMPERATURE_LEVEL feature")) + return false + end + for _, eps in ipairs(tl_eps) do + if eps == endpoint then + return true + end + end + device.log.warn(string.format("Endpoint(%d) does not support TEMPERATURE_LEVEL feature", endpoint)) + return false +end + +function common_utils.supports_temperature_number_endpoint(device, endpoint) + local feature = clusters.TemperatureControl.types.Feature.TEMPERATURE_NUMBER + local tn_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = feature}) + if #tn_eps == 0 then + device.log.warn(string.format("Device does not support TEMPERATURE_NUMBER feature")) + return false + end + for _, eps in ipairs(tn_eps) do + if eps == endpoint then + return true + end + end + device.log.warn(string.format("Endpoint(%d) does not support TEMPERATURE_NUMBER feature", endpoint)) + return false +end + +function common_utils.temperature_setpoint_attr_handler(device, ib, device_type) + local min = device:get_field(string.format("%s-%d", common_utils.setpoint_limit_device_field.MIN_TEMP, ib.endpoint_id)) + or default_min_and_max_temp_by_device_type[device_type].min_temp + local max = device:get_field(string.format("%s-%d", common_utils.setpoint_limit_device_field.MAX_TEMP, ib.endpoint_id)) + or default_min_and_max_temp_by_device_type[device_type].max_temp + if not min or not max or not common_utils.supports_temperature_number_endpoint(device, ib.endpoint_id) then return end + local temp = ib.data.value / 100.0 + local unit = "C" + local range = { minimum = min, maximum = max, step = 0.1 } + -- Only emit the capability for RPC version >= 5, since unit conversion for range capabilities is only supported in that case. + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureSetpoint.temperatureSetpointRange({value = range, unit = unit}, { visibility = { displayed = false } })) + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureSetpoint.temperatureSetpoint({value = temp, unit = unit})) +end + +function common_utils.setpoint_limit_handler(device, ib, limit_field, device_type) + local field = string.format("%s-%d", limit_field, ib.endpoint_id) + local val = ib.data.value / 100.0 + + local min_temp_in_c = default_min_and_max_temp_by_device_type[device_type].min_temp + local max_temp_in_c = default_min_and_max_temp_by_device_type[device_type].max_temp + if not min_temp_in_c or not max_temp_in_c or not common_utils.supports_temperature_number_endpoint(device, ib.endpoint_id) then return end + + val = utils.clamp_value(val, min_temp_in_c, max_temp_in_c) + device:set_field(field, val, { persist = true }) +end + +function common_utils.handle_temperature_setpoint(device, cmd, device_type) + local value = cmd.args.setpoint + local _, temp_setpoint = device:get_latest_state( + cmd.component, capabilities.temperatureSetpoint.ID, + capabilities.temperatureSetpoint.temperatureSetpoint.NAME, + 0, { value = 0, unit = "C" } + ) + local ep = device:component_to_endpoint(cmd.component) + local min = device:get_field(string.format("%s-%d", common_utils.setpoint_limit_device_field.MIN_TEMP, ep)) + or default_min_and_max_temp_by_device_type[device_type].min_temp + local max = device:get_field(string.format("%s-%d", common_utils.setpoint_limit_device_field.MAX_TEMP, ep)) + or default_min_and_max_temp_by_device_type[device_type].max_temp + if not min or not max or not common_utils.supports_temperature_number_endpoint(device, ep) then return end + + if value > default_min_and_max_temp_by_device_type[device_type].max_temp and version.rpc <= 5 then + value = utils.f_to_c(value) + end + if value < min or value > max then + device.log.warn(string.format("Invalid setpoint (%s) outside the min (%s) and the max (%s)", value, min, max)) + device:emit_event_for_endpoint(ep, capabilities.temperatureSetpoint.temperatureSetpoint(temp_setpoint)) + return + end + device:send(clusters.TemperatureControl.commands.SetTemperature(device, ep, utils.round(value * 100), nil)) +end + +return common_utils diff --git a/drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua b/drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua new file mode 100644 index 0000000000..5a5a89af73 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua @@ -0,0 +1,91 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.matter.clusters" +local utils = require "st.utils" +local version = require "version" + +if version.api < 10 then + clusters.ActivatedCarbonFilterMonitoring = require "ActivatedCarbonFilterMonitoring" + clusters.DishwasherAlarm = require "DishwasherAlarm" + clusters.DishwasherMode = require "DishwasherMode" + clusters.HepaFilterMonitoring = require "HepaFilterMonitoring" + clusters.LaundryWasherControls = require "LaundryWasherControls" + clusters.LaundryWasherMode = require "LaundryWasherMode" + clusters.OperationalState = require "OperationalState" + clusters.RefrigeratorAlarm = require "RefrigeratorAlarm" + clusters.RefrigeratorAndTemperatureControlledCabinetMode = require "RefrigeratorAndTemperatureControlledCabinetMode" + clusters.TemperatureControl = require "TemperatureControl" +end + +if version.api < 11 then + clusters.MicrowaveOvenControl = require "MicrowaveOvenControl" + clusters.MicrowaveOvenMode = require "MicrowaveOvenMode" +end + +if version.api < 12 then + clusters.OvenMode = require "OvenMode" +end + +local embedded_cluster_utils = {} + +local embedded_clusters_api_10 = { + [clusters.ActivatedCarbonFilterMonitoring.ID] = clusters.ActivatedCarbonFilterMonitoring, + [clusters.DishwasherAlarm.ID] = clusters.DishwasherAlarm, + [clusters.DishwasherMode.ID] = clusters.DishwasherMode, + [clusters.HepaFilterMonitoring.ID] = clusters.HepaFilterMonitoring, + [clusters.LaundryWasherControls.ID] = clusters.LaundryWasherControls, + [clusters.LaundryWasherMode.ID] = clusters.LaundryWasherMode, + [clusters.OperationalState.ID] = clusters.OperationalState, + [clusters.RefrigeratorAlarm.ID] = clusters.RefrigeratorAlarm, + [clusters.RefrigeratorAndTemperatureControlledCabinetMode.ID] = clusters.RefrigeratorAndTemperatureControlledCabinetMode, + [clusters.TemperatureControl.ID] = clusters.TemperatureControl +} + +local embedded_clusters_api_11 = { + [clusters.MicrowaveOvenControl.ID] = clusters.MicrowaveOvenControl, + [clusters.MicrowaveOvenMode.ID] = clusters.MicrowaveOvenMode, + [clusters.OvenMode.ID] = clusters.OvenMode +} + +local embedded_clusters_api_12 = { + [clusters.OvenMode.ID] = clusters.OvenMode +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 10 and embedded_clusters_api_10[cluster_id] ~= nil or + version.api < 11 and embedded_clusters_api_11[cluster_id] ~= nil or + version.api < 12 and embedded_clusters_api_12[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters_api_10[cluster_id] or embedded_clusters_api_11[cluster_id] or embedded_clusters_api_12[cluster_id] + if not opts then opts = {} end + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end +end + +return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-appliance/src/init.lua b/drivers/SmartThings/matter-appliance/src/init.lua new file mode 100644 index 0000000000..8f91ebcd0c --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/init.lua @@ -0,0 +1,305 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MatterDriver = require "st.matter.driver" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local log = require "log" +local version = require "version" + +if version.api < 10 then + clusters.ActivatedCarbonFilterMonitoring = require "ActivatedCarbonFilterMonitoring" + clusters.DishwasherAlarm = require "DishwasherAlarm" + clusters.DishwasherMode = require "DishwasherMode" + clusters.HepaFilterMonitoring = require "HepaFilterMonitoring" + clusters.LaundryWasherControls = require "LaundryWasherControls" + clusters.LaundryWasherMode = require "LaundryWasherMode" + clusters.OperationalState = require "OperationalState" + clusters.RefrigeratorAlarm = require "RefrigeratorAlarm" + clusters.RefrigeratorAndTemperatureControlledCabinetMode = require "RefrigeratorAndTemperatureControlledCabinetMode" + clusters.TemperatureControl = require "TemperatureControl" +end + +if version.api < 11 then + clusters.MicrowaveOvenControl = require "MicrowaveOvenControl" + clusters.MicrowaveOvenMode = require "MicrowaveOvenMode" +end + +if version.api < 12 then + clusters.OvenMode = require "OvenMode" +end + +local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureSetpoint.ID] = { + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.MaxTemperature + }, + [capabilities.temperatureLevel.ID] = { + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + }, + [capabilities.operationalState.ID] = { + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError + }, + [capabilities.mode.ID] = { + clusters.DishwasherMode.attributes.SupportedModes, + clusters.DishwasherMode.attributes.CurrentMode, + clusters.LaundryWasherMode.attributes.SupportedModes, + clusters.LaundryWasherMode.attributes.CurrentMode, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.SupportedModes, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.CurrentMode, + clusters.MicrowaveOvenMode.attributes.CurrentMode, + clusters.MicrowaveOvenMode.attributes.SupportedModes, + clusters.OvenMode.attributes.SupportedModes, + clusters.OvenMode.attributes.CurrentMode + }, + [capabilities.laundryWasherRinseMode.ID] = { + clusters.LaundryWasherControls.attributes.NumberOfRinses, + clusters.LaundryWasherControls.attributes.SupportedRinses + }, + [capabilities.laundryWasherSpinSpeed.ID] = { + clusters.LaundryWasherControls.attributes.SpinSpeeds, + clusters.LaundryWasherControls.attributes.SpinSpeedCurrent + }, + [capabilities.contactSensor.ID] = { + clusters.DishwasherAlarm.attributes.State, + clusters.RefrigeratorAlarm.attributes.State + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue + }, + [capabilities.waterFlowAlarm.ID] = { + clusters.DishwasherAlarm.attributes.State + }, + [capabilities.temperatureAlarm.ID] = { + clusters.DishwasherAlarm.attributes.State + }, + [capabilities.fanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + [capabilities.filterState.ID] = { + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition + }, + [capabilities.filterStatus.ID] = { + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication + }, + [capabilities.cookTime.ID] = { + clusters.MicrowaveOvenControl.attributes.MaxCookTime, + clusters.MicrowaveOvenControl.attributes.CookTime + } +} + +local function endpoint_to_component(device, ep) + local map = device:get_field(common_utils.COMPONENT_TO_ENDPOINT_MAP) or {} + for component, endpoint in pairs(map) do + if endpoint == ep then + return component + end + end + return "main" +end + +local function component_to_endpoint(device, component) + local map = device:get_field(common_utils.COMPONENT_TO_ENDPOINT_MAP) or {} + if map[component] then + return map[component] + end + return device.MATTER_DEFAULT_ENDPOINT +end + +-- Lifecycle Handlers -- +local function device_init(driver, device) + common_utils.check_field_name_updates(device) + device:subscribe() + device:set_endpoint_to_component_fn(endpoint_to_component) + device:set_component_to_endpoint_fn(component_to_endpoint) +end + +local function info_changed(driver, device, event, args) + --Note this is needed because device:subscribe() does not recalculate + -- the subscribed attributes each time it is run, that only happens at init. + -- This will change in the 0.48.x release of the lua libs. + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + device:subscribe() +end + +-- Matter Handlers -- +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) + end +end + +local function temperature_setpoint_attr_handler(driver, device, ib, response) + common_utils.temperature_setpoint_attr_handler(device, ib, "default") +end + +local function setpoint_limit_handler(limit_field) + return function(driver, device, ib, response) + common_utils.setpoint_limit_handler(device, ib, limit_field, "default") + end +end + +local function selected_temperature_level_attr_handler(driver, device, ib, response) + if not common_utils.supports_temperature_level_endpoint(device, ib.endpoint_id) then + return + end + local temperatureLevel = ib.data.value + local supportedTemperatureLevelsMap = device:get_field(common_utils.SUPPORTED_TEMPERATURE_LEVELS_MAP) + if not supportedTemperatureLevelsMap or not supportedTemperatureLevelsMap[ib.endpoint_id] then + return + end + local supportedTemperatureLevels = supportedTemperatureLevelsMap[ib.endpoint_id] + for i, tempLevel in ipairs(supportedTemperatureLevels) do + if i - 1 == temperatureLevel then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureLevel.temperatureLevel(tempLevel)) + break + end + end +end + +local function supported_temperature_levels_attr_handler(driver, device, ib, response) + if not common_utils.supports_temperature_level_endpoint(device, ib.endpoint_id) then + return + end + local supportedTemperatureLevelsMap = device:get_field(common_utils.SUPPORTED_TEMPERATURE_LEVELS_MAP) or {} + local supportedTemperatureLevels = {} + for _, tempLevel in ipairs(ib.data.elements) do + table.insert(supportedTemperatureLevels, tempLevel.value) + end + for ep = 1, ib.endpoint_id - 1 do + if not supportedTemperatureLevelsMap[ep] then + supportedTemperatureLevelsMap[ep] = {"Nothing"} + end + end + supportedTemperatureLevelsMap[ib.endpoint_id] = supportedTemperatureLevels + device:set_field(common_utils.SUPPORTED_TEMPERATURE_LEVELS_MAP, supportedTemperatureLevelsMap, { persist = true }) + local event = capabilities.temperatureLevel.supportedTemperatureLevels(supportedTemperatureLevels, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function temp_event_handler(driver, device, ib, response) + local temp = ib.data.value and ib.data.value / 100.0 or 0 + local unit = "C" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperature({value = temp, unit = unit})) +end + +-- Capability Handlers -- +local function handle_switch_on(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.On(device, endpoint_id) + device:send(req) +end + +local function handle_switch_off(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.Off(device, endpoint_id) + device:send(req) +end + +local function handle_temperature_setpoint(driver, device, cmd) + common_utils.handle_temperature_setpoint(device, cmd, "default") +end + +local function handle_temperature_level(driver, device, cmd) + local ep = device:component_to_endpoint(cmd.component) + if not common_utils.supports_temperature_level_endpoint(device, ep) then + return + end + local supportedTemperatureLevelsMap = device:get_field(common_utils.SUPPORTED_TEMPERATURE_LEVELS_MAP) + if not supportedTemperatureLevelsMap then + return + end + local supportedTemperatureLevels = supportedTemperatureLevelsMap[ep] + for i, tempLevel in ipairs(supportedTemperatureLevels) do + if cmd.args.temperatureLevel == tempLevel then + device:send(clusters.TemperatureControl.commands.SetTemperature(device, ep, nil, i - 1)) + return + end + end +end + +local matter_driver_template = { + lifecycle_handlers = { + init = device_init, + infoChanged = info_changed + }, + matter_handlers = { + attr = { + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler + }, + [clusters.TemperatureControl.ID] = { + [clusters.TemperatureControl.attributes.TemperatureSetpoint.ID] = temperature_setpoint_attr_handler, + [clusters.TemperatureControl.attributes.MaxTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MAX_TEMP), + [clusters.TemperatureControl.attributes.MinTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MIN_TEMP), + [clusters.TemperatureControl.attributes.SelectedTemperatureLevel.ID] = selected_temperature_level_attr_handler, + [clusters.TemperatureControl.attributes.SupportedTemperatureLevels.ID] = supported_temperature_levels_attr_handler + }, + [clusters.TemperatureMeasurement.ID] = { + [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = temp_event_handler + } + } + }, + subscribed_attributes = subscribed_attributes, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_switch_on, + [capabilities.switch.commands.off.NAME] = handle_switch_off + }, + [capabilities.temperatureLevel.ID] = { + [capabilities.temperatureLevel.commands.setTemperatureLevel.NAME] = handle_temperature_level + }, + [capabilities.temperatureSetpoint.ID] = { + [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = handle_temperature_setpoint + } + }, + supported_capabilities = { + capabilities.switch, + capabilities.temperatureSetpoint, + capabilities.temperatureLevel, + capabilities.operationalState.ID, + capabilities.mode, + capabilities.laundryWasherRinseMode, + capabilities.contactSensor, + capabilities.temperatureMeasurement, + capabilities.waterFlowAlarm, + capabilities.temperatureAlarm, + capabilities.filterState, + capabilities.filterStatus, + capabilities.fanMode, + capabilities.fanSpeedPercent, + capabilities.windMode + }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, +} + +local matter_driver = MatterDriver("matter-appliance", matter_driver_template) +log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) +matter_driver:run() diff --git a/drivers/SmartThings/matter-appliance/src/lazy_load_subdriver.lua b/drivers/SmartThings/matter-appliance/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..a04740d267 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/lazy_load_subdriver.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + local MatterDriver = require "st.matter.driver" + local version = require "version" + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/matter-appliance/src/matter-cook-top/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-cook-top/can_handle.lua new file mode 100644 index 0000000000..d2899d279a --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-cook-top/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_cook_top_device(opts, driver, device, ...) + local common_utils = require "common-utils" + local COOK_TOP_DEVICE_TYPE_ID = 0x0078 + local OVEN_DEVICE_ID = 0x007B + + local cook_top_eps = common_utils.get_endpoints_for_dt(device, COOK_TOP_DEVICE_TYPE_ID) + local oven_eps = common_utils.get_endpoints_for_dt(device, OVEN_DEVICE_ID) + -- we want to skip lifecycle events in cases where the device is an oven with a composed cook-top device + if (#oven_eps > 0) and opts.dispatcher_class == "DeviceLifecycleDispatcher" then + return false + end + if #cook_top_eps > 0 then + return true, require("matter-cook-top") + end + return false +end + +return is_cook_top_device diff --git a/drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua b/drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua new file mode 100644 index 0000000000..d0ce48dc18 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua @@ -0,0 +1,73 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local embedded_cluster_utils = require "embedded-cluster-utils" +local version = require "version" + +if version.api < 10 then + clusters.TemperatureControl = require "TemperatureControl" +end + +local COOK_SURFACE_DEVICE_TYPE_ID = 0x0077 + +local function table_contains(tab, val) + for _, tab_val in ipairs(tab) do + if tab_val == val then + return true + end + end + return false +end + +local function device_added(driver, device) + local cook_surface_endpoints = common_utils.get_endpoints_for_dt(device, COOK_SURFACE_DEVICE_TYPE_ID) + local componentToEndpointMap = { + ["cookSurfaceOne"] = cook_surface_endpoints[1], + ["cookSurfaceTwo"] = cook_surface_endpoints[2] + } + device:set_field(common_utils.COMPONENT_TO_ENDPOINT_MAP, componentToEndpointMap, { persist = true }) +end + +local function do_configure(driver, device) + local cook_surface_endpoints = common_utils.get_endpoints_for_dt(device, COOK_SURFACE_DEVICE_TYPE_ID) + + local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, + { feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL }) + + local profile_name + if #cook_surface_endpoints > 0 then + profile_name = "cook-surface-one" + if table_contains(tl_eps, cook_surface_endpoints[1]) then + profile_name = profile_name .. "-tl" + end + + -- we only support up to two cook surfaces + if #cook_surface_endpoints > 1 then + profile_name = profile_name .. "-cook-surface-two" + if table_contains(tl_eps, cook_surface_endpoints[2]) then + profile_name = profile_name .. "-tl" + end + end + end + + if profile_name then + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({ profile = profile_name }) + end +end + + +-- Matter Handlers -- +local matter_cook_top_handler = { + NAME = "matter-cook-top", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + can_handle = require("matter-cook-top.can_handle"), +} + +return matter_cook_top_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-dishwasher/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/can_handle.lua new file mode 100644 index 0000000000..37226f3f3e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function is_matter_dishwasher(opts, driver, device) + local DISHWASHER_DEVICE_TYPE_ID = 0x0075 + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == DISHWASHER_DEVICE_TYPE_ID then + return true, require("matter-dishwasher") + end + end + end + return false +end + +return is_matter_dishwasher diff --git a/drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua new file mode 100644 index 0000000000..2e01afe7fb --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua @@ -0,0 +1,250 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local embedded_cluster_utils = require "embedded-cluster-utils" +local version = require "version" + +if version.api < 10 then + clusters.DishwasherAlarm = require "DishwasherAlarm" + clusters.DishwasherMode = require "DishwasherMode" + clusters.OperationalState = require "OperationalState" + clusters.TemperatureControl = require "TemperatureControl" +end + +local OPERATIONAL_STATE_COMMAND_MAP = { + [clusters.OperationalState.commands.Pause.ID] = "pause", + [clusters.OperationalState.commands.Stop.ID] = "stop", + [clusters.OperationalState.commands.Start.ID] = "start", + [clusters.OperationalState.commands.Resume.ID] = "resume", +} + +local SUPPORTED_DISHWASHER_MODES = "__supported_dishwasher_modes" + + +-- Lifecycle Handlers -- +local function do_configure(driver, device) + local tn_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_NUMBER}) + local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL}) + local profile_name = "dishwasher" + if #tn_eps > 0 then + profile_name = profile_name .. "-tn" + common_utils.query_setpoint_limits(device) + end + if #tl_eps > 0 then + profile_name = profile_name .. "-tl" + end + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +-- Matter Handlers -- +local function temperature_setpoint_attr_handler(driver, device, ib, response) + common_utils.temperature_setpoint_attr_handler(device, ib, "dishwasher") +end + +local function setpoint_limit_handler(limit_field) + return function(driver, device, ib, response) + common_utils.setpoint_limit_handler(device, ib, limit_field, "dishwasher") + end +end + +local function dishwasher_supported_modes_attr_handler(driver, device, ib, response) + local supportedDishwasherModes = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 10 then + clusters.DishwasherMode.types.ModeOptionStruct:augment_type(mode) + end + table.insert(supportedDishwasherModes, mode.elements.label.value) + end + device:set_field(SUPPORTED_DISHWASHER_MODES, supportedDishwasherModes, { persist = true }) + local event = capabilities.mode.supportedModes(supportedDishwasherModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supportedDishwasherModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function dishwasher_mode_attr_handler(driver, device, ib, response) + local supportedDishwasherModes = device:get_field(SUPPORTED_DISHWASHER_MODES) + local currentMode = ib.data.value + for i, mode in ipairs(supportedDishwasherModes) do + if i - 1 == currentMode then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode)) + break + end + end +end + +local function dishwasher_alarm_attr_handler(driver, device, ib, response) + local isWaterFlowRateAlarm = false + local isContactSensorAlarm = false + local isTemperatureAlarm = false + local isWaterFlowVolumeAlarm = false + + local state = ib.data.value + if state & clusters.DishwasherAlarm.types.AlarmMap.INFLOW_ERROR > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.waterFlowAlarm.rateAlarm.alarm()) + isWaterFlowRateAlarm = true + end + if state & clusters.DishwasherAlarm.types.AlarmMap.DRAIN_ERROR > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.waterFlowAlarm.rateAlarm.alarm()) + isWaterFlowRateAlarm = true + end + if state & clusters.DishwasherAlarm.types.AlarmMap.DOOR_ERROR > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.contactSensor.contact.open()) + isContactSensorAlarm = true + end + if state & clusters.DishwasherAlarm.types.AlarmMap.TEMP_TOO_LOW > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureAlarm.temperatureAlarm.freeze()) + isTemperatureAlarm = true + end + if state & clusters.DishwasherAlarm.types.AlarmMap.TEMP_TOO_HIGH > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureAlarm.temperatureAlarm.heat()) + isTemperatureAlarm = true + end + if state & clusters.DishwasherAlarm.types.AlarmMap.WATER_LEVEL_ERROR > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.waterFlowAlarm.volumeAlarm.alarm()) + isWaterFlowVolumeAlarm = true + end + + if not isWaterFlowRateAlarm then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.waterFlowAlarm.rateAlarm.normal()) + end + if not isContactSensorAlarm then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.contactSensor.contact.closed()) + end + if not isTemperatureAlarm then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureAlarm.temperatureAlarm.cleared()) + end + if not isWaterFlowVolumeAlarm then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.waterFlowAlarm.volumeAlarm.normal()) + end +end + +local function operational_state_accepted_command_list_attr_handler(driver, device, ib, response) + local accepted_command_list = {} + for _, accepted_command in ipairs(ib.data.elements) do + local accepted_command_id = accepted_command.value + if OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id] ~= nil then + device.log.info(string.format("AcceptedCommand: %s => %s", accepted_command_id, OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id])) + table.insert(accepted_command_list, OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id]) + end + end + local event = capabilities.operationalState.supportedCommands(accepted_command_list, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function operational_state_attr_handler(driver, device, ib, response) + if ib.data.value == clusters.OperationalState.types.OperationalStateEnum.STOPPED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.stopped()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.RUNNING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.running()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.PAUSED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.paused()) + end +end + +local function operational_error_attr_handler(driver, device, ib, response) + if version.api < 10 then + clusters.OperationalState.types.ErrorStateStruct:augment_type(ib.data) + end + local operationalError = ib.data.elements.error_state_id.value + if operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToStartOrResume()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToCompleteOperation()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + end +end + +-- Capability Handlers -- +local function handle_dishwasher_mode(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local supportedDishwasherModes =device:get_field(SUPPORTED_DISHWASHER_MODES) + for i, mode in ipairs(supportedDishwasherModes) do + if cmd.args.mode == mode then + device:send(clusters.DishwasherMode.commands.ChangeToMode(device, endpoint_id, i - 1)) + return + end + end +end + +local function handle_temperature_setpoint(driver, device, cmd) + common_utils.handle_temperature_setpoint(device, cmd, "dishwasher") +end + +local function handle_operational_state_start(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Start(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local function handle_operational_state_stop(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Stop(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local function handle_operational_state_resume(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Resume(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local function handle_operational_state_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Pause(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local matter_dishwasher_handler = { + NAME = "matter-dishwasher", + lifecycle_handlers = { + doConfigure = do_configure + }, + matter_handlers = { + attr = { + [clusters.TemperatureControl.ID] = { + [clusters.TemperatureControl.attributes.TemperatureSetpoint.ID] = temperature_setpoint_attr_handler, + [clusters.TemperatureControl.attributes.MinTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MIN_TEMP), + [clusters.TemperatureControl.attributes.MaxTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MAX_TEMP) + }, + [clusters.DishwasherMode.ID] = { + [clusters.DishwasherMode.attributes.SupportedModes.ID] = dishwasher_supported_modes_attr_handler, + [clusters.DishwasherMode.attributes.CurrentMode.ID] = dishwasher_mode_attr_handler + }, + [clusters.DishwasherAlarm.ID] = { + [clusters.DishwasherAlarm.attributes.State.ID] = dishwasher_alarm_attr_handler + }, + [clusters.OperationalState.ID] = { + [clusters.OperationalState.attributes.AcceptedCommandList.ID] = operational_state_accepted_command_list_attr_handler, + [clusters.OperationalState.attributes.OperationalState.ID] = operational_state_attr_handler, + [clusters.OperationalState.attributes.OperationalError.ID] = operational_error_attr_handler + } + } + }, + capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = handle_dishwasher_mode + }, + [capabilities.temperatureSetpoint.ID] = { + [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = handle_temperature_setpoint + }, + [capabilities.operationalState.ID] = { + [capabilities.operationalState.commands.start.NAME] = handle_operational_state_start, + [capabilities.operationalState.commands.stop.NAME] = handle_operational_state_stop, + [capabilities.operationalState.commands.pause.NAME] = handle_operational_state_pause, + [capabilities.operationalState.commands.resume.NAME] = handle_operational_state_resume + } + }, + can_handle = require("matter-dishwasher.can_handle"), +} + +return matter_dishwasher_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/can_handle.lua new file mode 100644 index 0000000000..d8ec2f9afd --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_extractor_hood(opts, driver, device) + local EXTRACTOR_HOOD_DEVICE_TYPE_ID = 0x007A + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == EXTRACTOR_HOOD_DEVICE_TYPE_ID then + return true, require("matter-extractor-hood") + end + end + end + return false +end + +return is_matter_extractor_hood diff --git a/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua new file mode 100644 index 0000000000..f83681e726 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua @@ -0,0 +1,294 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local embedded_cluster_utils = require "embedded-cluster-utils" +local utils = require "st.utils" +local version = require "version" + +local EXTRACTOR_HOOD_DEVICE_TYPE_ID = 0x007A +local ON_OFF_LIGHT_DEVICE_TYPE_ID = 0x0100 +local ON_OFF_LIGHT_SWITCH_DEVICE_TYPE_ID = 0x0103 + +if version.api < 10 then + clusters.ActivatedCarbonFilterMonitoring = require "ActivatedCarbonFilterMonitoring" + clusters.HepaFilterMonitoring = require "HepaFilterMonitoring" +end + +local WIND_MODE_MAP = { + [0] = capabilities.windMode.windMode.sleepWind, + [1] = capabilities.windMode.windMode.naturalWind +} + +-- Lifecycle Handlers -- +local function device_added(driver, device) + local extractor_hood_endpoint = common_utils.get_endpoints_for_dt(device, EXTRACTOR_HOOD_DEVICE_TYPE_ID)[1] + local componentToEndpointMap = { + ["main"] = extractor_hood_endpoint + } + local on_off_light_device_type_endpoint = common_utils.get_endpoints_for_dt(device, ON_OFF_LIGHT_DEVICE_TYPE_ID)[1] + local on_off_light_switch_device_type_endpoint = common_utils.get_endpoints_for_dt(device, ON_OFF_LIGHT_SWITCH_DEVICE_TYPE_ID)[1] + if on_off_light_device_type_endpoint and + device:supports_server_cluster(clusters.OnOff.ID, on_off_light_device_type_endpoint) then + componentToEndpointMap["light"] = on_off_light_device_type_endpoint + elseif on_off_light_switch_device_type_endpoint and + device:supports_server_cluster(clusters.OnOff.ID, on_off_light_switch_device_type_endpoint) then + componentToEndpointMap["light"] = on_off_light_switch_device_type_endpoint + end + device:set_field(common_utils.COMPONENT_TO_ENDPOINT_MAP, componentToEndpointMap, { persist = true }) +end + +local function do_configure(driver, device) + local hepa_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.HepaFilterMonitoring.ID) + local ac_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.ActivatedCarbonFilterMonitoring.ID) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + local light_eps = device:get_endpoints(clusters.OnOff.ID) + local profile_name = "extractor-hood" + if #hepa_filter_eps > 0 then + profile_name = profile_name .. "-hepa" + end + if #ac_filter_eps > 0 then + profile_name = profile_name .. "-ac" + end + if #wind_eps > 0 then + profile_name = profile_name .. "-wind" + end + for _, ep in ipairs(light_eps) do + if device:supports_server_cluster(clusters.OnOff.ID, ep) then + profile_name = profile_name .. "-light" + break + end + end + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +-- Matter Handlers -- +local function fan_mode_handler(driver, device, ib, response) + if ib.data.value == clusters.FanControl.attributes.FanMode.OFF then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode.off()) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.LOW then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode.low()) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.MEDIUM then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode.medium()) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.HIGH then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode.high()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode.auto()) + end +end + +local function fan_mode_sequence_handler(driver, device, ib, response) + local supportedFanModes + if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_HIGH_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + else + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME + } + end + local event = capabilities.fanMode.supportedFanModes(supportedFanModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function fan_speed_percent_attr_handler(driver, device, ib, response) + if ib.data.value == nil then + return + end + local speed = utils.clamp_value(ib.data.value, 0, 100) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(speed)) +end + +local function wind_support_handler(driver, device, ib, response) + local supported_wind_modes = {capabilities.windMode.windMode.noWind.NAME} + for mode, wind_mode in pairs(WIND_MODE_MAP) do + if ((ib.data.value >> mode) & 1) > 0 then + table.insert(supported_wind_modes, wind_mode.NAME) + end + end + local event = capabilities.windMode.supportedWindModes(supported_wind_modes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function wind_setting_handler(driver, device, ib, response) + for index, wind_mode in pairs(WIND_MODE_MAP) do + if ((ib.data.value >> index) & 1) > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, wind_mode()) + return + end + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windMode.windMode.noWind()) +end + +local function hepa_filter_condition_handler(driver, device, ib, response) + local component = device.profile.components["hepaFilter"] + local condition = ib.data.value + device:emit_component_event(component, capabilities.filterState.filterLifeRemaining(condition)) +end + +local function hepa_filter_change_indication_handler(driver, device, ib, response) + local component = device.profile.components["hepaFilter"] + if ib.data.value == clusters.HepaFilterMonitoring.attributes.ChangeIndication.OK then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.HepaFilterMonitoring.attributes.ChangeIndication.WARNING then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.HepaFilterMonitoring.attributes.ChangeIndication.CRITICAL then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.replace()) + end +end + +local function activated_carbon_filter_condition_handler(driver, device, ib, response) + local component = device.profile.components["activatedCarbonFilter"] + local condition = ib.data.value + device:emit_component_event(component, capabilities.filterState.filterLifeRemaining(condition)) +end + +local function activated_carbon_filter_change_indication_handler(driver, device, ib, response) + local component = device.profile.components["activatedCarbonFilter"] + if ib.data.value == clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.OK then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.WARNING then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.CRITICAL then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.replace()) + end +end + +local function on_off_attr_handler(driver, device, ib, response) + local component = device.profile.components["light"] + if ib.data.value then + device:emit_component_event(component, capabilities.switch.switch.on()) + else + device:emit_component_event(component, capabilities.switch.switch.off()) + end +end + +-- Capability Handlers -- +local function set_fan_mode(driver, device, cmd) + local fan_mode_id + if cmd.args.fanMode == capabilities.fanMode.fanMode.low.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.LOW + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.medium.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.MEDIUM + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.high.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.HIGH + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.auto.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO + else + fan_mode_id = clusters.FanControl.attributes.FanMode.OFF + end + if fan_mode_id then + device:send(clusters.FanControl.attributes.FanMode:write(device, device:component_to_endpoint(cmd.component), fan_mode_id)) + end +end + +local function set_fan_speed_percent(driver, device, cmd) + local speed = math.floor(cmd.args.percent) + device:send(clusters.FanControl.attributes.PercentSetting:write(device, device:component_to_endpoint(cmd.component), speed)) +end + +local function set_wind_mode(driver, device, cmd) + local wind_mode = 0 + if cmd.args.windMode == capabilities.windMode.windMode.sleepWind.NAME then + wind_mode = clusters.FanControl.types.WindSupportMask.SLEEP_WIND + elseif cmd.args.windMode == capabilities.windMode.windMode.naturalWind.NAME then + wind_mode = clusters.FanControl.types.WindSupportMask.NATURAL_WIND + end + device:send(clusters.FanControl.attributes.WindSetting:write(device, device:component_to_endpoint(cmd.component), wind_mode)) +end + +local function handle_switch_on(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.On(device, endpoint_id) + device:send(req) +end + +local function handle_switch_off(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.Off(device, endpoint_id) + device:send(req) +end + +local matter_extractor_hood_handler = { + NAME = "matter-extractor-hood", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + matter_handlers = { + attr = { + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler + }, + [clusters.HepaFilterMonitoring.ID] = { + [clusters.HepaFilterMonitoring.attributes.Condition.ID] = hepa_filter_condition_handler, + [clusters.HepaFilterMonitoring.attributes.ChangeIndication.ID] = hepa_filter_change_indication_handler + }, + [clusters.ActivatedCarbonFilterMonitoring.ID] = { + [clusters.ActivatedCarbonFilterMonitoring.attributes.Condition.ID] = activated_carbon_filter_condition_handler, + [clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.ID] = activated_carbon_filter_change_indication_handler + }, + [clusters.FanControl.ID] = { + [clusters.FanControl.attributes.FanModeSequence.ID] = fan_mode_sequence_handler, + [clusters.FanControl.attributes.FanMode.ID] = fan_mode_handler, + [clusters.FanControl.attributes.PercentCurrent.ID] = fan_speed_percent_attr_handler, + [clusters.FanControl.attributes.WindSupport.ID] = wind_support_handler, + [clusters.FanControl.attributes.WindSetting.ID] = wind_setting_handler + } + } + }, + capability_handlers = { + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = set_fan_mode + }, + [capabilities.fanSpeedPercent.ID] = { + [capabilities.fanSpeedPercent.commands.setPercent.NAME] = set_fan_speed_percent + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_switch_on, + [capabilities.switch.commands.off.NAME] = handle_switch_off + }, + [capabilities.windMode.ID] = { + [capabilities.windMode.commands.setWindMode.NAME] = set_wind_mode + } + }, + can_handle = require("matter-extractor-hood.can_handle"), +} + +return matter_extractor_hood_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-laundry/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-laundry/can_handle.lua new file mode 100644 index 0000000000..dd71f12e9e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-laundry/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_laundry_device(opts, driver, device) + local LAUNDRY_WASHER_DEVICE_TYPE_ID = 0x0073 + local LAUNDRY_DRYER_DEVICE_TYPE_ID = 0x007C + local LAUNDRY_DEVICE_TYPE_ID= "__laundry_device_type_id" + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == LAUNDRY_WASHER_DEVICE_TYPE_ID or dt.device_type_id == LAUNDRY_DRYER_DEVICE_TYPE_ID then + device:set_field(LAUNDRY_DEVICE_TYPE_ID, dt.device_type_id, {persist = true}) + return dt.device_type_id, require("matter-laundry") + end + end + end + return false +end + +return is_matter_laundry_device diff --git a/drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua b/drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua new file mode 100644 index 0000000000..26b9efb606 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua @@ -0,0 +1,301 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local embedded_cluster_utils = require "embedded-cluster-utils" +local log = require "log" +local version = require "version" + +local LAUNDRY_WASHER_DEVICE_TYPE_ID = 0x0073 +local LAUNDRY_DRYER_DEVICE_TYPE_ID = 0x007C + +if version.api < 10 then + clusters.LaundryWasherControls = require "LaundryWasherControls" + clusters.LaundryWasherMode = require "LaundryWasherMode" + clusters.OperationalState = require "OperationalState" + clusters.TemperatureControl = require "TemperatureControl" +end + +local LAUNDRY_WASHER_RINSE_MODE_MAP = { + [clusters.LaundryWasherControls.types.NumberOfRinsesEnum.NONE] = capabilities.laundryWasherRinseMode.rinseMode.none, + [clusters.LaundryWasherControls.types.NumberOfRinsesEnum.NORMAL] = capabilities.laundryWasherRinseMode.rinseMode.normal, + [clusters.LaundryWasherControls.types.NumberOfRinsesEnum.EXTRA] = capabilities.laundryWasherRinseMode.rinseMode.extra, + [clusters.LaundryWasherControls.types.NumberOfRinsesEnum.MAX] = capabilities.laundryWasherRinseMode.rinseMode.max, +} +local OPERATIONAL_STATE_COMMAND_MAP = { + [clusters.OperationalState.commands.Pause.ID] = "pause", + [clusters.OperationalState.commands.Stop.ID] = "stop", + [clusters.OperationalState.commands.Start.ID] = "start", + [clusters.OperationalState.commands.Resume.ID] = "resume", +} + +local LAUNDRY_DEVICE_TYPE_ID= "__laundry_device_type_id" +local SUPPORTED_LAUNDRY_WASHER_MODES = "__supported_laundry_washer_modes" +local SUPPORTED_LAUNDRY_WASHER_SPIN_SPEEDS = "__supported_laundry_spin_speeds" +local SUPPORTED_LAUNDRY_WASHER_RINSES = "__supported_laundry_washer_rinses" + +-- Lifecycle Handlers -- +local function do_configure(driver, device) + local tn_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_NUMBER}) + local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL}) + local is_matter_laundry_device = require("matter-laundry.can_handle") + local device_type, _ = is_matter_laundry_device({}, driver, device) + local profile_name = "laundry" + if (device_type == LAUNDRY_WASHER_DEVICE_TYPE_ID) then + profile_name = profile_name.."-washer" + else + profile_name = profile_name.."-dryer" + end + if #tn_eps > 0 then + profile_name = profile_name .. "-tn" + common_utils.query_setpoint_limits(device) + end + if #tl_eps > 0 then + profile_name = profile_name .. "-tl" + end + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +local function get_device_type(device) + local laundry_device_type_id = device:get_field(LAUNDRY_DEVICE_TYPE_ID) + return laundry_device_type_id == LAUNDRY_DRYER_DEVICE_TYPE_ID and "dryer" or "washer" +end + +-- Matter Handlers -- +local function temperature_setpoint_attr_handler(driver, device, ib, response) + common_utils.temperature_setpoint_attr_handler(device, ib, get_device_type(device)) +end + +local function setpoint_limit_handler(limit_field) + return function(driver, device, ib, response) + common_utils.setpoint_limit_handler(device, ib, limit_field, get_device_type(device)) + end +end + +local function laundry_washer_supported_modes_attr_handler(driver, device, ib, response) + local supportedLaundryWasherModes = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 10 then + clusters.LaundryWasherMode.types.ModeOptionStruct:augment_type(mode) + end + log.info(string.format("Inserting supported washer mode: %s", mode.elements.label.value)) + table.insert(supportedLaundryWasherModes, mode.elements.label.value) + end + local component = device.profile.components["main"] + device:set_field(SUPPORTED_LAUNDRY_WASHER_MODES, supportedLaundryWasherModes, {persist = true}) + local event = capabilities.mode.supportedModes(supportedLaundryWasherModes, {visibility = {displayed = false}}) + device:emit_component_event(component, event) + event = capabilities.mode.supportedArguments(supportedLaundryWasherModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function laundry_washer_mode_attr_handler(driver, device, ib, response) + local supportedLaundryWasherModes = device:get_field(SUPPORTED_LAUNDRY_WASHER_MODES) + local currentMode = ib.data.value + for i, mode in ipairs(supportedLaundryWasherModes) do + if i - 1 == currentMode then + local component = device.profile.components["main"] + device:emit_component_event(component, capabilities.mode.mode(mode)) + return + end + end + log.warn(string.format("Washer mode %s not found in supported washer modes", currentMode)) +end + +local function laundry_washer_controls_spin_speeds_attr_handler(driver, device, ib, response) + local supportedLaundryWasherSpinSpeeds = {} + for _, spinSpeed in ipairs(ib.data.elements) do + log.info(string.format("Inserting supported spin speed mode: %s", spinSpeed.value)) + table.insert(supportedLaundryWasherSpinSpeeds, spinSpeed.value) + end + device:set_field(SUPPORTED_LAUNDRY_WASHER_SPIN_SPEEDS, supportedLaundryWasherSpinSpeeds, {persist = true}) + local event = capabilities.laundryWasherSpinSpeed.supportedSpinSpeeds(supportedLaundryWasherSpinSpeeds, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function laundry_washer_controls_spin_speed_current_attr_handler(driver, device, ib, response) + local supportedLaundryWasherSpinSpeeds = device:get_field(SUPPORTED_LAUNDRY_WASHER_SPIN_SPEEDS) + local spinSpeedCurrent = ib.data.value + for i, spinSpeed in ipairs(supportedLaundryWasherSpinSpeeds) do + if i - 1 == spinSpeedCurrent then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.laundryWasherSpinSpeed.spinSpeed(spinSpeed)) + return + end + end + log.warn(string.format("Spin speed %s not found in supported speed modes", spinSpeedCurrent)) +end + +local function laundry_washer_controls_number_of_rinses_attr_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, LAUNDRY_WASHER_RINSE_MODE_MAP[ib.data.value]()) +end + +local function laundry_washer_controls_supported_rinses_attr_handler(driver, device, ib, response) + local supportedLaundryWasherRinses = {} + for _, numberOfRinses in ipairs(ib.data.elements) do + device.log.info(string.format("numberOfRinses: %s => %s", numberOfRinses.value, LAUNDRY_WASHER_RINSE_MODE_MAP[numberOfRinses.value].NAME)) + table.insert(supportedLaundryWasherRinses, LAUNDRY_WASHER_RINSE_MODE_MAP[numberOfRinses.value].NAME) + end + device:set_field(SUPPORTED_LAUNDRY_WASHER_RINSES, supportedLaundryWasherRinses, {persist = true}) + local event = capabilities.laundryWasherRinseMode.supportedRinseModes(supportedLaundryWasherRinses, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function operational_state_accepted_command_list_attr_handler(driver, device, ib, response) + local accepted_command_list = {} + for _, accepted_command in ipairs(ib.data.elements) do + local accepted_command_id = accepted_command.value + if OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id] ~= nil then + device.log.info(string.format("AcceptedCommand: %s => %s", accepted_command_id, OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id])) + table.insert(accepted_command_list, OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id]) + end + end + local event = capabilities.operationalState.supportedCommands(accepted_command_list, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function operational_state_attr_handler(driver, device, ib, response) + if ib.data.value == clusters.OperationalState.types.OperationalStateEnum.STOPPED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.stopped()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.RUNNING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.running()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.PAUSED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.paused()) + end +end + +local function operational_error_attr_handler(driver, device, ib, response) + if version.api < 10 then + clusters.OperationalState.types.ErrorStateStruct:augment_type(ib.data) + end + local operationalError = ib.data.elements.error_state_id.value + if operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToStartOrResume()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToCompleteOperation()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + end +end + +-- Capability Handlers -- +local function handle_laundry_washer_mode(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local supportedLaundryWasherModes = device:get_field(SUPPORTED_LAUNDRY_WASHER_MODES) + for i, mode in ipairs(supportedLaundryWasherModes) do + if cmd.args.mode == mode then + device:send(clusters.LaundryWasherMode.commands.ChangeToMode(device, endpoint_id, i - 1)) + return + end + end +end + +local function handle_laundry_washer_spin_speed(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local supportedLaundryWasherSpinSpeeds = device:get_field(SUPPORTED_LAUNDRY_WASHER_SPIN_SPEEDS) + for i, spinSpeed in ipairs(supportedLaundryWasherSpinSpeeds) do + if cmd.args.spinSpeed == spinSpeed then + device:send(clusters.LaundryWasherControls.attributes.SpinSpeedCurrent:write(device, endpoint_id, i - 1)) + return + end + end +end + +local function handle_laundry_washer_rinse_mode(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + for clusterVal, capabilityVal in pairs(LAUNDRY_WASHER_RINSE_MODE_MAP) do + if cmd.args.rinseMode == capabilityVal.NAME then + device:send(clusters.LaundryWasherControls.attributes.NumberOfRinses:write(device, endpoint_id, clusterVal)) + break + end + end +end + +local function handle_temperature_setpoint(driver, device, cmd) + common_utils.handle_temperature_setpoint(device, cmd, get_device_type(device)) +end + +local function handle_operational_state_start(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Start(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local function handle_operational_state_stop(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Stop(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local function handle_operational_state_resume(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Resume(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local function handle_operational_state_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Pause(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +local matter_laundry_handler = { + NAME = "matter-laundry", + lifecycle_handlers = { + doConfigure = do_configure + }, + matter_handlers = { + attr = { + [clusters.TemperatureControl.ID] = { + [clusters.TemperatureControl.attributes.TemperatureSetpoint.ID] = temperature_setpoint_attr_handler, + [clusters.TemperatureControl.attributes.MinTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MIN_TEMP), + [clusters.TemperatureControl.attributes.MaxTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MAX_TEMP) + }, + [clusters.LaundryWasherMode.ID] = { + [clusters.LaundryWasherMode.attributes.SupportedModes.ID] = laundry_washer_supported_modes_attr_handler, + [clusters.LaundryWasherMode.attributes.CurrentMode.ID] = laundry_washer_mode_attr_handler + }, + [clusters.LaundryWasherControls.ID] = { + [clusters.LaundryWasherControls.attributes.SpinSpeeds.ID] = laundry_washer_controls_spin_speeds_attr_handler, + [clusters.LaundryWasherControls.attributes.SpinSpeedCurrent.ID] = laundry_washer_controls_spin_speed_current_attr_handler, + [clusters.LaundryWasherControls.attributes.NumberOfRinses.ID] = laundry_washer_controls_number_of_rinses_attr_handler, + [clusters.LaundryWasherControls.attributes.SupportedRinses.ID] = laundry_washer_controls_supported_rinses_attr_handler + }, + [clusters.OperationalState.ID] = { + [clusters.OperationalState.attributes.AcceptedCommandList.ID] = operational_state_accepted_command_list_attr_handler, + [clusters.OperationalState.attributes.OperationalState.ID] = operational_state_attr_handler, + [clusters.OperationalState.attributes.OperationalError.ID] = operational_error_attr_handler + } + } + }, + capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = handle_laundry_washer_mode + }, + [capabilities.temperatureSetpoint.ID] = { + [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = handle_temperature_setpoint + }, + [capabilities.laundryWasherSpinSpeed.ID] = { + [capabilities.laundryWasherSpinSpeed.commands.setSpinSpeed.NAME] = handle_laundry_washer_spin_speed + }, + [capabilities.laundryWasherRinseMode.ID] = { + [capabilities.laundryWasherRinseMode.commands.setRinseMode.NAME] = handle_laundry_washer_rinse_mode + }, + [capabilities.operationalState.ID] = { + [capabilities.operationalState.commands.start.NAME] = handle_operational_state_start, + [capabilities.operationalState.commands.stop.NAME] = handle_operational_state_stop, + [capabilities.operationalState.commands.pause.NAME] = handle_operational_state_pause, + [capabilities.operationalState.commands.resume.NAME] = handle_operational_state_resume + } + }, + can_handle = require("matter-laundry.can_handle"), +} + +return matter_laundry_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/can_handle.lua new file mode 100644 index 0000000000..ebdda29863 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_mircowave_oven(opts, driver, device) + local MICROWAVE_OVEN_DEVICE_TYPE_ID = 0x0079 + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == MICROWAVE_OVEN_DEVICE_TYPE_ID then + return true, require("matter-microwave-oven") + end + end + end + return false +end + +return is_matter_mircowave_oven diff --git a/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua new file mode 100644 index 0000000000..342723656a --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua @@ -0,0 +1,256 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local log = require "log" +local version = require "version" + +if version.api < 10 then + clusters.OperationalState = require "OperationalState" +end + +if version.api < 11 then + clusters.MicrowaveOvenControl = require "MicrowaveOvenControl" + clusters.MicrowaveOvenMode = require "MicrowaveOvenMode" +end + +local OPERATIONAL_STATE_COMMAND_MAP = { + [clusters.OperationalState.commands.Pause.ID] = "pause", + [clusters.OperationalState.commands.Stop.ID] = "stop", + [clusters.OperationalState.commands.Start.ID] = "start", + [clusters.OperationalState.commands.Resume.ID] = "resume", +} + +local DEFAULT_COOKING_MODE = 0 +local DEFAULT_COOKING_TIME = 30 +local MICROWAVE_OVEN_SUPPORTED_MODES_KEY = "__microwave_oven_supported_modes__" + +local function device_init(driver, device) + common_utils.check_field_name_updates(device) + device:subscribe() + device:send(clusters.MicrowaveOvenControl.attributes.MaxCookTime:read(device, device.MATTER_DEFAULT_ENDPOINT)) +end + + +local function get_last_set_cooking_parameters(device) + local cookingTime = device:get_latest_state("main", capabilities.cookTime.ID, capabilities.cookTime.cookTime.NAME) or DEFAULT_COOKING_TIME + local cookingMode = device:get_latest_state("main", capabilities.mode.ID, capabilities.mode.mode.ID) + local cookingModeId = DEFAULT_COOKING_MODE + local microwaveOvenModeSupportedModes = device:get_field(MICROWAVE_OVEN_SUPPORTED_MODES_KEY) or {} + for i, mode in ipairs(microwaveOvenModeSupportedModes) do + if cookingMode == mode then + cookingModeId = i - 1 + break + end + end + return cookingTime, cookingModeId +end + +local function operational_state_accepted_command_list_attr_handler(driver, device, ib, response) + log.info_with({ hub_logs = true }, + string.format("operational_state_accepted_command_list_attr_handler: %s", ib.data.elements)) + + local accepted_command_list = {} + for _, accepted_command in ipairs(ib.data.elements) do + local accepted_command_id = accepted_command.value + if OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id] then + log.info_with({ hub_logs = true }, + string.format("AcceptedCommand: %s => %s", accepted_command_id, + OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id])) + table.insert(accepted_command_list, OPERATIONAL_STATE_COMMAND_MAP[accepted_command_id]) + end + end + local event = capabilities.operationalState.supportedCommands(accepted_command_list, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function operational_state_attr_handler(driver, device, ib, response) + log.info_with({ hub_logs = true }, + string.format("operational_state_attr_handler operationalState: %s", ib.data.value)) + + local supported_mode = {} + if ib.data.value == clusters.OperationalState.types.OperationalStateEnum.STOPPED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.stopped()) + supported_mode = device:get_field(MICROWAVE_OVEN_SUPPORTED_MODES_KEY) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.RUNNING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.running()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.PAUSED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.paused()) + end + local event = capabilities.mode.supportedModes(supported_mode, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, event) + event = capabilities.mode.supportedArguments(supported_mode, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, event) +end + +local function operational_error_attr_handler(driver, device, ib, response) + if version.api < 10 then + clusters.OperationalState.types.ErrorStateStruct:augment_type(ib.data) + end + log.info_with({ hub_logs = true }, + string.format("operational_error_attr_handler errorStateID: %s", ib.data.elements.error_state_id.value)) + local operationalError = ib.data.elements.error_state_id.value + if operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToStartOrResume()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToCompleteOperation()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + end + if operationalError ~= clusters.OperationalState.types.ErrorStateEnum.NO_ERROR then + local event = capabilities.mode.supportedModes({}, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, event) + event = capabilities.mode.supportedArguments({}, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, event) + end +end + +local function microwave_oven_supported_modes_handler(driver, device, ib, response) + local microwaveOvenModeSupportedModes = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 11 then + clusters.MicrowaveOvenMode.types.ModeOptionStruct:augment_type(mode) + end + log.info_with({hub_logs=true},"Inserting supported microwave mode:", mode.elements.label.value) + table.insert(microwaveOvenModeSupportedModes, mode.elements.label.value) + end + local event = capabilities.mode.supportedModes(microwaveOvenModeSupportedModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, event) + event = capabilities.mode.supportedArguments(microwaveOvenModeSupportedModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, event) + device:set_field(MICROWAVE_OVEN_SUPPORTED_MODES_KEY, microwaveOvenModeSupportedModes) +end + +local function microwave_oven_current_mode_handler(driver, device, ib, response) + log.info_with({ hub_logs = true }, + string.format("microwave_oven_current_mode_handler currentMode: %s", ib.data.value)) + + local currentMode = ib.data.value + local microwaveOvenModeSupportedModes = device:get_field(MICROWAVE_OVEN_SUPPORTED_MODES_KEY) or {} + + if microwaveOvenModeSupportedModes[currentMode + 1] then + local mode = microwaveOvenModeSupportedModes[currentMode + 1] + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, capabilities.mode.mode(mode)) + return + end + log.warn(string.format("Microwave oven mode %s not found in supported microwave oven modes", currentMode)) +end + +local function microwave_oven_cook_time_handler(driver, device, ib, response) + local cookingTime = (ib.data.value == 0) and DEFAULT_COOKING_TIME or ib.data.value + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, capabilities.cookTime.cookTime(cookingTime)) +end + +local function microwave_oven_max_cook_time_handler(driver, device, ib, response) + local cook_time_range = { + minimum = 1, + maximum = ib.data.value + } + local event = capabilities.cookTime.cookTimeRange(cook_time_range, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(device.MATTER_DEFAULT_ENDPOINT, event) +end + +--------------------- +--capability handlers +--------------------- +local function update_device_state(device, endpoint) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint)) +end + +local function handle_operational_state_start(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Start(device, endpoint_id)) + update_device_state(device, endpoint_id) +end + +local function handle_operational_state_stop(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Stop(device, endpoint_id)) + update_device_state(device, endpoint_id) +end + +local function handle_operational_state_resume(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Resume(device, endpoint_id)) + update_device_state(device, endpoint_id) +end + +local function handle_operational_state_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OperationalState.server.commands.Pause(device, endpoint_id)) + update_device_state(device, endpoint_id) +end + +local function handle_microwave_oven_mode(driver, device, cmd) + log.info_with({ hub_logs = true }, + string.format("microwave_oven_mode[%s] mode: %s", cmd.component, cmd.args.mode)) + local microwaveOvenModeSupportedModes = device:get_field(MICROWAVE_OVEN_SUPPORTED_MODES_KEY) or {} + for i, mode in ipairs(microwaveOvenModeSupportedModes) do + if cmd.args.mode == mode then + local cookingTime, _ = get_last_set_cooking_parameters(device) + device:send(clusters.MicrowaveOvenControl.commands.SetCookingParameters(device, device.MATTER_DEFAULT_ENDPOINT, + i - 1, + cookingTime)) + return + end + end + log.warn(string.format("Microwave oven mode %s not found in supported modes", cmd.args.mode)) +end + +local function handle_set_cooking_time(driver, device, cmd) + local cookingTime = cmd.args.time + local _, mode_id = get_last_set_cooking_parameters(device) + device:send(clusters.MicrowaveOvenControl.commands.SetCookingParameters(device, device.MATTER_DEFAULT_ENDPOINT, mode_id, + cookingTime)) +end + +local matter_microwave_oven = { + NAME = "matter-microwave-oven", + lifecycle_handlers = { + init = device_init + }, + matter_handlers = { + attr = { + [clusters.OperationalState.ID] = { + [clusters.OperationalState.attributes.AcceptedCommandList.ID] = + operational_state_accepted_command_list_attr_handler, + [clusters.OperationalState.attributes.OperationalState.ID] = operational_state_attr_handler, + [clusters.OperationalState.attributes.OperationalError.ID] = operational_error_attr_handler + }, + [clusters.MicrowaveOvenMode.ID] = { + [clusters.MicrowaveOvenMode.attributes.SupportedModes.ID] = microwave_oven_supported_modes_handler, + [clusters.MicrowaveOvenMode.attributes.CurrentMode.ID] = microwave_oven_current_mode_handler + }, + [clusters.MicrowaveOvenControl.ID] = { + [clusters.MicrowaveOvenControl.attributes.MaxCookTime.ID] = microwave_oven_max_cook_time_handler, + [clusters.MicrowaveOvenControl.attributes.CookTime.ID] = microwave_oven_cook_time_handler + } + } + }, + capability_handlers = { + [capabilities.operationalState.ID] = { + [capabilities.operationalState.commands.start.NAME] = handle_operational_state_start, + [capabilities.operationalState.commands.stop.NAME] = handle_operational_state_stop, + [capabilities.operationalState.commands.pause.NAME] = handle_operational_state_pause, + [capabilities.operationalState.commands.resume.NAME] = handle_operational_state_resume + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = handle_microwave_oven_mode + }, + [capabilities.cookTime.ID] = { + [capabilities.cookTime.commands.setCookTime.NAME] = handle_set_cooking_time + } + }, + supported_capabilities = { + capabilities.operationalState, + capabilities.mode, + capabilities.cookTime + }, + can_handle = require("matter-microwave-oven.can_handle"), +} + +return matter_microwave_oven diff --git a/drivers/SmartThings/matter-appliance/src/matter-oven/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-oven/can_handle.lua new file mode 100644 index 0000000000..a5bb61f9b8 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-oven/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_oven_device(opts, driver, device) + local common_utils = require "common-utils" + local OVEN_DEVICE_ID = 0x007B + local oven_eps = common_utils.get_endpoints_for_dt(device, OVEN_DEVICE_ID) + if #oven_eps > 0 then + return true, require("matter-oven") + end + return false +end + +return is_oven_device diff --git a/drivers/SmartThings/matter-appliance/src/matter-oven/init.lua b/drivers/SmartThings/matter-appliance/src/matter-oven/init.lua new file mode 100644 index 0000000000..6c31504a6a --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-oven/init.lua @@ -0,0 +1,129 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local log = require "log" +local version = require "version" + +if version.api < 10 then + clusters.TemperatureControl = require "TemperatureControl" +end + +if version.api < 12 then + clusters.OvenMode = require "OvenMode" +end + +local SUPPORTED_OVEN_MODES_MAP = "__supported_oven_modes_map_key_" + +local COOK_SURFACE_DEVICE_TYPE_ID = 0x0077 +local COOK_TOP_DEVICE_TYPE_ID = 0x0078 +local TCC_DEVICE_TYPE_ID = 0x0071 + + +-- Lifecycle Handlers -- +local function device_added(driver, device) + -- We assume the following endpoint structure of oven device for now + local cook_surface_endpoints = common_utils.get_endpoints_for_dt(device, COOK_SURFACE_DEVICE_TYPE_ID) + local cook_top_endpoint = common_utils.get_endpoints_for_dt(device, COOK_TOP_DEVICE_TYPE_ID)[1] or device.MATTER_DEFAULT_ENDPOINT + local tcc_endpoints = common_utils.get_endpoints_for_dt(device, TCC_DEVICE_TYPE_ID) + local componentToEndpointMap = { + ["tccOne"] = tcc_endpoints[1], + ["tccTwo"] = tcc_endpoints[2], + ["cookTop"] = cook_top_endpoint, + ["cookSurfaceOne"] = cook_surface_endpoints[1], + ["cookSurfaceTwo"] = cook_surface_endpoints[2] + } + device:set_field(common_utils.COMPONENT_TO_ENDPOINT_MAP, componentToEndpointMap, { persist = true }) +end + +-- Matter Handlers -- +local function oven_supported_modes_attr_handler(driver, device, ib, response) + local supportedOvenModesMap = device:get_field(SUPPORTED_OVEN_MODES_MAP) or {} + local supportedOvenModes = {} + for _, mode in ipairs(ib.data.elements) do + clusters.OvenMode.types.ModeOptionStruct:augment_type(mode) + local modeLabel = mode.elements.label.value + log.info("Inserting supported oven mode: "..modeLabel) + table.insert(supportedOvenModes, modeLabel) + end + supportedOvenModesMap[string.format(ib.endpoint_id)] = supportedOvenModes + device:set_field(SUPPORTED_OVEN_MODES_MAP, supportedOvenModesMap, {persist = true}) + local event = capabilities.mode.supportedModes(supportedOvenModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supportedOvenModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function oven_mode_attr_handler(driver, device, ib, response) + local supportedOvenModesMap = device:get_field(SUPPORTED_OVEN_MODES_MAP) or {} + local supportedOvenModes = supportedOvenModesMap[string.format(ib.endpoint_id)] or {} + local currentMode = ib.data.value + if supportedOvenModes[currentMode+1] then + local mode = supportedOvenModes[currentMode+1] + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode)) + return + end + log.warn("oven_mode_attr_handler received unsupported mode for endpoint"..ib.endpoint_id) +end + +local function temperature_setpoint_attr_handler(driver, device, ib, response) + common_utils.temperature_setpoint_attr_handler(device, ib, "oven") +end + +local function setpoint_limit_handler(limit_field) + return function(driver, device, ib, response) + common_utils.setpoint_limit_handler(device, ib, limit_field, "oven") + end +end + +-- Capability Handlers -- +local function handle_oven_mode(driver, device, cmd) + local ep = device:component_to_endpoint(cmd.component) + local supportedOvenModesMap = device:get_field(SUPPORTED_OVEN_MODES_MAP) or {} + local supportedOvenModes = supportedOvenModesMap[string.format(ep)] or {} + for i, mode in ipairs(supportedOvenModes) do + if cmd.args.mode == mode then + device:send(clusters.OvenMode.commands.ChangeToMode(device, ep, i - 1)) + return + end + end + log.warn("handle_oven_mode received unsupported mode: ".." for endpoint: "..ep) +end + +local function handle_temperature_setpoint(driver, device, cmd) + common_utils.handle_temperature_setpoint(device, cmd, "oven") +end + +local matter_oven_handler = { + NAME = "matter-oven", + lifecycle_handlers = { + added = device_added + }, + matter_handlers = { + attr = { + [clusters.TemperatureControl.ID] = { + [clusters.TemperatureControl.attributes.TemperatureSetpoint.ID] = temperature_setpoint_attr_handler, + [clusters.TemperatureControl.attributes.MinTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MIN_TEMP), + [clusters.TemperatureControl.attributes.MaxTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MAX_TEMP) + }, + [clusters.OvenMode.ID] = { + [clusters.OvenMode.attributes.SupportedModes.ID] = oven_supported_modes_attr_handler, + [clusters.OvenMode.attributes.CurrentMode.ID] = oven_mode_attr_handler + } + } + }, + capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = handle_oven_mode + }, + [capabilities.temperatureSetpoint.ID] = { + [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = handle_temperature_setpoint + } + }, + can_handle = require("matter-oven.can_handle"), +} + +return matter_oven_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-refrigerator/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/can_handle.lua new file mode 100644 index 0000000000..376b32cc2d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_refrigerator(opts, driver, device) + local REFRIGERATOR_DEVICE_TYPE_ID = 0x0070 + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == REFRIGERATOR_DEVICE_TYPE_ID then + return true, require("matter-refrigerator") + end + end + end + return false +end + +return is_matter_refrigerator diff --git a/drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua new file mode 100644 index 0000000000..c48412e849 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua @@ -0,0 +1,166 @@ +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local embedded_cluster_utils = require "embedded-cluster-utils" +local version = require "version" + +local TEMPERATURE_CONTROLLED_CABINET_DEVICE_TYPE_ID = 0x0071 + +if version.api < 10 then + clusters.RefrigeratorAlarm = require "RefrigeratorAlarm" + clusters.RefrigeratorAndTemperatureControlledCabinetMode = require "RefrigeratorAndTemperatureControlledCabinetMode" + clusters.TemperatureControl = require "TemperatureControl" +end + +local SUPPORTED_REFRIGERATOR_TCC_MODES_MAP = "__supported_refrigerator_tcc_modes_map" + +-- Lifecycle Handlers -- +local function device_added(driver, device) + local cabinet_eps = {} + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == TEMPERATURE_CONTROLLED_CABINET_DEVICE_TYPE_ID then + table.insert(cabinet_eps, ep.endpoint_id) + break + end + end + end + + if #cabinet_eps > 1 then + table.sort(cabinet_eps) + local componentToEndpointMap = { -- This is just a guess for now + ["refrigerator"] = cabinet_eps[1], + ["freezer"] = cabinet_eps[2] + } + device:set_field(common_utils.COMPONENT_TO_ENDPOINT_MAP, componentToEndpointMap, {persist = true}) + end +end + +local function do_configure(driver, device) + local tn_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_NUMBER}) + local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL}) + local profile_name = "refrigerator-freezer" + if #tn_eps > 0 then + profile_name = profile_name .. "-tn" + common_utils.query_setpoint_limits(device) + end + if #tl_eps > 0 then + profile_name = profile_name .. "-tl" + end + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +-- Matter Handlers -- +local function temperature_setpoint_attr_handler(driver, device, ib, response) + local component = device:endpoint_to_component(ib.endpoint_id) + if not (component == "refrigerator" or component == "freezer") then + device.log.warn("Not a supported device type") + return + end + common_utils.temperature_setpoint_attr_handler(device, ib, component) +end + +local function setpoint_limit_handler(limit_field) + return function(driver, device, ib, response) + local component = device:endpoint_to_component(ib.endpoint_id) + if not (component == "refrigerator" or component == "freezer") then + device.log.warn("Not a supported device type") + return + end + common_utils.setpoint_limit_handler(device, ib, limit_field, component) + end +end + +local function refrigerator_tcc_supported_modes_attr_handler(driver, device, ib, response) + local supportedRefrigeratorTccModesMap = device:get_field(SUPPORTED_REFRIGERATOR_TCC_MODES_MAP) or {} + local supportedRefrigeratorTccModes = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 10 then + clusters.RefrigeratorAndTemperatureControlledCabinetMode.types.ModeOptionStruct:augment_type(mode) + end + table.insert(supportedRefrigeratorTccModes, mode.elements.label.value) + end + supportedRefrigeratorTccModesMap[ib.endpoint_id] = supportedRefrigeratorTccModes + device:set_field(SUPPORTED_REFRIGERATOR_TCC_MODES_MAP, supportedRefrigeratorTccModesMap, {persist = true}) + local event = capabilities.mode.supportedModes(supportedRefrigeratorTccModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supportedRefrigeratorTccModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function refrigerator_tcc_mode_attr_handler(driver, device, ib, response) + local supportedRefrigeratorTccModesMap = device:get_field(SUPPORTED_REFRIGERATOR_TCC_MODES_MAP) + local supportedRefrigeratorTccModes = supportedRefrigeratorTccModesMap[ib.endpoint_id] or {} + local currentMode = ib.data.value + for i, mode in ipairs(supportedRefrigeratorTccModes) do + if i - 1 == currentMode then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode)) + break + end + end +end + +local function refrigerator_alarm_attr_handler(driver, device, ib, response) + if ib.data.value & clusters.RefrigeratorAlarm.types.AlarmMap.DOOR_OPEN > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.contactSensor.contact.open()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.contactSensor.contact.closed()) + end +end + +-- Capability Handlers -- +local function handle_refrigerator_tcc_mode(driver, device, cmd) + local ep = device:component_to_endpoint(cmd.component) + local supportedRefrigeratorTccModesMap = device:get_field(SUPPORTED_REFRIGERATOR_TCC_MODES_MAP) + local supportedRefrigeratorTccModes = supportedRefrigeratorTccModesMap[ep] or {} + for i, mode in ipairs(supportedRefrigeratorTccModes) do + if cmd.args.mode == mode then + device:send(clusters.RefrigeratorAndTemperatureControlledCabinetMode.commands.ChangeToMode(device, ep, i - 1)) + return + end + end +end + +local function handle_temperature_setpoint(driver, device, cmd) + common_utils.handle_temperature_setpoint(device, cmd, cmd.component) +end + +local matter_refrigerator_handler = { + NAME = "matter-refrigerator", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + matter_handlers = { + attr = { + [clusters.TemperatureControl.ID] = { + [clusters.TemperatureControl.attributes.TemperatureSetpoint.ID] = temperature_setpoint_attr_handler, + [clusters.TemperatureControl.attributes.MinTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MIN_TEMP), + [clusters.TemperatureControl.attributes.MaxTemperature.ID] = setpoint_limit_handler(common_utils.setpoint_limit_device_field.MAX_TEMP) + }, + [clusters.RefrigeratorAndTemperatureControlledCabinetMode.ID] = { + [clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.SupportedModes.ID] = refrigerator_tcc_supported_modes_attr_handler, + [clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.CurrentMode.ID] = refrigerator_tcc_mode_attr_handler + }, + [clusters.RefrigeratorAlarm.ID] = { + [clusters.RefrigeratorAlarm.attributes.State.ID] = refrigerator_alarm_attr_handler + } + } + }, + capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = handle_refrigerator_tcc_mode + }, + [capabilities.temperatureSetpoint.ID] = { + [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = handle_temperature_setpoint + } + }, + can_handle = require("matter-refrigerator.can_handle"), +} + +return matter_refrigerator_handler diff --git a/drivers/SmartThings/matter-appliance/src/sub_drivers.lua b/drivers/SmartThings/matter-appliance/src/sub_drivers.lua new file mode 100644 index 0000000000..e0ad824520 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/sub_drivers.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("matter-cook-top"), + lazy_load_if_possible("matter-dishwasher"), + lazy_load_if_possible("matter-extractor-hood"), + lazy_load_if_possible("matter-laundry"), + lazy_load_if_possible("matter-microwave-oven"), + lazy_load_if_possible("matter-oven"), + lazy_load_if_possible("matter-refrigerator"), +} +return sub_drivers diff --git a/drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua b/drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua new file mode 100644 index 0000000000..0321d956ce --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua @@ -0,0 +1,199 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local COOK_TOP_ENDPOINT = 1 +local COOK_SURFACE_ONE_ENDPOINT = 2 +local COOK_SURFACE_TWO_ENDPOINT = 3 + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("cook-surface-one-tl-cook-surface-two-tl.yml"), --on an actual device we would switch to this over doConfigure. + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = COOK_TOP_ENDPOINT, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", feature_map = 4}, --OffOnly feature + }, + device_types = { + { device_type_id = 0x0078, device_type_revision = 1 } -- Cook Top + } + }, + { + endpoint_id = COOK_SURFACE_ONE_ENDPOINT, + clusters = { + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2 }, --Temperature Level + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0077, device_type_revision = 1 } -- Cook Surface + } + }, + { + endpoint_id = COOK_SURFACE_TWO_ENDPOINT, + clusters = { + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2}, --Temperature Level + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0077, device_type_revision = 1 } -- Cook Surface + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ profile = "cook-surface-one-tl-cook-surface-two-tl" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Assert component to endpoint map", + function() + local component_to_endpoint_map = mock_device:get_field("__component_to_endpoint_map") + assert(component_to_endpoint_map["cookSurfaceOne"] == COOK_SURFACE_ONE_ENDPOINT, "Cook Surface One Endpoint must be 2") + assert(component_to_endpoint_map["cookSurfaceTwo"] == COOK_SURFACE_TWO_ENDPOINT, "Cook Surface Two Endpoint must be 3") + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Off command should send appropriate commands", + -- we do not test "on" command, as cook-top is supposed to have offOnly feature. + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, COOK_TOP_ENDPOINT) + } + } + }, + { + min_api_version = 17 + } +) + +local utf1 = require "st.matter.data_types.UTF8String1" + +test.register_message_test( + "Cook Surface Two: TemperatureControl Supported Levels must be registered and setTemperatureLevel level command should send appropriate command", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels:build_test_report_data(mock_device, COOK_SURFACE_TWO_ENDPOINT, {utf1("Level 1"), utf1("Level 2"), utf1("Level 3")}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("cookSurfaceTwo", capabilities.temperatureLevel.supportedTemperatureLevels({"Level 1", "Level 2", "Level 3"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureLevel", component = "cookSurfaceTwo", command = "setTemperatureLevel", args = {"Level 1"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.server.commands.SetTemperature(mock_device, COOK_SURFACE_TWO_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "MeasuredValue of TemperatureMeasurement clusters should be reported correctly.", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 2, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("cookSurfaceOne", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 3, 20*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("cookSurfaceTwo", capabilities.temperatureMeasurement.temperature({ value = 20.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua b/drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua new file mode 100644 index 0000000000..6819815506 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua @@ -0,0 +1,556 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(6) +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local APPLICATION_ENDPOINT = 1 + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("dishwasher-tn-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + }, + { cluster_id = clusters.DishwasherAlarm.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.DishwasherMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0075, device_type_revision = 1 } -- Dishwasher + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.DishwasherMode.attributes.CurrentMode, + clusters.DishwasherMode.attributes.SupportedModes, + clusters.DishwasherAlarm.attributes.State, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device.id, read_req}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ profile = "dishwasher-tn-tl" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Off command should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, APPLICATION_ENDPOINT) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'start' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "start", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Start(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.RUNNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.running()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'stop' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "stop", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Stop(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.STOPPED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.stopped()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'pause' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "pause", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Pause(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.PAUSED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.paused()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "On receiving OperationalError, the appropriate operationalState event must be emitted", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.unableToStartOrResume()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.unableToCompleteOperation()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Dishwasher Supported Modes must be registered and Dishwasher Mode command should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DishwasherMode.attributes.SupportedModes:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + { + clusters.DishwasherMode.types.ModeOptionStruct({ ["label"] = "Quick", ["mode"] = 0, ["mode_tags"] = {} }), + clusters.DishwasherMode.types.ModeOptionStruct({ ["label"] = "Super Dry", ["mode"] = 1, ["mode_tags"] = {} }) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({"Quick", "Super Dry"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({"Quick", "Super Dry"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = {"Quick"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.DishwasherMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, 0) --0 is the index where Quick is stored. + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = {"Super Dry"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.DishwasherMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, 1) --1 is the index where Super Dry is stored. + } + } + }, + { + min_api_version = 17 + } +) + +local utf1 = require "st.matter.data_types.UTF8String1" + +test.register_message_test( + "TemperatureControl Supported Levels must be registered and setTemperatureLevel level command should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels:build_test_report_data(mock_device, APPLICATION_ENDPOINT, {utf1("Level 1"), utf1("Level 2"), utf1("Level 3")}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureLevel.supportedTemperatureLevels({"Level 1", "Level 2", "Level 3"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureLevel", component = "main", command = "setTemperatureLevel", args = {"Level 1"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.server.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 10000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 9000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=0.0,maximum=100.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 90.0, unit = "C"})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {40.0}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, 40 * 100, nil) + } + }, + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua b/drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua new file mode 100644 index 0000000000..d176352f80 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua @@ -0,0 +1,723 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("extractor-hood-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.HepaFilterMonitoring.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = clusters.FanControl.types.FanControlFeature.WIND}, + }, + device_types = { + {device_type_id = 0x007A, device_type_revision = 1} -- Extractor Hood + } + } + } +}) + +local mock_device_onoff = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("extractor-hood-wind-light.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = clusters.FanControl.types.FanControlFeature.WIND}, + }, + device_types = { + {device_type_id = 0x007A, device_type_revision = 1}, -- Extractor Hood + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0103, device_type_revision = 1} -- OnOff Light + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local subscribed_attributes = { + [capabilities.fanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + [capabilities.filterState.ID] = { + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition + }, + [capabilities.filterStatus.ID] = { + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication + } + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ profile = "extractor-hood-hepa-ac-wind" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_onoff() + test.disable_startup_messages() + local cluster_subscribe_list = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.OnOff.attributes.OnOff + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_onoff) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_onoff)) + end + end + test.mock_device.add_test_device(mock_device_onoff) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff.id, "init" }) + test.socket.matter:__expect_send({mock_device_onoff.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff.id, "doConfigure"}) + mock_device_onoff:expect_metadata_update({ profile = "extractor-hood-wind-light" }) + mock_device_onoff:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Test fan percent", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.server.attributes.PercentCurrent:build_test_report_data(mock_device, 1, 10) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(10)) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanSpeedPercent", component = "main", command = "setPercent", args = { 50 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan mode matter handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.types.FanModeEnum.OFF) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("off")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.types.FanModeEnum.LOW) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("low")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.types.FanModeEnum.MEDIUM) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("medium")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.types.FanModeEnum.HIGH) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("high")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.types.FanModeEnum.AUTO) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("auto")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan mode capability handler", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanMode", component = "main", command = "setFanMode", args = { capabilities.fanMode.fanMode.off.NAME } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.types.FanModeEnum.OFF) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanMode", component = "main", command = "setFanMode", args = { capabilities.fanMode.fanMode.low.NAME } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.types.FanModeEnum.LOW) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanMode", component = "main", command = "setFanMode", args = { capabilities.fanMode.fanMode.medium.NAME } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.types.FanModeEnum.MEDIUM) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanMode", component = "main", command = "setFanMode", args = { capabilities.fanMode.fanMode.high.NAME } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.types.FanModeEnum.HIGH) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanMode", component = "main", command = "setFanMode", args = { capabilities.fanMode.fanMode.auto.NAME } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.types.FanModeEnum.AUTO) + } + } + }, + { + min_api_version = 17 + } +) +test.register_message_test( + "Test setting fan mode sequence", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({ + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({ + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({ + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({ + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanModeSequence.OFF_HIGH_AUTO) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({ + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanModeSequence.OFF_HIGH) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({ + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME + }, {visibility={displayed=false}})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test wind mode matter handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSupport:build_test_report_data(mock_device, 1, 0x02) -- NoWind and NaturalWind + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.supportedWindModes({ + capabilities.windMode.windMode.noWind.NAME, + capabilities.windMode.windMode.naturalWind.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:build_test_report_data(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.windMode.naturalWind()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test wind mode capability handler", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "windMode", component = "main", command = "setWindMode", args = { "sleepWind" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.SLEEP_WIND) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "windMode", component = "main", command = "setWindMode", args = { "naturalWind" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test HEPA filter handlers", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.Condition:build_test_report_data(mock_device, 1, 3) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterState.filterLifeRemaining(3)) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.HepaFilterMonitoring.attributes.ChangeIndication.OK) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.HepaFilterMonitoring.attributes.ChangeIndication.WARNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.HepaFilterMonitoring.attributes.ChangeIndication.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.replace()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test Activated Carbon filter handlers", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition:build_test_report_data(mock_device, 1, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterState.filterLifeRemaining(5)) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.OK) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.WARNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.replace()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change on init for extractor hood with onoff light", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff.id, "doConfigure" }) + mock_device_onoff:expect_metadata_update({ profile = "extractor-hood-wind-light" }) + mock_device_onoff:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_onoff, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On and off commands should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_device_onoff.id, + { capability = "switch", component = "light", command = "on", args = { } } + }) + test.socket.matter:__expect_send({ + mock_device_onoff.id, + clusters.OnOff.server.commands.On(mock_device_onoff, 2) + }) + test.socket.capability:__queue_receive({ + mock_device_onoff.id, + { capability = "switch", component = "light", command = "off", args = { } } + }) + test.socket.matter:__expect_send({ + mock_device_onoff.id, + clusters.OnOff.server.commands.Off(mock_device_onoff, 2) + }) + end, + { + test_init = test_init_onoff, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Light on and off command handler", + function() + test.socket.matter:__queue_receive({ + mock_device_onoff.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device_onoff, 2, true) + }) + test.socket.capability:__expect_send( + mock_device_onoff:generate_test_message("light", capabilities.switch.switch.on()) + ) + test.socket.matter:__queue_receive({ + mock_device_onoff.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device_onoff, 2, false) + }) + test.socket.capability:__expect_send( + mock_device_onoff:generate_test_message("light", capabilities.switch.switch.off()) + ) + end, + { + test_init = test_init_onoff, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua b/drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua new file mode 100644 index 0000000000..ba1440af85 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua @@ -0,0 +1,598 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(6) +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local APPLICATION_ENDPOINT = 1 + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("laundry-dryer-tn-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + }, + { cluster_id = clusters.LaundryWasherMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x007C, device_type_revision = 1 } -- LaundryDryer + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LaundryWasherMode.attributes.CurrentMode, + clusters.LaundryWasherMode.attributes.SupportedModes, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device.id, read_req}) + mock_device:expect_metadata_update({ profile = "laundry-dryer-tn-tl" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Off command should send appropriate commands", + -- we do not test "on" command, as laundry devices are supposed to have offOnly feature. + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, APPLICATION_ENDPOINT) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'start' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "start", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Start(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.RUNNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.running()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'stop' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "stop", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Stop(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.STOPPED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.stopped()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'pause' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "pause", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Pause(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.PAUSED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.paused()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "On receiving OperationalError, the appropriate operationalState event must be emitted", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.unableToStartOrResume()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.unableToCompleteOperation()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Laundry Washer Suppoerted Modes must be registered and Laundry Washer Mode command should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LaundryWasherMode.attributes.SupportedModes:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + { + clusters.LaundryWasherMode.types.ModeOptionStruct({ ["label"] = "Quick", ["mode"] = 0, ["mode_tags"] = {} }), + clusters.LaundryWasherMode.types.ModeOptionStruct({ ["label"] = "Super Dry", ["mode"] = 1, ["mode_tags"] = {} }) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({"Quick", "Super Dry"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({"Quick", "Super Dry"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = {"Quick"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LaundryWasherMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, 0) --0 is the index where Quick is stored. + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = {"Super Dry"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LaundryWasherMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, 1) --1 is the index where Super Dry is stored. + } + } + }, + { + min_api_version = 17 + } +) + +local utf1 = require "st.matter.data_types.UTF8String1" + +test.register_message_test( + "TemperatureControl Supported Levels must be registered and setTemperatureLevel level command should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels:build_test_report_data(mock_device, APPLICATION_ENDPOINT, {utf1("Level 1"), utf1("Level 2"), utf1("Level 3")}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureLevel.supportedTemperatureLevels({"Level 1", "Level 2", "Level 3"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureLevel", component = "main", command = "setTemperatureLevel", args = {"Level 1"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.server.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for laundry dryer", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 3000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 7000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 6000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=30.0,maximum=70.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 60.0, unit = "C"})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {40.0}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, 40 * 100, nil) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for laundry dryer, temp bounds out of range", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, APPLICATION_ENDPOINT, -1500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 11000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 5000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=0.0,maximum=100.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 50.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua b/drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua new file mode 100644 index 0000000000..6aba845a27 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua @@ -0,0 +1,185 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(6) +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local APPLICATION_ENDPOINT = 1 + +local mock_device_washer = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("laundry-washer-tn-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + }, + { cluster_id = clusters.LaundryWasherMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0073, device_type_revision = 1 } -- LaundryWasher + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_washer) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LaundryWasherMode.attributes.CurrentMode, + clusters.LaundryWasherMode.attributes.SupportedModes, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + } + local subscribe_request_washer = cluster_subscribe_list[1]:subscribe(mock_device_washer) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request_washer:merge(cluster:subscribe(mock_device_washer)) + end + end + test.socket.matter:__expect_send({ mock_device_washer.id, subscribe_request_washer }) + test.socket.device_lifecycle:__queue_receive({ mock_device_washer.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_washer.id, "init" }) + test.socket.matter:__expect_send({ mock_device_washer.id, subscribe_request_washer }) + test.socket.device_lifecycle:__queue_receive({ mock_device_washer.id, "doConfigure"}) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device_washer.id, read_req}) + mock_device_washer:expect_metadata_update({ profile = "laundry-washer-tn-tl" }) + mock_device_washer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for laundry washer", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_washer, APPLICATION_ENDPOINT, 1500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_washer, APPLICATION_ENDPOINT, 5000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_washer.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_washer, APPLICATION_ENDPOINT, 4000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=15.0,maximum=50.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 40.0, unit = "C"})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_washer.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {25.0}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_washer.id, + clusters.TemperatureControl.commands.SetTemperature(mock_device_washer, APPLICATION_ENDPOINT, 25 * 100, nil) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for laundry washer, temp bounds out of range", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_washer, APPLICATION_ENDPOINT, -1000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_washer, APPLICATION_ENDPOINT, 12000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_washer.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_washer, APPLICATION_ENDPOINT, 3000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=0.0,maximum=100.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 30.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua b/drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua new file mode 100644 index 0000000000..7e59d7925f --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua @@ -0,0 +1,927 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(5) +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 10 then + clusters.TemperatureControl = require "TemperatureControl" +end + +if version.api < 12 then + clusters.OvenMode = require "OvenMode" +end + +local dishwasher_ep = 1 +local washer_ep = 1 +local dryer_ep = 1 +local oven_ep = 1 +local oven_tcc_one_ep = 2 +local oven_tcc_two_ep = 3 +local cook_top_ep = 4 +local cook_surface_one_ep = 5 +local cook_surface_two_ep = 6 +local refrigerator_ep = 1 +local freezer_ep = 2 + +local mock_device_dishwasher = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("dishwasher-tn-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = dishwasher_ep, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + }, + { cluster_id = clusters.DishwasherAlarm.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.DishwasherMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0075, device_type_revision = 1 } -- Dishwasher + } + } + } +}) + +local mock_device_washer = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("laundry-washer-tn-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = washer_ep, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + }, + { cluster_id = clusters.LaundryWasherMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0073, device_type_revision = 1 } -- LaundryWasher + } + } + } +}) + +local mock_device_dryer = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("laundry-dryer-tn-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = dryer_ep, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + }, + { cluster_id = clusters.LaundryWasherMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x007C, device_type_revision = 1 } -- LaundryDryer + } + } + } +}) + +local mock_device_oven = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = oven_ep, + clusters = {}, + device_types = { + { device_type_id = 0x007B, device_type_revision = 1 } -- Oven + } + }, + { + endpoint_id = oven_tcc_one_ep, + clusters = { + { cluster_id = clusters.OvenMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 1 }, --Temperature Number + }, + device_types = { + { device_type_id = 0x0071, device_type_revision = 1 } -- Oven TCC + } + }, + { + endpoint_id = oven_tcc_two_ep, + clusters = { + { cluster_id = clusters.OvenMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2 }, --Temperature Level + }, + device_types = { + { device_type_id = 0x0071, device_type_revision = 1 } -- Oven TCC + } + }, + { + endpoint_id = cook_top_ep, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", feature_map = 4 }, --OffOnly feature + }, + device_types = { + { device_type_id = 0x0078, device_type_revision = 1 } -- Cook Top + } + }, + { + endpoint_id = cook_surface_one_ep, + clusters = { + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2 }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0077, device_type_revision = 1 } -- Cook Surface + } + }, + { + endpoint_id = cook_surface_two_ep, + clusters = { + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2 }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0077, device_type_revision = 1 } -- Cook Surface + } + } + } +}) + +local mock_device_refrigerator = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("refrigerator-freezer-tn.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = refrigerator_ep, + clusters = { + { cluster_id = clusters.RefrigeratorAlarm.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.RefrigeratorAndTemperatureControlledCabinetMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0070, device_type_revision = 1 }, -- Refrigerator + { device_type_id = 0x0071, device_type_revision = 1 } -- Temperature Controlled Cabinet + } + }, + { + endpoint_id = freezer_ep, + clusters = { + { cluster_id = clusters.RefrigeratorAlarm.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.RefrigeratorAndTemperatureControlledCabinetMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0070, device_type_revision = 1 }, -- Refrigerator + { device_type_id = 0x0071, device_type_revision = 1 } -- Temperature Controlled Cabinet + } + } + } +}) + +local function test_init_dishwasher() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_dishwasher) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.DishwasherMode.attributes.CurrentMode, + clusters.DishwasherMode.attributes.SupportedModes, + clusters.DishwasherAlarm.attributes.State, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_dishwasher) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_dishwasher)) + end + end + test.socket.matter:__expect_send({ mock_device_dishwasher.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dishwasher.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dishwasher.id, "init" }) + test.socket.matter:__expect_send({ mock_device_dishwasher.id, subscribe_request }) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device_dishwasher.id, read_req}) + test.socket.device_lifecycle:__queue_receive({ mock_device_dishwasher.id, "doConfigure"}) + mock_device_dishwasher:expect_metadata_update({ profile = "dishwasher-tn-tl" }) + mock_device_dishwasher:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_dryer() + test.disable_startup_messages() + test.socket.matter:__set_channel_ordering("relaxed") + test.mock_device.add_test_device(mock_device_dryer) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LaundryWasherMode.attributes.CurrentMode, + clusters.LaundryWasherMode.attributes.SupportedModes, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_dryer) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_dryer)) + end + end + test.socket.matter:__expect_send({ mock_device_dryer.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dryer.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dryer.id, "init" }) + test.socket.matter:__expect_send({ mock_device_dryer.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dryer.id, "doConfigure"}) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device_dryer.id, read_req}) + mock_device_dryer:expect_metadata_update({ profile = "laundry-dryer-tn-tl" }) + mock_device_dryer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_washer() + test.disable_startup_messages() + test.socket.matter:__set_channel_ordering("relaxed") + test.mock_device.add_test_device(mock_device_washer) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LaundryWasherMode.attributes.CurrentMode, + clusters.LaundryWasherMode.attributes.SupportedModes, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_washer) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_washer)) + end + end + test.socket.matter:__expect_send({ mock_device_washer.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_washer.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_washer.id, "init" }) + test.socket.matter:__expect_send({ mock_device_washer.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_washer.id, "doConfigure"}) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device_washer.id, read_req}) + mock_device_washer:expect_metadata_update({ profile = "laundry-washer-tn-tl" }) + mock_device_washer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_oven() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_oven) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels, + clusters.OvenMode.attributes.CurrentMode, + clusters.OvenMode.attributes.SupportedModes, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_oven) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_oven)) + end + end + test.socket.matter:__expect_send({ mock_device_oven.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_oven.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_oven.id, "init" }) + test.socket.matter:__expect_send({ mock_device_oven.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_oven.id, "doConfigure"}) + mock_device_oven:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_refrigerator() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_refrigerator) + local cluster_subscribe_list = { + clusters.RefrigeratorAlarm.attributes.State, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.CurrentMode, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.SupportedModes, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_refrigerator) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_refrigerator)) + end + end + test.socket.matter:__expect_send({ mock_device_refrigerator.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_refrigerator.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_refrigerator.id, "init" }) + test.socket.matter:__expect_send({ mock_device_refrigerator.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device_refrigerator.id, "doConfigure"}) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device_refrigerator.id, read_req}) + mock_device_refrigerator:expect_metadata_update({ profile = "refrigerator-freezer-tn-tl" }) + mock_device_refrigerator:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for dishwasher", function() + test.socket.matter:__queue_receive( + { + mock_device_dishwasher.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_dishwasher, dishwasher_ep, 0) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dishwasher.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_dishwasher, dishwasher_ep, 10000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dishwasher.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_dishwasher, dishwasher_ep, 9000) + } + ) + test.socket.capability:__expect_send( + mock_device_dishwasher:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=33.0,maximum=90.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_dishwasher:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 90.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_dishwasher.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {40.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_dishwasher.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dishwasher, dishwasher_ep, 40 * 100, nil) } + ) + end, + { + test_init = test_init_dishwasher, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for dishwasher, temp bounds out of range and temp setpoint converted from F to C", function() + test.socket.matter:__queue_receive( + { + mock_device_dishwasher.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_dishwasher, dishwasher_ep, 0) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dishwasher.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_dishwasher, dishwasher_ep, 10000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dishwasher.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_dishwasher, dishwasher_ep, 9000) + } + ) + test.socket.capability:__expect_send( + mock_device_dishwasher:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=33.0,maximum=90.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_dishwasher:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 90.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_dishwasher.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {122.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_dishwasher.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dishwasher, dishwasher_ep, 50 * 100, nil) } + ) + end, + { + test_init = test_init_dishwasher, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for laundry washer", function() + test.socket.matter:__queue_receive( + { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_washer, washer_ep, 2000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_washer, washer_ep, 4000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_washer.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_washer, washer_ep, 3500) + } + ) + test.socket.capability:__expect_send( + mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=20.0,maximum=40.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 35.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_washer.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {28.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_washer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_washer, washer_ep, 28 * 100, nil) } + ) + end, + { + test_init = test_init_washer, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for laundry washer, temp bounds out of range and temp setpoint converted from F to C", function() + test.socket.matter:__queue_receive( + { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_washer, washer_ep, 0) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_washer.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_washer, washer_ep, 10000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_washer.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_washer, washer_ep, 3000) + } + ) + test.socket.capability:__expect_send( + mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=13.0,maximum=55.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 30.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_washer.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {122.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_washer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_washer, washer_ep, 50 * 100, nil) } + ) + end, + { + test_init = test_init_washer, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for laundry dryer", function() + test.socket.matter:__queue_receive( + { + mock_device_dryer.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_dryer, dryer_ep, 3000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dryer.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_dryer, dryer_ep, 7000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dryer.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_dryer, dryer_ep, 6000) + } + ) + test.socket.capability:__expect_send( + mock_device_dryer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=30.0,maximum=70.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_dryer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 60.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_dryer.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {40.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_dryer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dryer, dryer_ep, 40 * 100, nil) } + ) + end, + { + test_init = test_init_dryer, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for laundry dryer, temp bounds out of range and temp setpoint converted from F to C", function() + test.socket.matter:__queue_receive( + { + mock_device_dryer.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_dryer, dryer_ep, 0) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dryer.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_dryer, dryer_ep, 10000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_dryer.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_dryer, dryer_ep, 5000) + } + ) + test.socket.capability:__expect_send( + mock_device_dryer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=27.0,maximum=80.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_dryer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 50.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_dryer.id, + { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = {104.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_dryer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dryer, dryer_ep, 40 * 100, nil) } + ) + end, + { + test_init = test_init_dryer, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for oven", function() + test.socket.matter:__queue_receive( + { + mock_device_oven.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_oven, oven_tcc_one_ep, 12800) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_oven.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_oven, oven_tcc_one_ep, 20000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_oven.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_oven, oven_tcc_one_ep, 13000) + } + ) + test.socket.capability:__expect_send( + mock_device_oven:generate_test_message("tccOne", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=128.0,maximum=200.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_oven:generate_test_message("tccOne", capabilities.temperatureSetpoint.temperatureSetpoint({value = 130.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_oven.id, + { capability = "temperatureSetpoint", component = "tccOne", command = "setTemperatureSetpoint", args = {140.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_oven.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_oven, oven_tcc_one_ep, 140 * 100, nil) } + ) + end, + { + test_init = test_init_oven, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for oven, temp bounds out of range and temp setpoint converted from F to C", function() + test.socket.matter:__queue_receive( + { + mock_device_oven.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_oven, oven_tcc_one_ep, 10000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_oven.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_oven, oven_tcc_one_ep, 30000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_oven.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_oven, oven_tcc_one_ep, 13000) + } + ) + test.socket.capability:__expect_send( + mock_device_oven:generate_test_message("tccOne", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=127.0,maximum=260.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_oven:generate_test_message("tccOne", capabilities.temperatureSetpoint.temperatureSetpoint({value = 130.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_oven.id, + { capability = "temperatureSetpoint", component = "tccOne", command = "setTemperatureSetpoint", args = {284.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_oven.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_oven, oven_tcc_one_ep, 140 * 100, nil) } + ) + end, + { + test_init = test_init_oven, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for refrigerator endpoint", function() + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_refrigerator, refrigerator_ep, 0) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_refrigerator, refrigerator_ep, 1500) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_refrigerator, refrigerator_ep, 700) + } + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=0.0,maximum=15.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpoint({value = 7.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_refrigerator.id, + { capability = "temperatureSetpoint", component = "refrigerator", command = "setTemperatureSetpoint", args = {4.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, refrigerator_ep, 4 * 100, nil) } + ) + end, + { + test_init = test_init_refrigerator, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for refrigerator endpoint, temp bounds out of range and temp setpoint converted from F to C", function() + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_refrigerator, refrigerator_ep, -1000) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_refrigerator, refrigerator_ep, 2500) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_refrigerator, refrigerator_ep, 700) + } + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=-6.0,maximum=20.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpoint({value = 7.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_refrigerator.id, + { capability = "temperatureSetpoint", component = "refrigerator", command = "setTemperatureSetpoint", args = {50.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, refrigerator_ep, 10 * 100, nil) } + ) + end, + { + test_init = test_init_refrigerator, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for freezer endpoint", function() + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_refrigerator, freezer_ep, -2200) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_refrigerator, freezer_ep, -1400) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_refrigerator, freezer_ep, -1700) + } + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=-22.0,maximum=-14.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpoint({value = -17.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_refrigerator.id, + { capability = "temperatureSetpoint", component = "freezer", command = "setTemperatureSetpoint", args = {-15.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, freezer_ep, -15 * 100, nil) } + ) + end, + { + test_init = test_init_refrigerator, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "temperatureSetpoint command should send appropriate commands for freezer endpoint, temp bounds out of range and temp setpoint converted from F to C", function() + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device_refrigerator, freezer_ep, -2700) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device_refrigerator, freezer_ep, -500) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_refrigerator.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device_refrigerator, freezer_ep, -1500) + } + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=-24.0,maximum=-12.0,step=0.1}, unit = "C"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_refrigerator:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpoint({value = -15.0, unit = "C"})) + ) + test.socket.capability:__queue_receive( + { + mock_device_refrigerator.id, + { capability = "temperatureSetpoint", component = "freezer", command = "setTemperatureSetpoint", args = {-4.0} } + } + ) + test.socket.matter:__expect_send( + { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, freezer_ep, -20 * 100, nil) } + ) + end, + { + test_init = test_init_refrigerator, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua b/drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua new file mode 100644 index 0000000000..cf69721714 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua @@ -0,0 +1,579 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +clusters.OperationalState = require "OperationalState" +clusters.MicrowaveOvenControl = require "MicrowaveOvenControl" +clusters.MicrowaveOvenMode = require "MicrowaveOvenMode" + +local APPLICATION_ENDPOINT = 1 + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("microwave-oven.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OperationalState.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.MicrowaveOvenControl.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.MicrowaveOvenMode.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0079, device_type_revision = 1 } -- Microwave Oven + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.MicrowaveOvenMode.attributes.SupportedModes, + clusters.MicrowaveOvenMode.attributes.CurrentMode, + clusters.MicrowaveOvenControl.attributes.MaxCookTime, + clusters.MicrowaveOvenControl.attributes.CookTime + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.matter:__expect_send({ mock_device.id, clusters.MicrowaveOvenControl.attributes.MaxCookTime:read( + mock_device, APPLICATION_ENDPOINT) }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function init_supported_microwave_oven_modes() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.MicrowaveOvenMode.attributes.SupportedModes:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + { + clusters.MicrowaveOvenMode.types.ModeOptionStruct({ + ["label"] = "Grill", + ["mode"] = 0, + ["mode_tags"] = { + clusters.MicrowaveOvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.MicrowaveOvenMode.types.ModeOptionStruct({ + ["label"] = "Pre Heat", + ["mode"] = 1, + ["mode_tags"] = { + clusters.MicrowaveOvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }) + } + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.supportedModes({ "Grill", "Pre Heat" }, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.supportedArguments({ "Grill", "Pre Heat" }, {visibility={displayed=false}}))) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Setting operationalState command to 'start' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "start", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Start(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.RUNNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.running()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({},{visibility={displayed=false}})) + -- Prevent user from changing modes in between an operation. + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({},{visibility={displayed=false}})) + -- Prevent user from changing modes in between an operation. + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'stop' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "stop", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Stop(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.STOPPED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.stopped()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({ "Grill", "Pre Heat" },{visibility={displayed=false}})) + --When operation is stopped, enable mode options for user to choose. + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({ "Grill", "Pre Heat" },{visibility={displayed=false}})) + --When operation is stopped, enable mode options for user to choose. + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + test_init = function() + test_init() + init_supported_microwave_oven_modes() + end, + min_api_version = 17 + } +) + +test.register_message_test( + "Setting operationalState command to 'pause' should send appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "operationalState", component = "main", command = "pause", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.server.commands.Pause(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:read(mock_device, APPLICATION_ENDPOINT) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.PAUSED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.operationalState.operationalState.paused()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({},{visibility={displayed=false}})) + -- Prevent user from changing modes in between an operation. + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({},{visibility={displayed=false}})) + -- Prevent user from changing modes in between an operation. + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "On receiving OperationalError, the appropriate operationalState event must be emitted", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.unableToStartOrResume()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({},{visibility={displayed=false}})) + -- Prevent user from changing mode in event of error. + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({},{visibility={displayed=false}})) + -- Prevent user from changing mode in event of error. + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.unableToCompleteOperation()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({},{visibility={displayed=false}})) + -- Prevent user from changing mode in event of error. + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({},{visibility={displayed=false}})) + -- Prevent user from changing mode in event of error. + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({},{visibility={displayed=false}})) + -- Prevent user from changing mode in event of error. + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({},{visibility={displayed=false}})) + -- Prevent user from changing mode in event of error. + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.OperationalState.types.ErrorStateStruct({ + ["error_state_id"] = clusters.OperationalState.types.ErrorStateEnum.NO_ERROR, + ["error_state_label"] = "", + ["error_state_details"] = "" + })) + } + }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "The cookTimeRange value should be set on receiving MaxCookTime", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.MicrowaveOvenControl.attributes.MaxCookTime:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + 900) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.cookTime.cookTimeRange({ + minimum = 1, --minimum should be 1. + maximum = 900 + },{visibility={displayed=false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "This test case checks for the following events:\n1. Report cookTime value of 30 seconds.\n2. MicrowaveOven supportedModes must be registered.\n3. Setting oven mode and cookTime should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.MicrowaveOvenControl.attributes.CookTime:build_test_report_data(mock_device, APPLICATION_ENDPOINT, 30) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.cookTime.cookTime(30)) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.MicrowaveOvenMode.attributes.SupportedModes:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + { + clusters.MicrowaveOvenMode.types.ModeOptionStruct({ + ["label"] = "Grill", + ["mode"] = 0, + ["mode_tags"] = { + clusters.MicrowaveOvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.MicrowaveOvenMode.types.ModeOptionStruct({ + ["label"] = "Pre Heat", + ["mode"] = 1, + ["mode_tags"] = { + clusters.MicrowaveOvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedModes({ "Grill", "Pre Heat" }, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.mode.supportedArguments({ "Grill", "Pre Heat" }, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "Grill" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.MicrowaveOvenControl.commands.SetCookingParameters(mock_device, APPLICATION_ENDPOINT, + 0, --Index where Grill is stored + 30) --30 since that was the last received cookTime. + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "cookTime", component = "main", command = "setCookTime", args = { 300 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.MicrowaveOvenControl.commands.SetCookingParameters(mock_device, APPLICATION_ENDPOINT, + 0, --> Grill, as this was the last set microwave oven mode. + 300) + } + }, + }, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_oven.lua b/drivers/SmartThings/matter-appliance/src/test/test_oven.lua new file mode 100644 index 0000000000..905f34d666 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_oven.lua @@ -0,0 +1,564 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(6) +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local version = require "version" + +local OVEN_ENDPOINT = 1 +local OVEN_TCC_ONE_ENDPOINT = 2 +local OVEN_TCC_TWO_ENDPOINT = 3 +local COOK_TOP_ENDPOINT = 4 +local COOK_SURFACE_ONE_ENDPOINT = 5 +local COOK_SURFACE_TWO_ENDPOINT = 6 + +if version.api < 10 then + clusters.TemperatureControl = require "TemperatureControl" +end + +if version.api < 12 then + clusters.OvenMode = require "OvenMode" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("oven-cabinet-one-tn-cabinet-two-tl-cook-top-cook-surface-one-tl-cook-surface-two-tl.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = OVEN_ENDPOINT, + clusters = {}, + device_types = { + { device_type_id = 0x007B, device_type_revision = 1 } -- Oven + } + }, + { + endpoint_id = OVEN_TCC_ONE_ENDPOINT, + clusters = { + { cluster_id = clusters.OvenMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 1 }, --Temperature Number + }, + device_types = { + { device_type_id = 0x0071, device_type_revision = 1 } -- Oven TCC + } + }, + { + endpoint_id = OVEN_TCC_TWO_ENDPOINT, + clusters = { + { cluster_id = clusters.OvenMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2 }, --Temperature Level + }, + device_types = { + { device_type_id = 0x0071, device_type_revision = 1 } -- Oven TCC + } + }, + { + endpoint_id = COOK_TOP_ENDPOINT, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", feature_map = 4 }, --OffOnly feature + }, + device_types = { + { device_type_id = 0x0078, device_type_revision = 1 } -- Cook Top + } + }, + { + endpoint_id = COOK_SURFACE_ONE_ENDPOINT, + clusters = { + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2 }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0077, device_type_revision = 1 } -- Cook Surface + } + }, + { + endpoint_id = COOK_SURFACE_TWO_ENDPOINT, + clusters = { + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 2 }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0077, device_type_revision = 1 } -- Cook Surface + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureControl.attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels, + clusters.OvenMode.attributes.CurrentMode, + clusters.OvenMode.attributes.SupportedModes, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Assert component to endpoint map", + function() + local component_to_endpoint_map = mock_device:get_field("__component_to_endpoint_map") + assert(component_to_endpoint_map["tccOne"] == OVEN_TCC_ONE_ENDPOINT, "Oven TCC One Endpoint must be 2") + assert(component_to_endpoint_map["tccTwo"] == OVEN_TCC_TWO_ENDPOINT, "Oven TCC Two Endpoint must be 3") + assert(component_to_endpoint_map["cookTop"] == COOK_TOP_ENDPOINT, "Cook Top Endpoint must be 4") + assert(component_to_endpoint_map["cookSurfaceOne"] == COOK_SURFACE_ONE_ENDPOINT, + "Cook Surface One Endpoint must be 5") + assert(component_to_endpoint_map["cookSurfaceTwo"] == COOK_SURFACE_TWO_ENDPOINT, + "Cook Surface Two Endpoint must be 6") + end, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Oven TCC One: This test case checks for the following events:\n1. Oven supportedModes must be registered.\n2. Setting Oven mode should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OvenMode.attributes.SupportedModes:build_test_report_data(mock_device, OVEN_TCC_ONE_ENDPOINT, + { + clusters.OvenMode.types.ModeOptionStruct({ + ["label"] = "Grill", + ["mode"] = 0, + ["mode_tags"] = { + clusters.OvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.OvenMode.types.ModeOptionStruct({ + ["label"] = "Pre Heat", + ["mode"] = 1, + ["mode_tags"] = { + clusters.OvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccOne", + capabilities.mode.supportedModes({ "Grill", "Pre Heat" }, { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccOne", + capabilities.mode.supportedArguments({ "Grill", "Pre Heat" }, { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "tccOne", command = "setMode", args = { "Grill" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OvenMode.commands.ChangeToMode(mock_device, OVEN_TCC_ONE_ENDPOINT, 0) --Index where Grill is stored) + } + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "First Oven TCC: MeasuredValue of TemperatureMeasurement clusters should be reported correctly.", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, OVEN_TCC_ONE_ENDPOINT, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccOne", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "First Oven TCC: Verify temperatureSetpoint command sends the appropriate commands.", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, OVEN_TCC_ONE_ENDPOINT, 12800) --128*C + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, OVEN_TCC_ONE_ENDPOINT, 20000) --200*C + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, OVEN_TCC_ONE_ENDPOINT, 13000) --130*C + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccOne", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=128.0,maximum=200.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccOne", capabilities.temperatureSetpoint.temperatureSetpoint({value = 130.0, unit = "C"})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureSetpoint", component = "tccOne", command = "setTemperatureSetpoint", args = {130.0}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.commands.SetTemperature(mock_device, OVEN_TCC_ONE_ENDPOINT, 130 * 100, nil) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Oven TCC Two: This test case checks for the following events:\n1. Oven supportedModes must be registered.\n2. Setting Oven mode should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OvenMode.attributes.SupportedModes:build_test_report_data(mock_device, OVEN_TCC_TWO_ENDPOINT, + { + clusters.OvenMode.types.ModeOptionStruct({ + ["label"] = "Grill", + ["mode"] = 0, + ["mode_tags"] = { + clusters.OvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.OvenMode.types.ModeOptionStruct({ + ["label"] = "Pre Heat", + ["mode"] = 1, + ["mode_tags"] = { + clusters.OvenMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccTwo", + capabilities.mode.supportedModes({ "Grill", "Pre Heat" }, { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccTwo", + capabilities.mode.supportedArguments({ "Grill", "Pre Heat" }, { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "tccTwo", command = "setMode", args = { "Pre Heat" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OvenMode.commands.ChangeToMode(mock_device, OVEN_TCC_TWO_ENDPOINT, 1) --Index where Pre Heat is stored + } + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Oven TCC Two: MeasuredValue of TemperatureMeasurement clusters should be reported correctly.", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, OVEN_TCC_TWO_ENDPOINT, 50*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccTwo", capabilities.temperatureMeasurement.temperature({ value = 50.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +local utf1 = require "st.matter.data_types.UTF8String1" + +test.register_message_test( + "Second Oven TCC: TemperatureControl Supported Levels must be registered and setTemperatureLevel level command should send appropriate command", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels:build_test_report_data(mock_device, OVEN_TCC_TWO_ENDPOINT, {utf1("Level 1"), utf1("Level 2"), utf1("Level 3")}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("tccTwo", capabilities.temperatureLevel.supportedTemperatureLevels({"Level 1", "Level 2", "Level 3"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureLevel", component = "tccTwo", command = "setTemperatureLevel", args = {"Level 1"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.server.commands.SetTemperature(mock_device, OVEN_TCC_TWO_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Cook Top: Off command should send appropriate commands", + -- we do not test "on" command, as cook-top is supposed to have offOnly feature. + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "cookTop", command = "off", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, COOK_TOP_ENDPOINT) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Cook Surface One: TemperatureControl Supported Levels must be registered and setTemperatureLevel level command should send appropriate command", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels:build_test_report_data(mock_device, COOK_SURFACE_ONE_ENDPOINT, {utf1("Level 2"), utf1("Level 4"), utf1("Level 5")}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("cookSurfaceOne", capabilities.temperatureLevel.supportedTemperatureLevels({"Level 2", "Level 4", "Level 5"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureLevel", component = "cookSurfaceOne", command = "setTemperatureLevel", args = {"Level 5"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.server.commands.SetTemperature(mock_device, COOK_SURFACE_ONE_ENDPOINT, nil, 2) -- 2 is the index where Level 5 is stored. + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Cook Surface Two: TemperatureControl Supported Levels must be registered and setTemperatureLevel level command should send appropriate command", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.SupportedTemperatureLevels:build_test_report_data(mock_device, COOK_SURFACE_TWO_ENDPOINT, {utf1("Level 3"), utf1("Level 4"), utf1("Level 5")}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("cookSurfaceTwo", capabilities.temperatureLevel.supportedTemperatureLevels({"Level 3", "Level 4", "Level 5"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureLevel", component = "cookSurfaceTwo", command = "setTemperatureLevel", args = {"Level 4"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.server.commands.SetTemperature(mock_device, COOK_SURFACE_TWO_ENDPOINT, nil, 1) -- 1 is the index where Level 4 is stored. + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Cook Surface One: MeasuredValue of TemperatureMeasurement clusters should be reported correctly.", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, COOK_SURFACE_ONE_ENDPOINT, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("cookSurfaceOne", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Cook Surface Two: MeasuredValue of TemperatureMeasurement clusters should be reported correctly.", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, COOK_SURFACE_TWO_ENDPOINT, 20*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("cookSurfaceTwo", capabilities.temperatureMeasurement.temperature({ value = 20.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua b/drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua new file mode 100644 index 0000000000..1210819d51 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua @@ -0,0 +1,358 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(6) +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local refrigerator_ep = 1 +local freezer_ep = 2 + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("refrigerator-freezer-tn.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = refrigerator_ep, + clusters = { + { cluster_id = clusters.RefrigeratorAlarm.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.RefrigeratorAndTemperatureControlledCabinetMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0070, device_type_revision = 1 }, -- Refrigerator + { device_type_id = 0x0071, device_type_revision = 1 } -- Temperature Controlled Cabinet + } + }, + { + endpoint_id = freezer_ep, + clusters = { + { cluster_id = clusters.RefrigeratorAlarm.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.RefrigeratorAndTemperatureControlledCabinetMode.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureControl.ID, cluster_type = "SERVER", feature_map = 3}, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + { device_type_id = 0x0070, device_type_revision = 1 }, -- Refrigerator + { device_type_id = 0x0071, device_type_revision = 1 } -- Temperature Controlled Cabinet + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.RefrigeratorAlarm.attributes.State, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.CurrentMode, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.SupportedModes, + clusters.TemperatureControl.attributes.TemperatureSetpoint, + clusters.TemperatureControl.attributes.MaxTemperature, + clusters.TemperatureControl.attributes.MinTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + local read_req = clusters.TemperatureControl.attributes.MinTemperature:read() + read_req:merge(clusters.TemperatureControl.attributes.MaxTemperature:read()) + test.socket.matter:__expect_send({mock_device.id, read_req}) + mock_device:expect_metadata_update({ profile = "refrigerator-freezer-tn-tl" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Refrigerator Supported Modes must be registered and Refrigerator Mode command should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.attributes.SupportedModes:build_test_report_data(mock_device, refrigerator_ep, + { + clusters.RefrigeratorAndTemperatureControlledCabinetMode.types.ModeOptionStruct({ ["label"] = "Normal", ["mode"] = 0, ["mode_tags"] = {} }), + clusters.RefrigeratorAndTemperatureControlledCabinetMode.types.ModeOptionStruct({ ["label"] = "Energy Save", ["mode"] = 1, ["mode_tags"] = {} }) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("refrigerator", capabilities.mode.supportedModes({"Normal", "Energy Save"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("refrigerator", capabilities.mode.supportedArguments({"Normal", "Energy Save"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "refrigerator", command = "setMode", args = {"Normal"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.server.commands.ChangeToMode(mock_device, refrigerator_ep, 0) --0 is the index where Quick is stored. + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "refrigerator", command = "setMode", args = {"Energy Save"}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.RefrigeratorAndTemperatureControlledCabinetMode.server.commands.ChangeToMode(mock_device, refrigerator_ep, 1) --1 is the index where Super Dry is stored. + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for refrigerator endpoint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, refrigerator_ep, 0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, refrigerator_ep, 1500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, refrigerator_ep, 700) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=0.0,maximum=15.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpoint({value = 7.0, unit = "C"})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureSetpoint", component = "refrigerator", command = "setTemperatureSetpoint", args = {4.0}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.commands.SetTemperature(mock_device, refrigerator_ep, 4 * 100, nil) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for refrigerator endpoint, temp bounds out of range", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, refrigerator_ep, -1200) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, refrigerator_ep, 3500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, refrigerator_ep, 700) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=-10.0,maximum=30.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpoint({value = 7.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for freezer endpoint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, freezer_ep, -2200) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, freezer_ep, -1400) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, freezer_ep, -1700) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=-22.0,maximum=-14.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpoint({value = -17.0, unit = "C"})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "temperatureSetpoint", component = "freezer", command = "setTemperatureSetpoint", args = {-15.0}} + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureControl.commands.SetTemperature(mock_device, freezer_ep, -15 * 100, nil) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "temperatureSetpoint command should send appropriate commands for freezer endpoint, temp bounds out of range", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MinTemperature:build_test_report_data(mock_device, freezer_ep, -3300) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.MaxTemperature:build_test_report_data(mock_device, freezer_ep, 500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureControl.attributes.TemperatureSetpoint:build_test_report_data(mock_device, freezer_ep, -1500) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpointRange({value = {minimum=-30.0,maximum=0.0, step = 0.1}, unit = "C"}, {visibility = {displayed = false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpoint({value = -15.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-button/fingerprints.yml b/drivers/SmartThings/matter-button/fingerprints.yml deleted file mode 100644 index 68d5a38af5..0000000000 --- a/drivers/SmartThings/matter-button/fingerprints.yml +++ /dev/null @@ -1,6 +0,0 @@ -matterGeneric: - - id: "button" - deviceLabel: Matter Button - deviceTypes: - - id: 0x000F - deviceProfileName: button-battery # err on the side of buttons having batteries, it'll get fixed in the driver \ No newline at end of file diff --git a/drivers/SmartThings/matter-button/profiles/5-button-battery.yml b/drivers/SmartThings/matter-button/profiles/5-button-battery.yml new file mode 100644 index 0000000000..03ea20600f --- /dev/null +++ b/drivers/SmartThings/matter-button/profiles/5-button-battery.yml @@ -0,0 +1,38 @@ +name: 5-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController \ No newline at end of file diff --git a/drivers/SmartThings/matter-button/profiles/5-button.yml b/drivers/SmartThings/matter-button/profiles/5-button.yml new file mode 100644 index 0000000000..0ded733b29 --- /dev/null +++ b/drivers/SmartThings/matter-button/profiles/5-button.yml @@ -0,0 +1,36 @@ +name: 5-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-button/src/init.lua b/drivers/SmartThings/matter-button/src/init.lua index 88379c765c..1614d42c51 100644 --- a/drivers/SmartThings/matter-button/src/init.lua +++ b/drivers/SmartThings/matter-button/src/init.lua @@ -1,3 +1,10 @@ +-- As of September 2024, the matter-button driver has been deprecated in favor of being integrated into the matter-switch +-- driver. All functionality and test cases present in this driver were carried over into matter-switch. Therefore, +-- we are no longer accepting bug fixes or feature enhancements such as fingerprint adds to this driver. +-- Note that this change won't affect devices using the button driver unless they are re-onboarded, in which case +-- they would onboard to the switch driver, as all button fingerprints were removed from this driver and moved to +-- the switch driver. + local capabilities = require "st.capabilities" local log = require "log" local clusters = require "st.matter.generated.zap_clusters" @@ -9,7 +16,7 @@ local START_BUTTON_PRESS = "__start_button_press" local TIMEOUT_THRESHOLD = 10 --arbitrary timeout local HELD_THRESHOLD = 1 -- this is the number of buttons for which we have a static profile already made -local STATIC_PROFILE_SUPPORTED = {2, 4, 6, 8} +local STATIC_PROFILE_SUPPORTED = {2, 4, 5, 6, 8} local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" local DEFERRED_CONFIGURE = "__DEFERRED_CONFIGURE" diff --git a/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua b/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua index 58475ae0cc..084dc9048a 100644 --- a/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua +++ b/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -81,7 +84,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -116,7 +122,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -135,7 +144,10 @@ test.register_coroutine_test( ) ) test.wait_for_events() - end + end, + { + min_api_version = 19 + } ) -- run the tests diff --git a/drivers/SmartThings/matter-button/src/test/test_matter_button.lua b/drivers/SmartThings/matter-button/src/test/test_matter_button.lua index c18daeab98..dfc32bc7cb 100644 --- a/drivers/SmartThings/matter-button/src/test/test_matter_button.lua +++ b/drivers/SmartThings/matter-button/src/test/test_matter_button.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -66,6 +69,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 17 } ) @@ -101,6 +107,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) } +}, +{ + min_api_version = 17 } ) @@ -137,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) }, + }, + { + min_api_version = 17 } ) @@ -182,6 +194,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 17 } ) @@ -203,6 +218,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -224,6 +242,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -245,6 +266,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -282,6 +306,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) }, +}, +{ + min_api_version = 17 } ) @@ -319,6 +346,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, +}, +{ + min_api_version = 17 } ) @@ -341,6 +371,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 17 } ) -- run the tests diff --git a/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua b/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua index 83a8195090..01d3b103c7 100644 --- a/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua +++ b/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -40,27 +43,9 @@ local mock_device = test.mock_device.build_test_matter_device( clusters = { { cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, - cluster_type = "SERVER" - }, - }, - }, - { - endpoint_id = 5, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, - cluster_type = "SERVER" - }, - }, - }, - { - endpoint_id = 6, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH + | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS + | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, cluster_type = "SERVER" }, }, @@ -92,6 +77,7 @@ local CLUSTER_SUBSCRIBE_LIST ={ } local function test_init() + test.set_rpc_version(0) local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end @@ -124,29 +110,9 @@ local function test_init() parent_device_id = mock_device.id, parent_assigned_child_key = "04" }) - test.socket.capability:__expect_send(mock_children[4]:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 4)}) test.socket.capability:__expect_send(mock_children[4]:generate_test_message("main", button_attr.pushed({state_change = false}))) - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Button 4", - profile = "button", - parent_device_id = mock_device.id, - parent_assigned_child_key = "05" - }) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 5)}) - test.socket.capability:__expect_send(mock_children[5]:generate_test_message("main", button_attr.pushed({state_change = false}))) - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Button 5", - profile = "button", - parent_device_id = mock_device.id, - parent_assigned_child_key = "06" - }) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 6)}) - test.socket.capability:__expect_send(mock_children[6]:generate_test_message("main", button_attr.pushed({state_change = false}))) - end test.set_test_init_function(test_init) @@ -168,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 17 } ) @@ -199,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_children[3]:generate_test_message("main", button_attr.pushed({state_change = true})) }, + }, + { + min_api_version = 17 } ) @@ -239,27 +211,9 @@ test.register_message_test( ) } }, - } -) - -test.register_message_test( - "Receiving a max press attribute of 2 should emit correct event", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.attributes.MultiPressMax:build_test_report_data( - mock_device, 6, 2 - ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_children[6]:generate_test_message("main", - capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) - }, + }, + { + min_api_version = 17 } ) @@ -271,16 +225,19 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.attributes.MultiPressMax:build_test_report_data( - mock_device, 5, 3 + mock_device, 4, 3 ) }, }, { channel = "capability", direction = "send", - message = mock_children[5]:generate_test_message("main", - capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x"}, {visibility = {displayed = false}})) + message = mock_children[4]:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -302,6 +259,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -313,7 +273,7 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 5, {new_position = 1} + mock_device, 4, {new_position = 1} ) } }, @@ -323,16 +283,19 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 5, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + mock_device, 4, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} ) } }, { channel = "capability", direction = "send", - message = mock_children[5]:generate_test_message("main", button_attr.double({state_change = true})) + message = mock_children[4]:generate_test_message("main", button_attr.double({state_change = true})) }, +}, +{ + min_api_version = 17 } ) @@ -344,7 +307,7 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 6, {new_position = 1} + mock_device, 4, {new_position = 1} ) } }, @@ -354,16 +317,19 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 6, {new_position = 1, total_number_of_presses_counted = 4, previous_position = 0} + mock_device, 4, {new_position = 1, total_number_of_presses_counted = 4, previous_position = 0} ) } }, { channel = "capability", direction = "send", - message = mock_children[6]:generate_test_message("main", button_attr.pushed_4x({state_change = true})) + message = mock_children[4]:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, +}, +{ + min_api_version = 17 } ) @@ -386,6 +352,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 17 } ) -- run the tests diff --git a/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua index 42d97c109d..91f3a06494 100644 --- a/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua +++ b/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -73,35 +76,47 @@ local CLUSTER_SUBSCRIBE_LIST ={ clusters.Switch.server.events.MultiPressComplete, } +-- All messages queued and expectations set are done before the driver is actually run local function test_init() + -- we dont want the integration test framework to generate init/doConfigure, we are doing that here + -- so we can set the proper expectations on those events. + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) -- make sure the cache is populated + + -- added results in a profile update + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + mock_device:expect_metadata_update({ profile = "4-button-battery" }) + + -- init results in subscription interaction local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - mock_device:expect_metadata_update({ profile = "4-button-battery" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + --doConfigure sets the provisioing state to provisioned + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + -- simulate the profile change update taking affect and the device info changing local device_info_copy = utils.deep_copy(mock_device.raw_st_data) device_info_copy.profile.id = "4-buttons-battery" local device_info_json = dkjson.encode(device_info_copy) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 50)}) test.socket.capability:__expect_send(mock_device:generate_test_message("button4", button_attr.pushed({state_change = false}))) end test.set_test_init_function(test_init) +-- this one is failing because it expects added or test.register_message_test( "Handle single press sequence, no hold", { { @@ -119,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 17 } ) @@ -149,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 17 } ) @@ -171,7 +192,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.held({state_change = true}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -193,7 +217,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -214,7 +241,10 @@ test.register_coroutine_test( mock_device, 50, {previous_position = 0} ) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -251,7 +281,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button4", button_attr.double({state_change = true}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -276,7 +309,10 @@ test.register_coroutine_test( mock_device, 30, {previous_position = 0} ) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -301,7 +337,10 @@ test.register_coroutine_test( mock_device, 50, {previous_position = 0} ) }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -336,6 +375,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) } +}, +{ + min_api_version = 17 } ) @@ -372,6 +414,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) }, + }, + { + min_api_version = 17 } ) @@ -417,6 +462,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 17 } ) @@ -438,6 +486,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -459,6 +510,9 @@ test.register_message_test( message = mock_device:generate_test_message("button4", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -480,6 +534,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 17 } ) @@ -517,6 +574,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) }, +}, +{ + min_api_version = 17 } ) @@ -554,6 +614,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, +}, +{ + min_api_version = 17 } ) @@ -576,6 +639,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 17 } ) @@ -618,6 +684,9 @@ test.register_message_test( } } -- no double event +}, +{ + min_api_version = 17 } ) @@ -673,6 +742,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button4", button_attr.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) -- run the tests diff --git a/drivers/SmartThings/matter-energy/config.yml b/drivers/SmartThings/matter-energy/config.yml new file mode 100644 index 0000000000..ee64fed4ca --- /dev/null +++ b/drivers/SmartThings/matter-energy/config.yml @@ -0,0 +1,6 @@ +name: 'Matter Energy' +packageKey: 'matter-energy' +permissions: + matter: {} +description: "SmartThings driver for Matter Energy devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/matter-energy/fingerprints.yml b/drivers/SmartThings/matter-energy/fingerprints.yml new file mode 100644 index 0000000000..2e4efb9d24 --- /dev/null +++ b/drivers/SmartThings/matter-energy/fingerprints.yml @@ -0,0 +1,17 @@ +matterGeneric: + - id: "matter/evse" + deviceLabel: Matter EVSE + deviceTypes: + - id: 0x0510 + - id: 0x050C + deviceProfileName: evse + - id: "matter/solar-power" + deviceLabel: Matter Solar Power + deviceTypes: + - id: 0x0017 #Solar Power + deviceProfileName: solar-power + - id: "matter/battery-storage" + deviceLabel: Matter Battery Storage + deviceTypes: + - id: 0x0018 #Battery Storage + deviceProfileName: battery-storage diff --git a/drivers/SmartThings/matter-energy/profiles/battery-storage.yml b/drivers/SmartThings/matter-energy/profiles/battery-storage.yml new file mode 100644 index 0000000000..586c1daf82 --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/battery-storage.yml @@ -0,0 +1,30 @@ +name: battery-storage +components: +- id: main + capabilities: + - id: battery + version: 1 + - id: chargingState + version: 1 + - id: powerMeter + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Battery +- id: importedEnergy + label: Imported Energy + capabilities: + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 +- id: exportedEnergy + label: Exported Energy + capabilities: + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 diff --git a/drivers/SmartThings/matter-energy/profiles/evse-energy-meas-energy-mgmt-mode.yml b/drivers/SmartThings/matter-energy/profiles/evse-energy-meas-energy-mgmt-mode.yml new file mode 100644 index 0000000000..8270dd9b3a --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/evse-energy-meas-energy-mgmt-mode.yml @@ -0,0 +1,27 @@ +#EVSE with Electrical Energy Measurement Cluster in Electrical Sensor DT and Device Energy Manage Mode in Energy Management DT +name: evse-energy-meas-energy-mgmt-mode +components: +- id: main + capabilities: + - id: evseState + version: 1 + - id: evseChargingSession + version: 1 + - id: mode + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ElectricVehicleCharger +- id: deviceEnergyManagement + label: Energy Manager + capabilities: + - id: mode + version: 1 +metadata: + mnmn: SmartThingsEdge + vid: generic-evse diff --git a/drivers/SmartThings/matter-energy/profiles/evse-energy-meas-power-meas-energy-mgmt-mode.yml b/drivers/SmartThings/matter-energy/profiles/evse-energy-meas-power-meas-energy-mgmt-mode.yml new file mode 100644 index 0000000000..49a6ce9814 --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/evse-energy-meas-power-meas-energy-mgmt-mode.yml @@ -0,0 +1,32 @@ +#EVSE with Electrical Power Measurement & Electrical Energy Measurement Cluster in Electrical Sensor DT and Device Energy Management mode in Energy Management DT +name: evse-energy-meas-power-meas-energy-mgmt-mode +components: +- id: main + capabilities: + - id: evseState + version: 1 + - id: evseChargingSession + version: 1 + - id: mode + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ElectricVehicleCharger +- id: electricalSensor + label: Electrical Sensor + capabilities: + - id: powerSource + version: 1 +- id: deviceEnergyManagement + label: Energy Manager + capabilities: + - id: mode + version: 1 +metadata: + mnmn: SmartThingsEdge + vid: generic-evse diff --git a/drivers/SmartThings/matter-energy/profiles/evse-energy-meas.yml b/drivers/SmartThings/matter-energy/profiles/evse-energy-meas.yml new file mode 100644 index 0000000000..3cb332168c --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/evse-energy-meas.yml @@ -0,0 +1,22 @@ +#EVSE with Electrical Energy Measurement Cluster in Electrical Sensor +name: evse-energy-meas +components: +- id: main + capabilities: + - id: evseState + version: 1 + - id: evseChargingSession + version: 1 + - id: mode + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ElectricVehicleCharger +metadata: + mnmn: SmartThingsEdge + vid: generic-evse diff --git a/drivers/SmartThings/matter-energy/profiles/evse-power-meas-energy-mgmt-mode.yml b/drivers/SmartThings/matter-energy/profiles/evse-power-meas-energy-mgmt-mode.yml new file mode 100644 index 0000000000..e389346d82 --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/evse-power-meas-energy-mgmt-mode.yml @@ -0,0 +1,30 @@ +#EVSE with Electrical Power Measurement Cluster in Electrical Sensor DT and Device Energy Management mode in Energy Management DT +name: evse-power-meas-energy-mgmt-mode +components: +- id: main + capabilities: + - id: evseState + version: 1 + - id: evseChargingSession + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ElectricVehicleCharger +- id: electricalSensor + label: Electrical Sensor + capabilities: + - id: powerSource + version: 1 +- id: deviceEnergyManagement + label: Energy Manager + capabilities: + - id: mode + version: 1 +metadata: + mnmn: SmartThingsEdge + vid: generic-evse diff --git a/drivers/SmartThings/matter-energy/profiles/evse-power-meas.yml b/drivers/SmartThings/matter-energy/profiles/evse-power-meas.yml new file mode 100644 index 0000000000..9ec54d4140 --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/evse-power-meas.yml @@ -0,0 +1,25 @@ +#EVSE with Electrical Power Measurement Cluster in Electrical Sensor DT +name: evse-power-meas +components: +- id: main + capabilities: + - id: evseState + version: 1 + - id: evseChargingSession + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ElectricVehicleCharger +- id: electricalSensor + label: Electrical Sensor + capabilities: + - id: powerSource + version: 1 +metadata: + mnmn: SmartThingsEdge + vid: generic-evse diff --git a/drivers/SmartThings/matter-energy/profiles/evse.yml b/drivers/SmartThings/matter-energy/profiles/evse.yml new file mode 100644 index 0000000000..e20aed42c9 --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/evse.yml @@ -0,0 +1,19 @@ +name: evse +components: +- id: main + capabilities: + - id: evseState + version: 1 + - id: evseChargingSession + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ElectricVehicleCharger +metadata: + mnmn: SmartThingsEdge + vid: generic-evse diff --git a/drivers/SmartThings/matter-energy/profiles/solar-power.yml b/drivers/SmartThings/matter-energy/profiles/solar-power.yml new file mode 100644 index 0000000000..ca5551dd47 --- /dev/null +++ b/drivers/SmartThings/matter-energy/profiles/solar-power.yml @@ -0,0 +1,18 @@ +name: solar-power +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SolarPanel +- id: exportedEnergy + capabilities: + - id: powerConsumptionReport + version: 1 diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/init.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/init.lua new file mode 100644 index 0000000000..2893282bb2 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/init.lua @@ -0,0 +1,86 @@ +local cluster_base = require "st.matter.cluster_base" +local DeviceEnergyManagementModeServerAttributes = require "DeviceEnergyManagementMode.server.attributes" +local DeviceEnergyManagementModeServerCommands = require "DeviceEnergyManagementMode.server.commands" +local DeviceEnergyManagementModeTypes = require "DeviceEnergyManagementMode.types" + + +local DeviceEnergyManagementMode = {} + +DeviceEnergyManagementMode.ID = 0x009F +DeviceEnergyManagementMode.NAME = "DeviceEnergyManagementMode" +DeviceEnergyManagementMode.server = {} +DeviceEnergyManagementMode.server.attributes = DeviceEnergyManagementModeServerAttributes:set_parent_cluster(DeviceEnergyManagementMode) +DeviceEnergyManagementMode.server.commands = DeviceEnergyManagementModeServerCommands:set_parent_cluster(DeviceEnergyManagementMode) +DeviceEnergyManagementMode.types = DeviceEnergyManagementModeTypes +DeviceEnergyManagementMode.FeatureMap = DeviceEnergyManagementMode.types.Feature + +function DeviceEnergyManagementMode.are_features_supported(feature, feature_map) + if (DeviceEnergyManagementMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +function DeviceEnergyManagementMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function DeviceEnergyManagementMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +-- Attribute Mapping +DeviceEnergyManagementMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +-- Command Mapping +DeviceEnergyManagementMode.command_direction_map = { + ["ChangeToMode"] = "server", +} + +-- Cluster Completion +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = DeviceEnergyManagementMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, DeviceEnergyManagementMode.NAME)) + end + return DeviceEnergyManagementMode[direction].attributes[key] +end +DeviceEnergyManagementMode.attributes = {} +setmetatable(DeviceEnergyManagementMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = DeviceEnergyManagementMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, DeviceEnergyManagementMode.NAME)) + end + return DeviceEnergyManagementMode[direction].commands[key] +end +DeviceEnergyManagementMode.commands = {} +setmetatable(DeviceEnergyManagementMode.commands, command_helper_mt) + +setmetatable(DeviceEnergyManagementMode, {__index = cluster_base}) + +return DeviceEnergyManagementMode \ No newline at end of file diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..066732c701 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +AcceptedCommandList.enum_fields = {} + +function AcceptedCommandList:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function AcceptedCommandList.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, AcceptedCommandList.enum_fields[value_obj.value]) +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..a441e90e0b --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/AttributeList.lua @@ -0,0 +1,81 @@ + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +AttributeList.enum_fields = {} + +function AttributeList:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function AttributeList.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, AttributeList.enum_fields[value_obj.value]) +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value}) +return AttributeList diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..f2987e294d --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/CurrentMode.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..69b7e2969a --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/SupportedModes.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "DeviceEnergyManagementMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/init.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/init.lua new file mode 100644 index 0000000000..d00f4242d2 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("DeviceEnergyManagementMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local DeviceEnergyManagementModeServerAttributes = {} + +function DeviceEnergyManagementModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DeviceEnergyManagementModeServerAttributes, attr_mt) + +return DeviceEnergyManagementModeServerAttributes diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..7e6ee5f9f5 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/ChangeToMode.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + optional = false, + nullable = false, + data_type = data_types.Uint8, + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/init.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/init.lua new file mode 100644 index 0000000000..c6d2ba9c06 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("DeviceEnergyManagementMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local DeviceEnergyManagementModeServerCommands = {} + +function DeviceEnergyManagementModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DeviceEnergyManagementModeServerCommands, command_mt) + +return DeviceEnergyManagementModeServerCommands diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/Feature.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/Feature.lua new file mode 100644 index 0000000000..4feed93356 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/Feature.lua @@ -0,0 +1,54 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..caa7734d7c --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeOptionStruct.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "DeviceEnergyManagementMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTag.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTag.lua new file mode 100644 index 0000000000..1f6d83578a --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTag.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NO_OPTIMIZATION] = "NO_OPTIMIZATION", + [self.DEVICE_OPTIMIZATION] = "DEVICE_OPTIMIZATION", + [self.LOCAL_OPTIMIZATION] = "LOCAL_OPTIMIZATION", + [self.GRID_OPTIMIZATION] = "GRID_OPTIMIZATION", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NO_OPTIMIZATION = 0x4000 +new_mt.__index.DEVICE_OPTIMIZATION = 0x4001 +new_mt.__index.LOCAL_OPTIMIZATION = 0x4002 +new_mt.__index.GRID_OPTIMIZATION = 0x4003 + +ModeTag.NO_OPTIMIZATION = 0x4000 +ModeTag.DEVICE_OPTIMIZATION = 0x4001 +ModeTag.LOCAL_OPTIMIZATION = 0x4002 +ModeTag.GRID_OPTIMIZATION = 0x4003 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..610fb90ad7 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/ModeTagStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/init.lua b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/init.lua new file mode 100644 index 0000000000..5962f739be --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/DeviceEnergyManagementMode/types/init.lua @@ -0,0 +1,16 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + local req_loc = string.format("DeviceEnergyManagementMode.types.%s", key) + local cluster_type = require(req_loc) + types_mt.__types_cache[key] = cluster_type + end + return types_mt.__types_cache[key] +end + +local DeviceEnergyManagementModeTypes = {} + +setmetatable(DeviceEnergyManagementModeTypes, types_mt) + +return DeviceEnergyManagementModeTypes diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/init.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/init.lua new file mode 100644 index 0000000000..991e2e10de --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/init.lua @@ -0,0 +1,66 @@ +local cluster_base = require "st.matter.cluster_base" +local ElectricalEnergyMeasurementServerAttributes = require "ElectricalEnergyMeasurement.server.attributes" +local ElectricalEnergyMeasurementTypes = require "ElectricalEnergyMeasurement.types" +local ElectricalEnergyMeasurement = {} + +ElectricalEnergyMeasurement.ID = 0x0091 +ElectricalEnergyMeasurement.NAME = "ElectricalEnergyMeasurement" +ElectricalEnergyMeasurement.server = {} +ElectricalEnergyMeasurement.client = {} +ElectricalEnergyMeasurement.server.attributes = ElectricalEnergyMeasurementServerAttributes:set_parent_cluster(ElectricalEnergyMeasurement) +ElectricalEnergyMeasurement.types = ElectricalEnergyMeasurementTypes + +function ElectricalEnergyMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Accuracy", + [0x0001] = "CumulativeEnergyImported", + [0x0002] = "CumulativeEnergyExported", + [0x0003] = "PeriodicEnergyImported", + [0x0004] = "PeriodicEnergyExported", + [0x0005] = "CumulativeEnergyReset", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +ElectricalEnergyMeasurement.attribute_direction_map = { + ["Accuracy"] = "server", + ["CumulativeEnergyImported"] = "server", + ["CumulativeEnergyExported"] = "server", + ["PeriodicEnergyImported"] = "server", + ["PeriodicEnergyExported"] = "server", + ["CumulativeEnergyReset"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +ElectricalEnergyMeasurement.FeatureMap = ElectricalEnergyMeasurement.types.Feature + +function ElectricalEnergyMeasurement.are_features_supported(feature, feature_map) + if (ElectricalEnergyMeasurement.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ElectricalEnergyMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ElectricalEnergyMeasurement.NAME)) + end + return ElectricalEnergyMeasurement[direction].attributes[key] +end +ElectricalEnergyMeasurement.attributes = {} +setmetatable(ElectricalEnergyMeasurement.attributes, attribute_helper_mt) + +setmetatable(ElectricalEnergyMeasurement, {__index = cluster_base}) + +return ElectricalEnergyMeasurement diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyExported.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyExported.lua new file mode 100644 index 0000000000..c6293e369c --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyExported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CumulativeEnergyExported = { + ID = 0x0002, + NAME = "CumulativeEnergyExported", + base_type = require "ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function CumulativeEnergyExported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CumulativeEnergyExported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyExported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyExported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CumulativeEnergyExported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CumulativeEnergyExported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CumulativeEnergyExported, {__call = CumulativeEnergyExported.new_value, __index = CumulativeEnergyExported.base_type}) +return CumulativeEnergyExported diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua new file mode 100644 index 0000000000..7750255974 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CumulativeEnergyImported = { + ID = 0x0001, + NAME = "CumulativeEnergyImported", + base_type = require "ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function CumulativeEnergyImported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CumulativeEnergyImported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyImported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyImported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CumulativeEnergyImported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CumulativeEnergyImported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CumulativeEnergyImported, {__call = CumulativeEnergyImported.new_value, __index = CumulativeEnergyImported.base_type}) +return CumulativeEnergyImported diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyExported.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyExported.lua new file mode 100644 index 0000000000..f2a0f570fa --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyExported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PeriodicEnergyExported = { + ID = 0x0004, + NAME = "PeriodicEnergyExported", + base_type = require "ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function PeriodicEnergyExported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function PeriodicEnergyExported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyExported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyExported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PeriodicEnergyExported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PeriodicEnergyExported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(PeriodicEnergyExported, {__call = PeriodicEnergyExported.new_value, __index = PeriodicEnergyExported.base_type}) +return PeriodicEnergyExported diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua new file mode 100644 index 0000000000..32c5afe7f2 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PeriodicEnergyImported = { + ID = 0x0003, + NAME = "PeriodicEnergyImported", + base_type = require "ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function PeriodicEnergyImported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function PeriodicEnergyImported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyImported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyImported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PeriodicEnergyImported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PeriodicEnergyImported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(PeriodicEnergyImported, {__call = PeriodicEnergyImported.new_value, __index = PeriodicEnergyImported.base_type}) +return PeriodicEnergyImported diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..bd08f6841d --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("ElectricalEnergyMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ElectricalEnergyMeasurementServerAttributes = {} + +function ElectricalEnergyMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ElectricalEnergyMeasurementServerAttributes, attr_mt) + +return ElectricalEnergyMeasurementServerAttributes diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua new file mode 100644 index 0000000000..576b83d6c2 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua @@ -0,0 +1,97 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local EnergyMeasurementStruct = {} +local new_mt = StructureABC.new_mt({NAME = "EnergyMeasurementStruct", ID = data_types.name_to_id_map["Structure"]}) + +EnergyMeasurementStruct.field_defs = { + { + name = "energy", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "start_timestamp", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "end_timestamp", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "start_systime", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "end_systime", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, +} + +EnergyMeasurementStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +EnergyMeasurementStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = EnergyMeasurementStruct.init +new_mt.__index.serialize = EnergyMeasurementStruct.serialize + +EnergyMeasurementStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(EnergyMeasurementStruct, new_mt) + +return EnergyMeasurementStruct diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/Feature.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/Feature.lua new file mode 100644 index 0000000000..ba57387b21 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/Feature.lua @@ -0,0 +1,115 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.IMPORTED_ENERGY = 0x0001 +Feature.EXPORTED_ENERGY = 0x0002 +Feature.CUMULATIVE_ENERGY = 0x0004 +Feature.PERIODIC_ENERGY = 0x0008 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + IMPORTED_ENERGY = 0x0001, + EXPORTED_ENERGY = 0x0002, + CUMULATIVE_ENERGY = 0x0004, + PERIODIC_ENERGY = 0x0008, +} + +Feature.is_imported_energy_set = function(self) + return (self.value & self.IMPORTED_ENERGY) ~= 0 +end + +Feature.set_imported_energy = function(self) + if self.value ~= nil then + self.value = self.value | self.IMPORTED_ENERGY + else + self.value = self.IMPORTED_ENERGY + end +end + +Feature.unset_imported_energy = function(self) + self.value = self.value & (~self.IMPORTED_ENERGY & self.BASE_MASK) +end +Feature.is_exported_energy_set = function(self) + return (self.value & self.EXPORTED_ENERGY) ~= 0 +end + +Feature.set_exported_energy = function(self) + if self.value ~= nil then + self.value = self.value | self.EXPORTED_ENERGY + else + self.value = self.EXPORTED_ENERGY + end +end + +Feature.unset_exported_energy = function(self) + self.value = self.value & (~self.EXPORTED_ENERGY & self.BASE_MASK) +end +Feature.is_cumulative_energy_set = function(self) + return (self.value & self.CUMULATIVE_ENERGY) ~= 0 +end + +Feature.set_cumulative_energy = function(self) + if self.value ~= nil then + self.value = self.value | self.CUMULATIVE_ENERGY + else + self.value = self.CUMULATIVE_ENERGY + end +end + +Feature.unset_cumulative_energy = function(self) + self.value = self.value & (~self.CUMULATIVE_ENERGY & self.BASE_MASK) +end +Feature.is_periodic_energy_set = function(self) + return (self.value & self.PERIODIC_ENERGY) ~= 0 +end + +Feature.set_periodic_energy = function(self) + if self.value ~= nil then + self.value = self.value | self.PERIODIC_ENERGY + else + self.value = self.PERIODIC_ENERGY + end +end + +Feature.unset_periodic_energy = function(self) + self.value = self.value & (~self.PERIODIC_ENERGY & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.IMPORTED_ENERGY | + Feature.EXPORTED_ENERGY | + Feature.CUMULATIVE_ENERGY | + Feature.PERIODIC_ENERGY + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_imported_energy_set = Feature.is_imported_energy_set, + set_imported_energy = Feature.set_imported_energy, + unset_imported_energy = Feature.unset_imported_energy, + is_exported_energy_set = Feature.is_exported_energy_set, + set_exported_energy = Feature.set_exported_energy, + unset_exported_energy = Feature.unset_exported_energy, + is_cumulative_energy_set = Feature.is_cumulative_energy_set, + set_cumulative_energy = Feature.set_cumulative_energy, + unset_cumulative_energy = Feature.unset_cumulative_energy, + is_periodic_energy_set = Feature.is_periodic_energy_set, + set_periodic_energy = Feature.set_periodic_energy, + unset_periodic_energy = Feature.unset_periodic_energy, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/init.lua b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/init.lua new file mode 100644 index 0000000000..3fe7471234 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalEnergyMeasurement/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("ElectricalEnergyMeasurement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ElectricalEnergyMeasurementTypes = {} + +setmetatable(ElectricalEnergyMeasurementTypes, types_mt) + +return ElectricalEnergyMeasurementTypes diff --git a/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/init.lua b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/init.lua new file mode 100644 index 0000000000..2b541d111d --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/init.lua @@ -0,0 +1,53 @@ +local cluster_base = require "st.matter.cluster_base" +local ElectricalPowerMeasurementServerAttributes = require "ElectricalPowerMeasurement.server.attributes" +local ElectricalPowerMeasurementTypes = require "ElectricalPowerMeasurement.types" + +local ElectricalPowerMeasurement = {} + +ElectricalPowerMeasurement.ID = 0x0090 +ElectricalPowerMeasurement.NAME = "ElectricalPowerMeasurement" +ElectricalPowerMeasurement.server = {} +ElectricalPowerMeasurement.client = {} +ElectricalPowerMeasurement.server.attributes = ElectricalPowerMeasurementServerAttributes:set_parent_cluster(ElectricalPowerMeasurement) +ElectricalPowerMeasurement.types = ElectricalPowerMeasurementTypes + +function ElectricalPowerMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "PowerMode", + [0x0008] = "ActivePower", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +ElectricalPowerMeasurement.attribute_direction_map = { + ["PowerMode"] = "server", + ["ActivePower"] = "server", +} + +ElectricalPowerMeasurement.FeatureMap = ElectricalPowerMeasurement.types.Feature + +function ElectricalPowerMeasurement.are_features_supported(feature, feature_map) + if (ElectricalPowerMeasurement.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ElectricalPowerMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ElectricalPowerMeasurement.NAME)) + end + return ElectricalPowerMeasurement[direction].attributes[key] +end +ElectricalPowerMeasurement.attributes = {} +setmetatable(ElectricalPowerMeasurement.attributes, attribute_helper_mt) + +setmetatable(ElectricalPowerMeasurement, {__index = cluster_base}) + +return ElectricalPowerMeasurement diff --git a/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/ActivePower.lua b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/ActivePower.lua new file mode 100644 index 0000000000..457f6484af --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/ActivePower.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ActivePower = { + ID = 0x0008, + NAME = "ActivePower", + base_type = require "st.matter.data_types.Int64", +} + +function ActivePower:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ActivePower:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActivePower:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActivePower:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ActivePower:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ActivePower:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ActivePower, {__call = ActivePower.new_value, __index = ActivePower.base_type}) +return ActivePower diff --git a/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/PowerMode.lua b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/PowerMode.lua new file mode 100644 index 0000000000..294c43d5a1 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/PowerMode.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PowerMode = { + ID = 0x0000, + NAME = "PowerMode", + base_type = require "ElectricalPowerMeasurement.types.PowerModeEnum", +} + + +function PowerMode:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function PowerMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function PowerMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function PowerMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PowerMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PowerMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(PowerMode, {__call = PowerMode.new_value, __index = PowerMode.base_type}) +return PowerMode diff --git a/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..61dee3f7c5 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("ElectricalPowerMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ElectricalPowerMeasurementServerAttributes = {} + +function ElectricalPowerMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ElectricalPowerMeasurementServerAttributes, attr_mt) + +return ElectricalPowerMeasurementServerAttributes diff --git a/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/Feature.lua b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/Feature.lua new file mode 100644 index 0000000000..19ca955acd --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/Feature.lua @@ -0,0 +1,137 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.DIRECT_CURRENT = 0x0001 +Feature.ALTERNATING_CURRENT = 0x0002 +Feature.POLYPHASE_POWER = 0x0004 +Feature.HARMONICS = 0x0008 +Feature.POWER_QUALITY = 0x0010 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + DIRECT_CURRENT = 0x0001, + ALTERNATING_CURRENT = 0x0002, + POLYPHASE_POWER = 0x0004, + HARMONICS = 0x0008, + POWER_QUALITY = 0x0010, +} + +Feature.is_direct_current_set = function(self) + return (self.value & self.DIRECT_CURRENT) ~= 0 +end + +Feature.set_direct_current = function(self) + if self.value ~= nil then + self.value = self.value | self.DIRECT_CURRENT + else + self.value = self.DIRECT_CURRENT + end +end + +Feature.unset_direct_current = function(self) + self.value = self.value & (~self.DIRECT_CURRENT & self.BASE_MASK) +end +Feature.is_alternating_current_set = function(self) + return (self.value & self.ALTERNATING_CURRENT) ~= 0 +end + +Feature.set_alternating_current = function(self) + if self.value ~= nil then + self.value = self.value | self.ALTERNATING_CURRENT + else + self.value = self.ALTERNATING_CURRENT + end +end + +Feature.unset_alternating_current = function(self) + self.value = self.value & (~self.ALTERNATING_CURRENT & self.BASE_MASK) +end +Feature.is_polyphase_power_set = function(self) + return (self.value & self.POLYPHASE_POWER) ~= 0 +end + +Feature.set_polyphase_power = function(self) + if self.value ~= nil then + self.value = self.value | self.POLYPHASE_POWER + else + self.value = self.POLYPHASE_POWER + end +end + +Feature.unset_polyphase_power = function(self) + self.value = self.value & (~self.POLYPHASE_POWER & self.BASE_MASK) +end +Feature.is_harmonics_set = function(self) + return (self.value & self.HARMONICS) ~= 0 +end + +Feature.set_harmonics = function(self) + if self.value ~= nil then + self.value = self.value | self.HARMONICS + else + self.value = self.HARMONICS + end +end + +Feature.unset_harmonics = function(self) + self.value = self.value & (~self.HARMONICS & self.BASE_MASK) +end +Feature.is_power_quality_set = function(self) + return (self.value & self.POWER_QUALITY) ~= 0 +end + +Feature.set_power_quality = function(self) + if self.value ~= nil then + self.value = self.value | self.POWER_QUALITY + else + self.value = self.POWER_QUALITY + end +end + +Feature.unset_power_quality = function(self) + self.value = self.value & (~self.POWER_QUALITY & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.DIRECT_CURRENT | + Feature.ALTERNATING_CURRENT | + Feature.POLYPHASE_POWER | + Feature.HARMONICS | + Feature.POWER_QUALITY + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_direct_current_set = Feature.is_direct_current_set, + set_direct_current = Feature.set_direct_current, + unset_direct_current = Feature.unset_direct_current, + is_alternating_current_set = Feature.is_alternating_current_set, + set_alternating_current = Feature.set_alternating_current, + unset_alternating_current = Feature.unset_alternating_current, + is_polyphase_power_set = Feature.is_polyphase_power_set, + set_polyphase_power = Feature.set_polyphase_power, + unset_polyphase_power = Feature.unset_polyphase_power, + is_harmonics_set = Feature.is_harmonics_set, + set_harmonics = Feature.set_harmonics, + unset_harmonics = Feature.unset_harmonics, + is_power_quality_set = Feature.is_power_quality_set, + set_power_quality = Feature.set_power_quality, + unset_power_quality = Feature.unset_power_quality, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/PowerModeEnum.lua b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/PowerModeEnum.lua new file mode 100644 index 0000000000..de87167ead --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/PowerModeEnum.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local PowerModeEnum = {} +local new_mt = UintABC.new_mt({NAME = "PowerModeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN] = "UNKNOWN", + [self.DC] = "DC", + [self.AC] = "AC", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN = 0x00 +new_mt.__index.DC = 0x01 +new_mt.__index.AC = 0x02 + +PowerModeEnum.UNKNOWN = 0x00 +PowerModeEnum.DC = 0x01 +PowerModeEnum.AC = 0x02 + +PowerModeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(PowerModeEnum, new_mt) + +return PowerModeEnum diff --git a/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/init.lua b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/init.lua new file mode 100644 index 0000000000..1e294dcacd --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/ElectricalPowerMeasurement/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("ElectricalPowerMeasurement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ElectricalPowerMeasurementTypes = {} + +setmetatable(ElectricalPowerMeasurementTypes, types_mt) + +return ElectricalPowerMeasurementTypes diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/init.lua new file mode 100644 index 0000000000..004626a954 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/init.lua @@ -0,0 +1,162 @@ +local cluster_base = require "st.matter.cluster_base" +local EnergyEvseServerAttributes = require "EnergyEvse.server.attributes" +local EnergyEvseServerCommands = require "EnergyEvse.server.commands" +local EnergyEvseEvents = require "EnergyEvse.server.events" +local EnergyEvseTypes = require "EnergyEvse.types" + +local EnergyEvse = {} + +EnergyEvse.ID = 0x0099 +EnergyEvse.NAME = "EnergyEvse" +EnergyEvse.server = {} +EnergyEvse.client = {} +EnergyEvse.server.attributes = EnergyEvseServerAttributes:set_parent_cluster(EnergyEvse) +EnergyEvse.server.commands = EnergyEvseServerCommands:set_parent_cluster(EnergyEvse) +EnergyEvse.server.events = EnergyEvseEvents:set_parent_cluster(EnergyEvse) +EnergyEvse.types = EnergyEvseTypes +EnergyEvse.FeatureMap = EnergyEvse.types.Feature + +function EnergyEvse.are_features_supported(feature, feature_map) + if (EnergyEvse.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +function EnergyEvse:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "State", + [0x0001] = "SupplyState", + [0x0002] = "FaultState", + [0x0003] = "ChargingEnabledUntil", + [0x0004] = "DischargingEnabledUntil", + [0x0005] = "CircuitCapacity", + [0x0006] = "MinimumChargeCurrent", + [0x0007] = "MaximumChargeCurrent", + [0x0008] = "MaximumDischargeCurrent", + [0x0009] = "UserMaximumChargeCurrent", + [0x000A] = "RandomizationDelayWindow", + [0x0023] = "NextChargeStartTime", + [0x0024] = "NextChargeTargetTime", + [0x0025] = "NextChargeRequiredEnergy", + [0x0026] = "NextChargeTargetSoC", + [0x0027] = "ApproximateEVEfficiency", + [0x0030] = "StateOfCharge", + [0x0031] = "BatteryCapacity", + [0x0032] = "VehicleID", + [0x0040] = "SessionID", + [0x0041] = "SessionDuration", + [0x0042] = "SessionEnergyCharged", + [0x0043] = "SessionEnergyDischarged", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function EnergyEvse:get_server_command_by_id(command_id) + local server_id_map = { + [0x0001] = "Disable", + [0x0002] = "EnableCharging", + [0x0003] = "EnableDischarging", + [0x0004] = "StartDiagnostics", + [0x0005] = "SetTargets", + [0x0006] = "GetTargets", + [0x0007] = "ClearTargets", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function EnergyEvse:get_event_by_id(event_id) + local event_id_map = { + [0x0000] = "EVConnected", + [0x0001] = "EVNotDetected", + [0x0002] = "EnergyTransferStarted", + [0x0003] = "EnergyTransferStopped", + [0x0004] = "Fault", + [0x0005] = "Rfid", + } + if event_id_map[event_id] ~= nil then + return self.server.events[event_id_map[event_id]] + end + return nil +end +EnergyEvse.attribute_direction_map = { + ["State"] = "server", + ["SupplyState"] = "server", + ["FaultState"] = "server", + ["ChargingEnabledUntil"] = "server", + ["DischargingEnabledUntil"] = "server", + ["CircuitCapacity"] = "server", + ["MinimumChargeCurrent"] = "server", + ["MaximumChargeCurrent"] = "server", + ["MaximumDischargeCurrent"] = "server", + ["UserMaximumChargeCurrent"] = "server", + ["RandomizationDelayWindow"] = "server", + ["NextChargeStartTime"] = "server", + ["NextChargeTargetTime"] = "server", + ["NextChargeRequiredEnergy"] = "server", + ["NextChargeTargetSoC"] = "server", + ["ApproximateEVEfficiency"] = "server", + ["StateOfCharge"] = "server", + ["BatteryCapacity"] = "server", + ["VehicleID"] = "server", + ["SessionID"] = "server", + ["SessionDuration"] = "server", + ["SessionEnergyCharged"] = "server", + ["SessionEnergyDischarged"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +EnergyEvse.command_direction_map = { + ["Disable"] = "server", + ["EnableCharging"] = "server", + ["EnableDischarging"] = "server", + ["StartDiagnostics"] = "server", + ["SetTargets"] = "server", + ["GetTargets"] = "server", + ["ClearTargets"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = EnergyEvse.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, EnergyEvse.NAME)) + end + return EnergyEvse[direction].attributes[key] +end +EnergyEvse.attributes = {} +setmetatable(EnergyEvse.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = EnergyEvse.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, EnergyEvse.NAME)) + end + return EnergyEvse[direction].commands[key] +end +EnergyEvse.commands = {} +setmetatable(EnergyEvse.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return EnergyEvse.server.events[key] +end +EnergyEvse.events = {} +setmetatable(EnergyEvse.events, event_helper_mt) + +setmetatable(EnergyEvse, {__index = cluster_base}) + +return EnergyEvse diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..2d1bdd9f6a --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,79 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32" +} + +AcceptedCommandList.enum_fields = {} + +function AcceptedCommandList:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function AcceptedCommandList.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, AcceptedCommandList.enum_fields[value_obj.value]) +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ApproximateEVEfficiency.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ApproximateEVEfficiency.lua new file mode 100644 index 0000000000..1be08eb3b2 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ApproximateEVEfficiency.lua @@ -0,0 +1,76 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ApproximateEVEfficiency = { + ID = 0x0027, + NAME = "ApproximateEVEfficiency", + base_type = data_types.Uint16, +} + +function ApproximateEVEfficiency:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function ApproximateEVEfficiency:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ApproximateEVEfficiency:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --event_id + data + ) +end + +function ApproximateEVEfficiency:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ApproximateEVEfficiency:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ApproximateEVEfficiency:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ApproximateEVEfficiency:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(ApproximateEVEfficiency, {__call = ApproximateEVEfficiency.new_value}) +return ApproximateEVEfficiency diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..743ffa4daf --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/AttributeList.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32" +} + +AttributeList.enum_fields = { +} + +function AttributeList:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function AttributeList.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, AttributeList.enum_fields[value_obj.value]) +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value}) +return AttributeList diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/BatteryCapacity.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/BatteryCapacity.lua new file mode 100644 index 0000000000..e9bb2c905b --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/BatteryCapacity.lua @@ -0,0 +1,79 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local BatteryCapacity = { + ID = 0x0031, + NAME = "BatteryCapacity", + base_type = data_types.Int64, +} + +BatteryCapacity.enum_fields = { +} + +function BatteryCapacity:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function BatteryCapacity.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, BatteryCapacity.enum_fields[value_obj.value]) +end + +function BatteryCapacity:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function BatteryCapacity:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function BatteryCapacity:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function BatteryCapacity:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function BatteryCapacity:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function BatteryCapacity:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(BatteryCapacity, {__call = BatteryCapacity.new_value}) +return BatteryCapacity diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ChargingEnabledUntil.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ChargingEnabledUntil.lua new file mode 100644 index 0000000000..30288fba35 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/ChargingEnabledUntil.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChargingEnabledUntil = { + ID = 0x0003, + NAME = "ChargingEnabledUntil", + base_type = data_types.Uint32, +} + +function ChargingEnabledUntil:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function ChargingEnabledUntil:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChargingEnabledUntil:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChargingEnabledUntil:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChargingEnabledUntil:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ChargingEnabledUntil:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(ChargingEnabledUntil, {__call = ChargingEnabledUntil.new_value}) +return ChargingEnabledUntil diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/CircuitCapacity.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/CircuitCapacity.lua new file mode 100644 index 0000000000..31cecdf647 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/CircuitCapacity.lua @@ -0,0 +1,79 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CircuitCapacity = { + ID = 0x0005, + NAME = "CircuitCapacity", + base_type = data_types.Int64, +} + +CircuitCapacity.enum_fields = { +} + +function CircuitCapacity:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function CircuitCapacity.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, CircuitCapacity.enum_fields[value_obj.value]) +end + +function CircuitCapacity:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CircuitCapacity:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CircuitCapacity:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CircuitCapacity:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CircuitCapacity:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CircuitCapacity:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CircuitCapacity, {__call = CircuitCapacity.new_value}) +return CircuitCapacity diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/DischargingEnabledUntil.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/DischargingEnabledUntil.lua new file mode 100644 index 0000000000..1d90b0bfd1 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/DischargingEnabledUntil.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local DischargingEnabledUntil = { + ID = 0x0004, + NAME = "DischargingEnabledUntil", + base_type = data_types.Uint32, +} + +function DischargingEnabledUntil:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function DischargingEnabledUntil:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function DischargingEnabledUntil:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function DischargingEnabledUntil:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function DischargingEnabledUntil:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function DischargingEnabledUntil:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(DischargingEnabledUntil, {__call = DischargingEnabledUntil.new_value}) +return DischargingEnabledUntil diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/EventList.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/EventList.lua new file mode 100644 index 0000000000..ec5a5c5426 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/EventList.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local EventList = { + ID = 0xFFFA, + NAME = "EventList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32" +} + +EventList.enum_fields = { +} + +function EventList:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function EventList.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, EventList.enum_fields[value_obj.value]) +end + +function EventList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function EventList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function EventList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function EventList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EventList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EventList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(EventList, {__call = EventList.new_value}) +return EventList diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/FaultState.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/FaultState.lua new file mode 100644 index 0000000000..3ce56ca65a --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/FaultState.lua @@ -0,0 +1,113 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local FaultState = { + ID = 0x0002, + NAME = "FaultState", + base_type = data_types.Uint8, +} +FaultState.NO_ERROR = 0x00 +FaultState.METER_FAILURE = 0x01 +FaultState.OVER_VOLTAGE = 0x02 +FaultState.UNDER_VOLTAGE = 0x03 +FaultState.OVER_CURRENT = 0x04 +FaultState.CONTACT_WET_FAILURE = 0x05 +FaultState.CONTACT_DRY_FAILURE = 0x06 +FaultState.GROUND_FAULT = 0x07 +FaultState.POWER_LOSS = 0x08 +FaultState.POWER_QUALITY = 0x09 +FaultState.PILOT_SHORT_CIRCUIT = 0x0A +FaultState.EMERGENCY_STOP = 0x0B +FaultState.EV_DISCONNECTED = 0x0C +FaultState.WRONG_POWER_SUPPLY = 0x0D +FaultState.LIVE_NEUTRAL_SWAP = 0x0E +FaultState.OVER_TEMPERATURE = 0x0F +FaultState.OTHER = 0xFF + +FaultState.enum_fields = { + [FaultState.NO_ERROR] = "NO_ERROR", + [FaultState.METER_FAILURE] = "METER_FAILURE", + [FaultState.OVER_VOLTAGE] = "OVER_VOLTAGE", + [FaultState.UNDER_VOLTAGE] = "UNDER_VOLTAGE", + [FaultState.OVER_CURRENT] = "OVER_CURRENT", + [FaultState.CONTACT_WET_FAILURE] = "CONTACT_WET_FAILURE", + [FaultState.CONTACT_DRY_FAILURE] = "CONTACT_DRY_FAILURE", + [FaultState.GROUND_FAULT] = "GROUND_FAULT", + [FaultState.POWER_LOSS] = "POWER_LOSS", + [FaultState.POWER_QUALITY] = "POWER_QUALITY", + [FaultState.PILOT_SHORT_CIRCUIT] = "PILOT_SHORT_CIRCUIT", + [FaultState.EMERGENCY_STOP] = "EMERGENCY_STOP", + [FaultState.EV_DISCONNECTED] = "EV_DISCONNECTED", + [FaultState.WRONG_POWER_SUPPLY] = "WRONG_POWER_SUPPLY", + [FaultState.LIVE_NEUTRAL_SWAP] = "LIVE_NEUTRAL_SWAP", + [FaultState.OVER_TEMPERATURE] = "OVER_TEMPERATURE", + [FaultState.OTHER] = "OTHER", +} + +function FaultState:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function FaultState.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, FaultState.enum_fields[value_obj.value]) +end + +function FaultState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function FaultState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function FaultState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function FaultState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function FaultState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function FaultState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(FaultState, {__call = FaultState.new_value}) +return FaultState diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumChargeCurrent.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumChargeCurrent.lua new file mode 100644 index 0000000000..4030d89faa --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumChargeCurrent.lua @@ -0,0 +1,79 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MaximumChargeCurrent = { + ID = 0x0007, + NAME = "MaximumChargeCurrent", + base_type = data_types.Int64, +} + +MaximumChargeCurrent.enum_fields = { +} + +function MaximumChargeCurrent:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function MaximumChargeCurrent.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, MaximumChargeCurrent.enum_fields[value_obj.value]) +end + +function MaximumChargeCurrent:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function MaximumChargeCurrent:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MaximumChargeCurrent:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MaximumChargeCurrent:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MaximumChargeCurrent:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MaximumChargeCurrent:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MaximumChargeCurrent, {__call = MaximumChargeCurrent.new_value}) +return MaximumChargeCurrent diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumDischargeCurrent.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumDischargeCurrent.lua new file mode 100644 index 0000000000..609e9c0372 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MaximumDischargeCurrent.lua @@ -0,0 +1,79 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MaximumDischargeCurrent = { + ID = 0x0008, + NAME = "MaximumDischargeCurrent", + base_type = data_types.Int64, +} + +MaximumDischargeCurrent.enum_fields = { +} + +function MaximumDischargeCurrent:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function MaximumDischargeCurrent.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, MaximumDischargeCurrent.enum_fields[value_obj.value]) +end + +function MaximumDischargeCurrent:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function MaximumDischargeCurrent:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MaximumDischargeCurrent:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MaximumDischargeCurrent:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MaximumDischargeCurrent:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MaximumDischargeCurrent:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MaximumDischargeCurrent, {__call = MaximumDischargeCurrent.new_value}) +return MaximumDischargeCurrent diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MinimumChargeCurrent.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MinimumChargeCurrent.lua new file mode 100644 index 0000000000..ba2af6f39d --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/MinimumChargeCurrent.lua @@ -0,0 +1,79 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + + +local MinimumChargeCurrent = { + ID = 0x0006, + NAME = "MinimumChargeCurrent", + base_type = data_types.Int64, +} + +MinimumChargeCurrent.enum_fields = {} + +function MinimumChargeCurrent:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function MinimumChargeCurrent.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, MinimumChargeCurrent.enum_fields[value_obj.value]) +end + +function MinimumChargeCurrent:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function MinimumChargeCurrent:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MinimumChargeCurrent:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function MinimumChargeCurrent:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MinimumChargeCurrent:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MinimumChargeCurrent:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MinimumChargeCurrent, {__call = MinimumChargeCurrent.new_value}) +return MinimumChargeCurrent diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeRequiredEnergy.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeRequiredEnergy.lua new file mode 100644 index 0000000000..61a1a664f1 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeRequiredEnergy.lua @@ -0,0 +1,78 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NextChargeRequiredEnergy = { + ID = 0x0025, + NAME = "NextChargeRequiredEnergy", + base_type = data_types.Uint32, +} + +NextChargeRequiredEnergy.enum_fields = {} + +function NextChargeRequiredEnergy:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function NextChargeRequiredEnergy.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, NextChargeRequiredEnergy.enum_fields[value_obj.value]) +end + +function NextChargeRequiredEnergy:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function NextChargeRequiredEnergy:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeRequiredEnergy:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeRequiredEnergy:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NextChargeRequiredEnergy:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NextChargeRequiredEnergy:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(NextChargeRequiredEnergy, {__call = NextChargeRequiredEnergy.new_value}) +return NextChargeRequiredEnergy diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeStartTime.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeStartTime.lua new file mode 100644 index 0000000000..0125d1e4f0 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeStartTime.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NextChargeStartTime = { + ID = 0x0023, + NAME = "NextChargeStartTime", + base_type = data_types.Uint32, +} + +function NextChargeStartTime:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function NextChargeStartTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeStartTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeStartTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NextChargeStartTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NextChargeStartTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(NextChargeStartTime, {__call = NextChargeStartTime.new_value}) +return NextChargeStartTime diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetSoC.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetSoC.lua new file mode 100644 index 0000000000..d25101acfa --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetSoC.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NextChargeTargetSoC = { + ID = 0x0026, + NAME = "NextChargeTargetSoC", + base_type = data_types.Uint8, +} + +function NextChargeTargetSoC:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function NextChargeTargetSoC:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeTargetSoC:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeTargetSoC:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NextChargeTargetSoC:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NextChargeTargetSoC:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(NextChargeTargetSoC, {__call = NextChargeTargetSoC.new_value}) +return NextChargeTargetSoC diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetTime.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetTime.lua new file mode 100644 index 0000000000..93099b9a99 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/NextChargeTargetTime.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NextChargeTargetTime = { + ID = 0x0024, + NAME = "NextChargeTargetTime", + base_type = data_types.Uint32, +} + +function NextChargeTargetTime:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function NextChargeTargetTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeTargetTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function NextChargeTargetTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NextChargeTargetTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NextChargeTargetTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(NextChargeTargetTime, {__call = NextChargeTargetTime.new_value}) +return NextChargeTargetTime diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/RandomizationDelayWindow.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/RandomizationDelayWindow.lua new file mode 100644 index 0000000000..d9036e9a29 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/RandomizationDelayWindow.lua @@ -0,0 +1,91 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local RandomizationDelayWindow = { + ID = 0x000A, + NAME = "RandomizationDelayWindow", + base_type = data_types.Uint32, +} + +RandomizationDelayWindow.enum_fields = {} + +function RandomizationDelayWindow:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function RandomizationDelayWindow.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, RandomizationDelayWindow.enum_fields[value_obj.value]) +end + +function RandomizationDelayWindow:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function RandomizationDelayWindow:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function RandomizationDelayWindow:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --event_id + data + ) +end + +function RandomizationDelayWindow:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function RandomizationDelayWindow:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function RandomizationDelayWindow:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function RandomizationDelayWindow:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(RandomizationDelayWindow, {__call = RandomizationDelayWindow.new_value}) +return RandomizationDelayWindow diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionDuration.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionDuration.lua new file mode 100644 index 0000000000..f87e0d59ad --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionDuration.lua @@ -0,0 +1,78 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SessionDuration = { + ID = 0x0041, + NAME = "SessionDuration", + base_type = data_types.Uint32, +} + +SessionDuration.enum_fields = {} + +function SessionDuration:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function SessionDuration.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, SessionDuration.enum_fields[value_obj.value]) +end + +function SessionDuration:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SessionDuration:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionDuration:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionDuration:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SessionDuration:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SessionDuration:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SessionDuration, {__call = SessionDuration.new_value}) +return SessionDuration diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyCharged.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyCharged.lua new file mode 100644 index 0000000000..b7c4275961 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyCharged.lua @@ -0,0 +1,78 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SessionEnergyCharged = { + ID = 0x0042, + NAME = "SessionEnergyCharged", + base_type = data_types.Int64, +} + +SessionEnergyCharged.enum_fields = {} + +function SessionEnergyCharged:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function SessionEnergyCharged.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, SessionEnergyCharged.enum_fields[value_obj.value]) +end + +function SessionEnergyCharged:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SessionEnergyCharged:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionEnergyCharged:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionEnergyCharged:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SessionEnergyCharged:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SessionEnergyCharged:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SessionEnergyCharged, {__call = SessionEnergyCharged.new_value}) +return SessionEnergyCharged diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyDischarged.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyDischarged.lua new file mode 100644 index 0000000000..cfcee83351 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionEnergyDischarged.lua @@ -0,0 +1,78 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SessionEnergyDischarged = { + ID = 0x0043, + NAME = "SessionEnergyDischarged", + base_type = data_types.Int64, +} + +SessionEnergyDischarged.enum_fields = {} + +function SessionEnergyDischarged:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function SessionEnergyDischarged.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, SessionEnergyDischarged.enum_fields[value_obj.value]) +end + +function SessionEnergyDischarged:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SessionEnergyDischarged:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionEnergyDischarged:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionEnergyDischarged:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SessionEnergyDischarged:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SessionEnergyDischarged:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SessionEnergyDischarged, {__call = SessionEnergyDischarged.new_value}) +return SessionEnergyDischarged diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionID.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionID.lua new file mode 100644 index 0000000000..28ab386c08 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SessionID.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SessionID = { + ID = 0x0040, + NAME = "SessionID", + base_type = data_types.Uint32, +} + +function SessionID:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function SessionID:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionID:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SessionID:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SessionID:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SessionID:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(SessionID, {__call = SessionID.new_value}) +return SessionID diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/State.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/State.lua new file mode 100644 index 0000000000..d063ed4e0d --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/State.lua @@ -0,0 +1,93 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local State = { + ID = 0x0000, + NAME = "State", + base_type = data_types.Uint8, +} +State.NOT_PLUGGED_IN = 0x00 +State.PLUGGED_IN_NO_DEMAND = 0x01 +State.PLUGGED_IN_DEMAND = 0x02 +State.PLUGGED_IN_CHARGING = 0x03 +State.PLUGGED_IN_DISCHARGING = 0x04 +State.SESSION_ENDING = 0x05 +State.FAULT = 0x06 + +State.enum_fields = { + [State.NOT_PLUGGED_IN] = "NOT_PLUGGED_IN", + [State.PLUGGED_IN_NO_DEMAND] = "PLUGGED_IN_NO_DEMAND", + [State.PLUGGED_IN_DEMAND] = "PLUGGED_IN_DEMAND", + [State.PLUGGED_IN_CHARGING] = "PLUGGED_IN_CHARGING", + [State.PLUGGED_IN_DISCHARGING] = "PLUGGED_IN_DISCHARGING", + [State.SESSION_ENDING] = "SESSION_ENDING", + [State.FAULT] = "FAULT", +} + +function State:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function State.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, State.enum_fields[value_obj.value]) +end + +function State:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function State:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function State:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function State:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function State:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function State:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(State, {__call = State.new_value}) +return State diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/StateOfCharge.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/StateOfCharge.lua new file mode 100644 index 0000000000..20734be59a --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/StateOfCharge.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local StateOfCharge = { + ID = 0x0030, + NAME = "StateOfCharge", + base_type = data_types.Uint8, +} + +function StateOfCharge:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function StateOfCharge:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function StateOfCharge:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function StateOfCharge:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function StateOfCharge:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function StateOfCharge:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(StateOfCharge, {__call = StateOfCharge.new_value}) +return StateOfCharge diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SupplyState.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SupplyState.lua new file mode 100644 index 0000000000..8b86968725 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/SupplyState.lua @@ -0,0 +1,89 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupplyState = { + ID = 0x0001, + NAME = "SupplyState", + base_type = data_types.Uint8, +} +SupplyState.DISABLED = 0x00 +SupplyState.CHARGING_ENABLED = 0x01 +SupplyState.DISCHARGING_ENABLED = 0x02 +SupplyState.DISABLED_ERROR = 0x03 +SupplyState.DISABLED_DIAGNOSTICS = 0x04 + +SupplyState.enum_fields = { + [SupplyState.DISABLED] = "DISABLED", + [SupplyState.CHARGING_ENABLED] = "CHARGING_ENABLED", + [SupplyState.DISCHARGING_ENABLED] = "DISCHARGING_ENABLED", + [SupplyState.DISABLED_ERROR] = "DISABLED_ERROR", + [SupplyState.DISABLED_DIAGNOSTICS] = "DISABLED_DIAGNOSTICS", +} + +function SupplyState:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function SupplyState.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, SupplyState.enum_fields[value_obj.value]) +end + +function SupplyState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupplyState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SupplyState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SupplyState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupplyState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupplyState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupplyState, {__call = SupplyState.new_value}) +return SupplyState diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/UserMaximumChargeCurrent.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/UserMaximumChargeCurrent.lua new file mode 100644 index 0000000000..30da83b49d --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/UserMaximumChargeCurrent.lua @@ -0,0 +1,92 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local UserMaximumChargeCurrent = { + ID = 0x0009, + NAME = "UserMaximumChargeCurrent", + base_type = data_types.Int64, +} + +UserMaximumChargeCurrent.enum_fields = { +} + +function UserMaximumChargeCurrent:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function UserMaximumChargeCurrent.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, UserMaximumChargeCurrent.enum_fields[value_obj.value]) +end + +function UserMaximumChargeCurrent:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function UserMaximumChargeCurrent:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function UserMaximumChargeCurrent:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --event_id + data + ) +end + +function UserMaximumChargeCurrent:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function UserMaximumChargeCurrent:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function UserMaximumChargeCurrent:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function UserMaximumChargeCurrent:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(UserMaximumChargeCurrent, {__call = UserMaximumChargeCurrent.new_value}) +return UserMaximumChargeCurrent diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/VehicleID.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/VehicleID.lua new file mode 100644 index 0000000000..e1f7ce5d8c --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/VehicleID.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local VehicleID = { + ID = 0x0032, + NAME = "VehicleID", + base_type = data_types.UTF8String1, +} + +function VehicleID:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function VehicleID:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function VehicleID:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function VehicleID:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function VehicleID:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function VehicleID:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(VehicleID, {__call = VehicleID.new_value}) +return VehicleID diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/init.lua new file mode 100644 index 0000000000..d0a62d54da --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("EnergyEvse.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local EnergyEvseServerAttributes = {} + +function EnergyEvseServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(EnergyEvseServerAttributes, attr_mt) + +return EnergyEvseServerAttributes diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/ClearTargets.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/ClearTargets.lua new file mode 100644 index 0000000000..bb80560b06 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/ClearTargets.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ClearTargets = {} + +ClearTargets.NAME = "ClearTargets" +ClearTargets.ID = 0x0007 +ClearTargets.field_defs = { +} + +function ClearTargets:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function ClearTargets:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ClearTargets, + __tostring = ClearTargets.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function ClearTargets:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ClearTargets:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function ClearTargets:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(ClearTargets, {__call = ClearTargets.init}) + +return ClearTargets diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/Disable.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/Disable.lua new file mode 100644 index 0000000000..b3b84e6ad2 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/Disable.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Disable = {} + +Disable.NAME = "Disable" +Disable.ID = 0x0001 +Disable.field_defs = { +} + +function Disable:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function Disable:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Disable, + __tostring = Disable.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function Disable:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Disable:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function Disable:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(Disable, {__call = Disable.init}) + +return Disable diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableCharging.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableCharging.lua new file mode 100644 index 0000000000..e82f8ec2dc --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableCharging.lua @@ -0,0 +1,106 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local amperage_maType = require "st.matter.data_types.Int64" + +local EnableCharging = {} + +EnableCharging.NAME = "EnableCharging" +EnableCharging.ID = 0x0002 +EnableCharging.field_defs = { + { + name = "charging_enabled_until", + field_id = 0, + optional = false, + nullable = true, + data_type = data_types.Uint32, + }, + { + name = "minimum_charge_current", + field_id = 1, + optional = false, + nullable = false, + data_type = amperage_maType, + }, + { + name = "maximum_charge_current", + field_id = 2, + optional = false, + nullable = false, + data_type = amperage_maType, + }, +} + +function EnableCharging:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function EnableCharging:init(device, endpoint_id, charging_enabled_until, minimum_charge_current, maximum_charge_current) + local out = {} + local args = {charging_enabled_until, minimum_charge_current, maximum_charge_current} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = EnableCharging, + __tostring = EnableCharging.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function EnableCharging:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EnableCharging:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function EnableCharging:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(EnableCharging, {__call = EnableCharging.init}) + +return EnableCharging diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableDischarging.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableDischarging.lua new file mode 100644 index 0000000000..01df4e7df1 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/EnableDischarging.lua @@ -0,0 +1,99 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local amperage_maType = require "st.matter.data_types.Int64" + +local EnableDischarging = {} + +EnableDischarging.NAME = "EnableDischarging" +EnableDischarging.ID = 0x0003 +EnableDischarging.field_defs = { + { + name = "discharging_enabled_until", + field_id = 0, + optional = false, + nullable = true, + data_type = data_types.Uint32, + }, + { + name = "maximum_discharge_current", + field_id = 1, + optional = false, + nullable = false, + data_type = amperage_maType, + }, +} + +function EnableDischarging:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function EnableDischarging:init(device, endpoint_id, discharging_enabled_until, maximum_discharge_current) + local out = {} + local args = {discharging_enabled_until, maximum_discharge_current} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = EnableDischarging, + __tostring = EnableDischarging.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function EnableDischarging:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EnableDischarging:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function EnableDischarging:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(EnableDischarging, {__call = EnableDischarging.init}) + +return EnableDischarging diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/GetTargets.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/GetTargets.lua new file mode 100644 index 0000000000..f110548447 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/GetTargets.lua @@ -0,0 +1,72 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetTargets = {} + +GetTargets.NAME = "GetTargets" +GetTargets.ID = 0x0006 +GetTargets.field_defs = {} + +function GetTargets:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetTargets, + __tostring = GetTargets.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function GetTargets:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetTargets:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function GetTargets:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(GetTargets, {__call = GetTargets.init}) + +return GetTargets diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/SetTargets.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/SetTargets.lua new file mode 100644 index 0000000000..f3726de104 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/SetTargets.lua @@ -0,0 +1,93 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ChargingTargetScheduleStructType = require "EnergyEvsetypes.ChargingTargetScheduleStruct" + +local SetTargets = {} + +SetTargets.NAME = "SetTargets" +SetTargets.ID = 0x0005 +SetTargets.field_defs = { + { + name = "charging_target_schedules", + field_id = 0, + optional = false, + nullable = false, + data_type = data_types.Array, + array_type = ChargingTargetScheduleStructType, + }, +} + +function SetTargets:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function SetTargets:init(device, endpoint_id, charging_target_schedules) + local out = {} + local args = {charging_target_schedules} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetTargets, + __tostring = SetTargets.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function SetTargets:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetTargets:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function SetTargets:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(SetTargets, {__call = SetTargets.init}) + +return SetTargets diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/StartDiagnostics.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/StartDiagnostics.lua new file mode 100644 index 0000000000..6b7623cbb6 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/StartDiagnostics.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local StartDiagnostics = {} + +StartDiagnostics.NAME = "StartDiagnostics" +StartDiagnostics.ID = 0x0004 +StartDiagnostics.field_defs = { +} + +function StartDiagnostics:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function StartDiagnostics:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = StartDiagnostics, + __tostring = StartDiagnostics.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function StartDiagnostics:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function StartDiagnostics:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function StartDiagnostics:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(StartDiagnostics, {__call = StartDiagnostics.init}) + +return StartDiagnostics diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/init.lua new file mode 100644 index 0000000000..34f126c95b --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("EnergyEvse.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local EnergyEvseServerCommands = {} + +function EnergyEvseServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(EnergyEvseServerCommands, command_mt) + +return EnergyEvseServerCommands diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVConnected.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVConnected.lua new file mode 100644 index 0000000000..3790cec4a3 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVConnected.lua @@ -0,0 +1,97 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local EVConnected = { + ID = 0x0000, + NAME = "EVConnected", + base_type = data_types.Structure, +} + +EVConnected.field_defs = { + { + data_type = data_types.Uint32, + field_id = 0, + is_array = false, + name = "session_id", + is_nullable = false, + is_optional = false, + }, +} + +function EVConnected:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + elems[field_def.name].field_name = field_def.name + end + end + end + base_type_obj.elements = elems +end + +function EVConnected:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EVConnected:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EVConnected:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "EVConnectedEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EVConnected:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function EVConnected:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return EVConnected diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVNotDetected.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVNotDetected.lua new file mode 100644 index 0000000000..d4796814cd --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EVNotDetected.lua @@ -0,0 +1,129 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local StateEnum = require "EnergyEvsetypes.StateEnum" +local EVNotDetected = { + ID = 0x0001, + NAME = "EVNotDetected", + base_type = data_types.Structure, +} + +EVNotDetected.field_defs = { + { + data_type = data_types.Uint32, + field_id = 0, + is_array = false, + name = "session_id", + is_nullable = false, + is_optional = false, + }, + { + data_type = StateEnum, + field_id = 1, + is_array = false, + name = "state", + is_nullable = false, + is_optional = false, + }, + { + data_type = data_types.Uint32, + field_id = 2, + is_array = false, + name = "session_duration", + is_nullable = false, + is_optional = false, + }, + { + data_type = data_types.Int64, + field_id = 3, + is_array = false, + name = "session_energy_charged", + is_nullable = false, + is_optional = false, + }, + { + data_type = data_types.Int64, + field_id = 4, + is_array = false, + name = "session_energy_discharged", + is_nullable = false, + is_optional = false, + }, +} + +function EVNotDetected:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + elems[field_def.name].field_name = field_def.name + end + end + end + base_type_obj.elements = elems +end + +function EVNotDetected:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EVNotDetected:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EVNotDetected:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "EVNotDetectedEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EVNotDetected:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function EVNotDetected:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return EVNotDetected diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStarted.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStarted.lua new file mode 100644 index 0000000000..afb67f5a92 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStarted.lua @@ -0,0 +1,114 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local StateEnum = require "EnergyEvsetypes.StateEnum" + +local EnergyTransferStarted = { + ID = 0x0002, + NAME = "EnergyTransferStarted", + base_type = data_types.Structure, +} + +EnergyTransferStarted.field_defs = { + { + data_type = data_types.Uint32, + field_id = 0, + is_array = false, + name = "session_id", + is_nullable = false, + is_optional = false, + }, + { + data_type = StateEnum, + field_id = 1, + is_array = false, + name = "state", + is_nullable = false, + is_optional = false, + }, + { + data_type = data_types.Int64, + field_id = 2, + is_array = false, + name = "maximum_current", + is_nullable = false, + is_optional = false, + }, +} + +function EnergyTransferStarted:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + elems[field_def.name].field_name = field_def.name + end + end + end + base_type_obj.elements = elems +end + +function EnergyTransferStarted:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EnergyTransferStarted:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EnergyTransferStarted:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "EnergyTransferStartedEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EnergyTransferStarted:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function EnergyTransferStarted:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return EnergyTransferStarted diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStopped.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStopped.lua new file mode 100644 index 0000000000..cc4b23c2b2 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/EnergyTransferStopped.lua @@ -0,0 +1,123 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local StateEnum = require "EnergyEvsetypes.StateEnum" +local EnergyTransferStoppedReasonEnum = require "EnergyEvsetypes.EnergyTransferStoppedReasonEnum" + +local EnergyTransferStopped = { + ID = 0x0003, + NAME = "EnergyTransferStopped", + base_type = data_types.Structure, +} + +EnergyTransferStopped.field_defs = { + { + data_type = data_types.Uint32, + field_id = 0, + is_array = false, + name = "session_id", + is_nullable = false, + is_optional = false, + }, + { + data_type = StateEnum, + field_id = 1, + is_array = false, + name = "state", + is_nullable = false, + is_optional = false, + }, + { + data_type = EnergyTransferStoppedReasonEnum, + field_id = 2, + is_array = false, + name = "reason", + is_nullable = false, + is_optional = false, + }, + { + data_type = data_types.Int64, + field_id = 4, + is_array = false, + name = "energy_transferred", + is_nullable = false, + is_optional = false, + }, +} + +function EnergyTransferStopped:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + elems[field_def.name].field_name = field_def.name + end + end + end + base_type_obj.elements = elems +end + +function EnergyTransferStopped:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EnergyTransferStopped:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function EnergyTransferStopped:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "EnergyTransferStoppedEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EnergyTransferStopped:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function EnergyTransferStopped:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return EnergyTransferStopped diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Fault.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Fault.lua new file mode 100644 index 0000000000..76b6918ae3 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Fault.lua @@ -0,0 +1,123 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local StateEnum = require "EnergyEvsetypes.StateEnum" +local FaultStateEnum = require "EnergyEvsetypes.FaultStateEnum" + +local Fault = { + ID = 0x0004, + NAME = "Fault", + base_type = data_types.Structure, +} + +Fault.field_defs = { + { + data_type = data_types.Uint32, + field_id = 0, + is_array = false, + name = "session_id", + is_nullable = true, + is_optional = true, + }, + { + data_type = StateEnum, + field_id = 1, + is_array = false, + name = "state", + is_nullable = false, + is_optional = false, + }, + { + data_type = FaultStateEnum, + field_id = 2, + is_array = false, + name = "fault_state_previous_state", + is_nullable = false, + is_optional = false, + }, + { + data_type = FaultStateEnum, + field_id = 4, + is_array = false, + name = "fault_state_current_state", + is_nullable = false, + is_optional = false, + }, +} + +function Fault:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + elems[field_def.name].field_name = field_def.name + end + end + end + base_type_obj.elements = elems +end + +function Fault:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function Fault:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function Fault:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "FaultEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Fault:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function Fault:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return Fault diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Rfid.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Rfid.lua new file mode 100644 index 0000000000..aa3a85342a --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/Rfid.lua @@ -0,0 +1,97 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local Rfid = { + ID = 0x0005, + NAME = "Rfid", + base_type = data_types.Structure, +} + +Rfid.field_defs = { + { + data_type = data_types.OctetString1, + field_id = 0, + is_array = false, + name = "uid", + is_nullable = false, + is_optional = false, + }, +} + +function Rfid:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + elems[field_def.name].field_name = field_def.name + end + end + end + base_type_obj.elements = elems +end + +function Rfid:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function Rfid:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, --attribute_id + self.ID + ) +end + +function Rfid:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "RfidEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Rfid:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function Rfid:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return Rfid diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/init.lua new file mode 100644 index 0000000000..aa81eed4b5 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/server/events/init.lua @@ -0,0 +1,23 @@ +local event_mt = {} +event_mt.__event_cache = {} +event_mt.__index = function(self, key) + if event_mt.__event_cache[key] == nil then + local req_loc = string.format("EnergyEvse.server.events.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + event_mt.__event_cache[key] = raw_def + end + return event_mt.__event_cache[key] +end + +local EnergyEvseEvents = {} + +function EnergyEvseEvents:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(EnergyEvseEvents, event_mt) + +return EnergyEvseEvents diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetScheduleStruct.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetScheduleStruct.lua new file mode 100644 index 0000000000..f94d0859b5 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetScheduleStruct.lua @@ -0,0 +1,71 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local TargetDayOfWeekBitmap = require "EnergyEvsetypes.TargetDayOfWeekBitmap" +local ChargingTargetStruct = require "EnergyEvsetypes.ChargingTargetStruct" + +local ChargingTargetScheduleStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ChargingTargetScheduleStruct", ID = data_types.name_to_id_map["Structure"]}) + +ChargingTargetScheduleStruct.field_defs = { + { + data_type = TargetDayOfWeekBitmap, + field_id = 0, + name = "day_of_week_for_sequence", + }, + { + data_type = ChargingTargetStruct, + field_id = 1, + name = "charging_targets", + }, +} + +ChargingTargetScheduleStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ChargingTargetScheduleStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ChargingTargetScheduleStruct.init +new_mt.__index.serialize = ChargingTargetScheduleStruct.serialize + +ChargingTargetScheduleStruct.augment_type = function(self, val) + local elems = {} + for _, v in ipairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.array_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.array_type) + end + end + end + end + end + val.elements = elems + setmetatable(val, new_mt) +end + +setmetatable(ChargingTargetScheduleStruct, new_mt) + +return ChargingTargetScheduleStruct diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetStruct.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetStruct.lua new file mode 100644 index 0000000000..81ecd3e751 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/ChargingTargetStruct.lua @@ -0,0 +1,78 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ChargingTargetStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ChargingTargetStruct", ID = data_types.name_to_id_map["Structure"]}) + +ChargingTargetStruct.field_defs = { + { + data_type = data_types.Uint16, + field_id = 0, + name = "target_time_minutes_past_midnight", + is_nullable = false, + is_optional = false, + }, + { + data_type = data_types.Uint8, + field_id = 1, + name = "target_so_c", + is_nullable = false, + is_optional = false, + }, + { + data_type = data_types.Int64, + field_id = 2, + name = "added_energy", + }, +} + +ChargingTargetStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional or not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ChargingTargetStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ChargingTargetStruct.init +new_mt.__index.serialize = ChargingTargetStruct.serialize + +ChargingTargetStruct.augment_type = function(self, val) + local elems = {} + for _, v in ipairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.array_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.array_type) + end + end + end + end + end + val.elements = elems + setmetatable(val, new_mt) +end + +setmetatable(ChargingTargetStruct, new_mt) + +return ChargingTargetStruct diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/EnergyTransferStoppedReasonEnum.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/EnergyTransferStoppedReasonEnum.lua new file mode 100644 index 0000000000..76e0958be3 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/EnergyTransferStoppedReasonEnum.lua @@ -0,0 +1,26 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local EnergyTransferStoppedReasonEnum = {} +local new_mt = UintABC.new_mt({NAME = "EnergyTransferStoppedReasonEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.EV_STOPPED] = "EV_STOPPED", + [self.EVSE_STOPPED] = "EVSE_STOPPED", + [self.OTHER] = "OTHER", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.EV_STOPPED = 0x00 +new_mt.__index.EVSE_STOPPED = 0x01 +new_mt.__index.OTHER = 0x02 + +EnergyTransferStoppedReasonEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(EnergyTransferStoppedReasonEnum, new_mt) + +return EnergyTransferStoppedReasonEnum diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/FaultStateEnum.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/FaultStateEnum.lua new file mode 100644 index 0000000000..6013c0a251 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/FaultStateEnum.lua @@ -0,0 +1,54 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local FaultStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "FaultStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NO_ERROR] = "NO_ERROR", + [self.METER_FAILURE] = "METER_FAILURE", + [self.OVER_VOLTAGE] = "OVER_VOLTAGE", + [self.UNDER_VOLTAGE] = "UNDER_VOLTAGE", + [self.OVER_CURRENT] = "OVER_CURRENT", + [self.CONTACT_WET_FAILURE] = "CONTACT_WET_FAILURE", + [self.CONTACT_DRY_FAILURE] = "CONTACT_DRY_FAILURE", + [self.GROUND_FAULT] = "GROUND_FAULT", + [self.POWER_LOSS] = "POWER_LOSS", + [self.POWER_QUALITY] = "POWER_QUALITY", + [self.PILOT_SHORT_CIRCUIT] = "PILOT_SHORT_CIRCUIT", + [self.EMERGENCY_STOP] = "EMERGENCY_STOP", + [self.EV_DISCONNECTED] = "EV_DISCONNECTED", + [self.WRONG_POWER_SUPPLY] = "WRONG_POWER_SUPPLY", + [self.LIVE_NEUTRAL_SWAP] = "LIVE_NEUTRAL_SWAP", + [self.OVER_TEMPERATURE] = "OVER_TEMPERATURE", + [self.OTHER] = "OTHER", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NO_ERROR = 0x00 +new_mt.__index.METER_FAILURE = 0x01 +new_mt.__index.OVER_VOLTAGE = 0x02 +new_mt.__index.UNDER_VOLTAGE = 0x03 +new_mt.__index.OVER_CURRENT = 0x04 +new_mt.__index.CONTACT_WET_FAILURE = 0x05 +new_mt.__index.CONTACT_DRY_FAILURE = 0x06 +new_mt.__index.GROUND_FAULT = 0x07 +new_mt.__index.POWER_LOSS = 0x08 +new_mt.__index.POWER_QUALITY = 0x09 +new_mt.__index.PILOT_SHORT_CIRCUIT = 0x0A +new_mt.__index.EMERGENCY_STOP = 0x0B +new_mt.__index.EV_DISCONNECTED = 0x0C +new_mt.__index.WRONG_POWER_SUPPLY = 0x0D +new_mt.__index.LIVE_NEUTRAL_SWAP = 0x0E +new_mt.__index.OVER_TEMPERATURE = 0x0F +new_mt.__index.OTHER = 0xFF + +FaultStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(FaultStateEnum, new_mt) + +return FaultStateEnum diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/Feature.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/Feature.lua new file mode 100644 index 0000000000..924ea7116c --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/Feature.lua @@ -0,0 +1,137 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.CHARGING_PREFERENCES = 0x0001 +Feature.SOC_REPORTING = 0x0002 +Feature.PLUG_AND_CHARGE = 0x0004 +Feature.RFID = 0x0008 +Feature.V2X = 0x0010 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + CHARGING_PREFERENCES = 0x0001, + SOC_REPORTING = 0x0002, + PLUG_AND_CHARGE = 0x0004, + RFID = 0x0008, + V2X = 0x0010, +} + +Feature.is_charging_preferences_set = function(self) + return (self.value & self.CHARGING_PREFERENCES) ~= 0 +end + +Feature.set_charging_preferences = function(self) + if self.value ~= nil then + self.value = self.value | self.CHARGING_PREFERENCES + else + self.value = self.CHARGING_PREFERENCES + end +end + +Feature.unset_charging_preferences = function(self) + self.value = self.value & (~self.CHARGING_PREFERENCES & self.BASE_MASK) +end + +Feature.is_soc_reporting_set = function(self) + return (self.value & self.SOC_REPORTING) ~= 0 +end + +Feature.set_soc_reporting = function(self) + if self.value ~= nil then + self.value = self.value | self.SOC_REPORTING + else + self.value = self.SOC_REPORTING + end +end + +Feature.unset_soc_reporting = function(self) + self.value = self.value & (~self.SOC_REPORTING & self.BASE_MASK) +end + +Feature.is_plug_and_charge_set = function(self) + return (self.value & self.PLUG_AND_CHARGE) ~= 0 +end + +Feature.set_plug_and_charge = function(self) + if self.value ~= nil then + self.value = self.value | self.PLUG_AND_CHARGE + else + self.value = self.PLUG_AND_CHARGE + end +end + +Feature.unset_plug_and_charge = function(self) + self.value = self.value & (~self.PLUG_AND_CHARGE & self.BASE_MASK) +end + +Feature.is_rfid_set = function(self) + return (self.value & self.RFID) ~= 0 +end + +Feature.set_rfid = function(self) + if self.value ~= nil then + self.value = self.value | self.RFID + else + self.value = self.RFID + end +end + +Feature.unset_rfid = function(self) + self.value = self.value & (~self.RFID & self.BASE_MASK) +end + +Feature.is_v2x_set = function(self) + return (self.value & self.V2X) ~= 0 +end + +Feature.set_v2x = function(self) + if self.value ~= nil then + self.value = self.value | self.V2X + else + self.value = self.V2X + end +end + +Feature.unset_v2x = function(self) + self.value = self.value & (~self.V2X & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.CHARGING_PREFERENCES | Feature.SOC_REPORTING | Feature.PLUG_AND_CHARGE | Feature.RFID | Feature.V2X + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_charging_preferences_set = Feature.is_charging_preferences_set, + set_charging_preferences = Feature.set_charging_preferences, + unset_charging_preferences = Feature.unset_charging_preferences, + is_soc_reporting_set = Feature.is_soc_reporting_set, + set_soc_reporting = Feature.set_soc_reporting, + unset_soc_reporting = Feature.unset_soc_reporting, + is_plug_and_charge_set = Feature.is_plug_and_charge_set, + set_plug_and_charge = Feature.set_plug_and_charge, + unset_plug_and_charge = Feature.unset_plug_and_charge, + is_rfid_set = Feature.is_rfid_set, + set_rfid = Feature.set_rfid, + unset_rfid = Feature.unset_rfid, + is_v2x_set = Feature.is_v2x_set, + set_v2x = Feature.set_v2x, + unset_v2x = Feature.unset_v2x, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/StateEnum.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/StateEnum.lua new file mode 100644 index 0000000000..3f01167718 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/StateEnum.lua @@ -0,0 +1,34 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local StateEnum = {} +local new_mt = UintABC.new_mt({NAME = "StateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NOT_PLUGGED_IN] = "NOT_PLUGGED_IN", + [self.PLUGGED_IN_NO_DEMAND] = "PLUGGED_IN_NO_DEMAND", + [self.PLUGGED_IN_DEMAND] = "PLUGGED_IN_DEMAND", + [self.PLUGGED_IN_CHARGING] = "PLUGGED_IN_CHARGING", + [self.PLUGGED_IN_DISCHARGING] = "PLUGGED_IN_DISCHARGING", + [self.SESSION_ENDING] = "SESSION_ENDING", + [self.FAULT] = "FAULT", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NOT_PLUGGED_IN = 0x00 +new_mt.__index.PLUGGED_IN_NO_DEMAND = 0x01 +new_mt.__index.PLUGGED_IN_DEMAND = 0x02 +new_mt.__index.PLUGGED_IN_CHARGING = 0x03 +new_mt.__index.PLUGGED_IN_DISCHARGING = 0x04 +new_mt.__index.SESSION_ENDING = 0x05 +new_mt.__index.FAULT = 0x06 + +StateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(StateEnum, new_mt) + +return StateEnum diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/SupplyStateEnum.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/SupplyStateEnum.lua new file mode 100644 index 0000000000..cf7764f8cc --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/SupplyStateEnum.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local SupplyStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "SupplyStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.DISABLED] = "DISABLED", + [self.CHARGING_ENABLED] = "CHARGING_ENABLED", + [self.DISCHARGING_ENABLED] = "DISCHARGING_ENABLED", + [self.DISABLED_ERROR] = "DISABLED_ERROR", + [self.DISABLED_DIAGNOSTICS] = "DISABLED_DIAGNOSTICS", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.DISABLED = 0x00 +new_mt.__index.CHARGING_ENABLED = 0x01 +new_mt.__index.DISCHARGING_ENABLED = 0x02 +new_mt.__index.DISABLED_ERROR = 0x03 +new_mt.__index.DISABLED_DIAGNOSTICS = 0x04 + +SupplyStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(SupplyStateEnum, new_mt) + +return SupplyStateEnum diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/TargetDayOfWeekBitmap.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/TargetDayOfWeekBitmap.lua new file mode 100644 index 0000000000..4459cf4985 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/TargetDayOfWeekBitmap.lua @@ -0,0 +1,170 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local TargetDayOfWeekBitmap = {} +local new_mt = UintABC.new_mt({NAME = "TargetDayOfWeekBitmap", ID = data_types.name_to_id_map["Uint8"]}, 1) + +TargetDayOfWeekBitmap.BASE_MASK = 0xFFFF +TargetDayOfWeekBitmap.SUNDAY = 0x0001 +TargetDayOfWeekBitmap.MONDAY = 0x0002 +TargetDayOfWeekBitmap.TUESDAY = 0x0004 +TargetDayOfWeekBitmap.WEDNESDAY = 0x0008 +TargetDayOfWeekBitmap.THURSDAY = 0x0010 +TargetDayOfWeekBitmap.FRIDAY = 0x0020 +TargetDayOfWeekBitmap.SATURDAY = 0x0040 + +TargetDayOfWeekBitmap.mask_fields = { + BASE_MASK = 0xFFFF, + SUNDAY = 0x0001, + MONDAY = 0x0002, + TUESDAY = 0x0004, + WEDNESDAY = 0x0008, + THURSDAY = 0x0010, + FRIDAY = 0x0020, + SATURDAY = 0x0040, +} + +TargetDayOfWeekBitmap.is_sunday_set = function(self) + return (self.value & self.SUNDAY) ~= 0 +end + +TargetDayOfWeekBitmap.set_sunday = function(self) + if self.value ~= nil then + self.value = self.value | self.SUNDAY + else + self.value = self.SUNDAY + end +end + +TargetDayOfWeekBitmap.unset_sunday = function(self) + self.value = self.value & (~self.SUNDAY & self.BASE_MASK) +end + +TargetDayOfWeekBitmap.is_monday_set = function(self) + return (self.value & self.MONDAY) ~= 0 +end + +TargetDayOfWeekBitmap.set_monday = function(self) + if self.value ~= nil then + self.value = self.value | self.MONDAY + else + self.value = self.MONDAY + end +end + +TargetDayOfWeekBitmap.unset_monday = function(self) + self.value = self.value & (~self.MONDAY & self.BASE_MASK) +end + +TargetDayOfWeekBitmap.is_tuesday_set = function(self) + return (self.value & self.TUESDAY) ~= 0 +end + +TargetDayOfWeekBitmap.set_tuesday = function(self) + if self.value ~= nil then + self.value = self.value | self.TUESDAY + else + self.value = self.TUESDAY + end +end + +TargetDayOfWeekBitmap.unset_tuesday = function(self) + self.value = self.value & (~self.TUESDAY & self.BASE_MASK) +end + +TargetDayOfWeekBitmap.is_wednesday_set = function(self) + return (self.value & self.WEDNESDAY) ~= 0 +end + +TargetDayOfWeekBitmap.set_wednesday = function(self) + if self.value ~= nil then + self.value = self.value | self.WEDNESDAY + else + self.value = self.WEDNESDAY + end +end + +TargetDayOfWeekBitmap.unset_wednesday = function(self) + self.value = self.value & (~self.WEDNESDAY & self.BASE_MASK) +end + +TargetDayOfWeekBitmap.is_thursday_set = function(self) + return (self.value & self.THURSDAY) ~= 0 +end + +TargetDayOfWeekBitmap.set_thursday = function(self) + if self.value ~= nil then + self.value = self.value | self.THURSDAY + else + self.value = self.THURSDAY + end +end + +TargetDayOfWeekBitmap.unset_thursday = function(self) + self.value = self.value & (~self.THURSDAY & self.BASE_MASK) +end + +TargetDayOfWeekBitmap.is_friday_set = function(self) + return (self.value & self.FRIDAY) ~= 0 +end + +TargetDayOfWeekBitmap.set_friday = function(self) + if self.value ~= nil then + self.value = self.value | self.FRIDAY + else + self.value = self.FRIDAY + end +end + +TargetDayOfWeekBitmap.unset_friday = function(self) + self.value = self.value & (~self.FRIDAY & self.BASE_MASK) +end + +TargetDayOfWeekBitmap.is_saturday_set = function(self) + return (self.value & self.SATURDAY) ~= 0 +end + +TargetDayOfWeekBitmap.set_saturday = function(self) + if self.value ~= nil then + self.value = self.value | self.SATURDAY + else + self.value = self.SATURDAY + end +end + +TargetDayOfWeekBitmap.unset_saturday = function(self) + self.value = self.value & (~self.SATURDAY & self.BASE_MASK) +end + + +TargetDayOfWeekBitmap.mask_methods = { + is_sunday_set = TargetDayOfWeekBitmap.is_sunday_set, + set_sunday = TargetDayOfWeekBitmap.set_sunday, + unset_sunday = TargetDayOfWeekBitmap.unset_sunday, + is_monday_set = TargetDayOfWeekBitmap.is_monday_set, + set_monday = TargetDayOfWeekBitmap.set_monday, + unset_monday = TargetDayOfWeekBitmap.unset_monday, + is_tuesday_set = TargetDayOfWeekBitmap.is_tuesday_set, + set_tuesday = TargetDayOfWeekBitmap.set_tuesday, + unset_tuesday = TargetDayOfWeekBitmap.unset_tuesday, + is_wednesday_set = TargetDayOfWeekBitmap.is_wednesday_set, + set_wednesday = TargetDayOfWeekBitmap.set_wednesday, + unset_wednesday = TargetDayOfWeekBitmap.unset_wednesday, + is_thursday_set = TargetDayOfWeekBitmap.is_thursday_set, + set_thursday = TargetDayOfWeekBitmap.set_thursday, + unset_thursday = TargetDayOfWeekBitmap.unset_thursday, + is_friday_set = TargetDayOfWeekBitmap.is_friday_set, + set_friday = TargetDayOfWeekBitmap.set_friday, + unset_friday = TargetDayOfWeekBitmap.unset_friday, + is_saturday_set = TargetDayOfWeekBitmap.is_saturday_set, + set_saturday = TargetDayOfWeekBitmap.set_saturday, + unset_saturday = TargetDayOfWeekBitmap.unset_saturday, +} + +TargetDayOfWeekBitmap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(TargetDayOfWeekBitmap, new_mt) + +return TargetDayOfWeekBitmap diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvse/types/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/init.lua new file mode 100644 index 0000000000..325111ebd6 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvse/types/init.lua @@ -0,0 +1,16 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + local req_loc = string.format("EnergyEvse.types.%s", key) + local cluster_type = require(req_loc) + types_mt.__types_cache[key] = cluster_type + end + return types_mt.__types_cache[key] +end + +local EnergyEvseTypes = {} + +setmetatable(EnergyEvseTypes, types_mt) + +return EnergyEvseTypes diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/init.lua new file mode 100644 index 0000000000..e643539cb9 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/init.lua @@ -0,0 +1,96 @@ +local cluster_base = require "st.matter.cluster_base" +local EnergyEvseModeServerAttributes = require "EnergyEvseMode.server.attributes" +local EnergyEvseModeServerCommands = require "EnergyEvseMode.server.commands" +local EnergyEvseModeTypes = require "EnergyEvseMode.types" + +local EnergyEvseMode = {} + +EnergyEvseMode.ID = 0x009D +EnergyEvseMode.NAME = "EnergyEvseMode" +EnergyEvseMode.server = {} +EnergyEvseMode.server.attributes = EnergyEvseModeServerAttributes:set_parent_cluster(EnergyEvseMode) +EnergyEvseMode.server.commands = EnergyEvseModeServerCommands:set_parent_cluster(EnergyEvseMode) +EnergyEvseMode.types = EnergyEvseModeTypes +EnergyEvseMode.FeatureMap = EnergyEvseMode.types.Feature + +function EnergyEvseMode.are_features_supported(feature, feature_map) + if (EnergyEvseMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +function EnergyEvseMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function EnergyEvseMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function EnergyEvseMode:get_client_command_by_id(command_id) + local client_id_map = { + [0x0001] = "ChangeToModeResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +-- Attribute Mapping +EnergyEvseMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +-- Command Mapping +EnergyEvseMode.command_direction_map = { + ["ChangeToMode"] = "server", + ["ChangeToModeResponse"] = "client", +} + +-- Cluster Completion +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = EnergyEvseMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, EnergyEvseMode.NAME)) + end + return EnergyEvseMode[direction].attributes[key] +end +EnergyEvseMode.attributes = {} +setmetatable(EnergyEvseMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = EnergyEvseMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, EnergyEvseMode.NAME)) + end + return EnergyEvseMode[direction].commands[key] +end +EnergyEvseMode.commands = {} +setmetatable(EnergyEvseMode.commands, command_helper_mt) + +setmetatable(EnergyEvseMode, {__index = cluster_base}) + +return EnergyEvseMode diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..066732c701 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +AcceptedCommandList.enum_fields = {} + +function AcceptedCommandList:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function AcceptedCommandList.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, AcceptedCommandList.enum_fields[value_obj.value]) +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..5cdd60da2d --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/AttributeList.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +AttributeList.enum_fields = {} + +function AttributeList:augment_type(base_type_obj) + base_type_obj.field_name = self.NAME + base_type_obj.pretty_print = self.pretty_print +end + +function AttributeList.pretty_print(value_obj) + return string.format("%s.%s", value_obj.field_name or value_obj.NAME, AttributeList.enum_fields[value_obj.value]) +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value}) +return AttributeList diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..e7b5a885b6 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/CurrentMode.lua @@ -0,0 +1,64 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..d217cdf658 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/SupportedModes.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "EnergyEvseMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/init.lua new file mode 100644 index 0000000000..862077d7d6 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("EnergyEvseMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local EnergyEvseModeServerAttributes = {} + +function EnergyEvseModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(EnergyEvseModeServerAttributes, attr_mt) + +return EnergyEvseModeServerAttributes diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..7e6ee5f9f5 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/ChangeToMode.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + optional = false, + nullable = false, + data_type = data_types.Uint8, + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.optional and args[i] == nil then + out[v.name] = nil + elseif v.nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + return TLVParser.decode_tlv(tlv_buf) +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/init.lua new file mode 100644 index 0000000000..2414d88e80 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("EnergyEvseMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local EnergyEvseModeServerCommands = {} + +function EnergyEvseModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(EnergyEvseModeServerCommands, command_mt) + +return EnergyEvseModeServerCommands diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/Feature.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/Feature.lua new file mode 100644 index 0000000000..6632f45908 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/Feature.lua @@ -0,0 +1,55 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..72465b9893 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeOptionStruct.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "EnergyEvseMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTag.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTag.lua new file mode 100644 index 0000000000..71b2c43960 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTag.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.MANUAL] = "MANUAL", + [self.TIME_OF_USE] = "TIME_OF_USE", + [self.SOLAR_CHARGING] = "SOLAR_CHARGING", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.MANUAL = 0x4000 +new_mt.__index.TIME_OF_USE = 0x4001 +new_mt.__index.SOLAR_CHARGING = 0x4002 + +ModeTag.MANUAL = 0x4000 +ModeTag.TIME_OF_USE = 0x4001 +ModeTag.SOLAR_CHARGING = 0x4002 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..610fb90ad7 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/ModeTagStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/init.lua b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/init.lua new file mode 100644 index 0000000000..a0d575f778 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/EnergyEvseMode/types/init.lua @@ -0,0 +1,16 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + local req_loc = string.format("EnergyEvseMode.types.%s", key) + local cluster_type = require(req_loc) + types_mt.__types_cache[key] = cluster_type + end + return types_mt.__types_cache[key] +end + +local EnergyEvseModeTypes = {} + +setmetatable(EnergyEvseModeTypes, types_mt) + +return EnergyEvseModeTypes diff --git a/drivers/SmartThings/matter-energy/src/embedded_cluster_utils.lua b/drivers/SmartThings/matter-energy/src/embedded_cluster_utils.lua new file mode 100644 index 0000000000..ee274e64a3 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/embedded_cluster_utils.lua @@ -0,0 +1,60 @@ +local clusters = require "st.matter.clusters" +local utils = require "st.utils" +local version = require "version" + +if version.api < 11 then + clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" + clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" + clusters.EnergyEvse = require "EnergyEvse" + clusters.EnergyEvseMode = require "EnergyEvseMode" +end + +if version.api < 12 then + clusters.DeviceEnergyManagementMode = require "DeviceEnergyManagementMode" +end + +local embedded_cluster_utils = {} + +local embedded_clusters = { + [clusters.ElectricalPowerMeasurement.ID] = clusters.ElectricalPowerMeasurement, + [clusters.EnergyEvse.ID] = clusters.EnergyEvse, + [clusters.DeviceEnergyManagementMode.ID] = clusters.DeviceEnergyManagementMode, + [clusters.ElectricalEnergyMeasurement.ID] = clusters.ElectricalEnergyMeasurement, + [clusters.EnergyEvseMode.ID] = clusters.EnergyEvseMode, +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + if embedded_clusters[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + table.sort(eps) + return eps + else + local eps = device:get_endpoints(cluster_id, opts) + table.sort(eps) + return eps + end + end + + return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-energy/src/init.lua b/drivers/SmartThings/matter-energy/src/init.lua new file mode 100644 index 0000000000..c6e44008d6 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/init.lua @@ -0,0 +1,758 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local MatterDriver = require "st.matter.driver" +local clusters = require "st.matter.clusters" +local log = require "log" +local utils = require "st.utils" +local matter_driver_template = {} +local embedded_cluster_utils = require "embedded_cluster_utils" + +local version = require "version" + +if version.api < 11 then + clusters.EnergyEvse = require "EnergyEvse" + clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" + clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" + clusters.EnergyEvseMode = require "EnergyEvseMode" +end + +if version.api < 12 then + clusters.DeviceEnergyManagementMode = require "DeviceEnergyManagementMode" +end + +local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" +local SUPPORTED_EVSE_MODES = "__supported_evse_modes" +local SUPPORTED_DEVICE_ENERGY_MANAGEMENT_MODES = "__supported_device_energy_management_modes" + +local CUMULATIVE_REPORTS_NOT_SUPPORTED = "__cumulative_reports_not_supported" +local LAST_IMPORTED_REPORT_TIMESTAMP = "__last_imported_report_timestamp" +local LAST_EXPORTED_REPORT_TIMESTAMP = "__last_exported_report_timestamp" +local MINIMUM_ST_ENERGY_REPORT_INTERVAL = (15 * 60) -- 15 minutes, reported in seconds + +-- total in case there are multiple electrical sensors +local TOTAL_CUMULATIVE_ENERGY_IMPORTED = "__total_cumulative_energy_imported" +local TOTAL_CUMULATIVE_ENERGY_EXPORTED = "__total_cumulative_energy_exported" +local TOTAL_ACTIVE_POWER = "__total_active_power" + +local updated_fields = { + { current_field_name = "__supported_evse_modes_map", updated_field_name = nil }, + { current_field_name = "__supported_device_energy_management_modes_map", updated_field_name = nil } +} + +local MAX_CHARGING_CURRENT_CONSTRAINT = 80000 -- In v1.3 release of stack, this check for 80 A is performed. + +local EVSE_DEVICE_TYPE_ID = 0x050C +local SOLAR_POWER_DEVICE_TYPE_ID = 0x0017 +local BATTERY_STORAGE_DEVICE_TYPE_ID = 0x0018 +local ELECTRICAL_SENSOR_DEVICE_TYPE_ID = 0x0510 +local DEVICE_ENERGY_MANAGEMENT_DEVICE_TYPE_ID = 0x050D + +local function get_endpoints_for_dt(device, device_type) + local endpoints = {} + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == device_type then + table.insert(endpoints, ep.endpoint_id) + break + end + end + end + table.sort(endpoints) + return endpoints +end + +local find_default_endpoint = function(device) + local evse_eps = get_endpoints_for_dt(device, EVSE_DEVICE_TYPE_ID) or {} + local solar_power_eps = get_endpoints_for_dt(device, SOLAR_POWER_DEVICE_TYPE_ID) or {} + if #evse_eps > 0 then + return evse_eps[1] + elseif #solar_power_eps > 0 then + return solar_power_eps[1] + end + return device.MATTER_DEFAULT_ENDPOINT +end + +local function endpoint_to_component(device, ep) + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} + for component, endpoint in pairs(map) do + if endpoint == ep then + return component + end + end + return "main" +end + +local function component_to_endpoint(device, component) + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} + if map[component] then + return map[component] + else + return find_default_endpoint(device) + end +end + +local function get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +local function set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +local function check_field_name_updates(device) + for _, field in ipairs(updated_fields) do + if device:get_field(field.current_field_name) then + if field.updated_field_name ~= nil then + device:set_field(field.updated_field_name, device:get_field(field.current_field_name), {persist = true}) + end + device:set_field(field.current_field_name, nil) + end + end +end + +local function time_zone_offset() + return os.difftime(os.time(), os.time(os.date("!*t", os.time()))) +end + +local function iso8601_to_epoch(iso8061Timestamp) + local pattern = "(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)" + local year, month, day, hour, mins, sec = iso8061Timestamp:match(pattern) + local time_tab = { + year = tonumber(year), + month = tonumber(month), + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(mins), + sec = tonumber(sec), + isdst = false + } + return math.floor(os.time(time_tab) + time_zone_offset()) +end + +local function epoch_to_iso8601(time) + return os.date("!%Y-%m-%dT%H:%M:%SZ", time) +end + +local function tbl_contains(array, value) + for _, element in ipairs(array) do + if element == value then + return true + end + end + return false +end + +local get_total = function(map) + local total_value = 0 + if type(map) == "table" then + for _, value in pairs(map) do + if type(value) == "number" then + total_value = total_value + value + end + end + else + log.debug("get_total: 'map' should be of type table") + end + return total_value +end + +-- MAPS -- +local EVSE_STATE_ENUM_MAP = { + -- Since PLUGGED_IN_DISCHARGING is not to be supported, it is not checked. + [clusters.EnergyEvse.types.StateEnum.NOT_PLUGGED_IN] = capabilities.evseState.state.notPluggedIn, + [clusters.EnergyEvse.types.StateEnum.PLUGGED_IN_NO_DEMAND] = capabilities.evseState.state.pluggedInNoDemand, + [clusters.EnergyEvse.types.StateEnum.PLUGGED_IN_DEMAND] = capabilities.evseState.state.pluggedInDemand, + [clusters.EnergyEvse.types.StateEnum.PLUGGED_IN_CHARGING] = capabilities.evseState.state.pluggedInCharging, + [clusters.EnergyEvse.types.StateEnum.SESSION_ENDING] = capabilities.evseState.state.sessionEnding, + [clusters.EnergyEvse.types.StateEnum.FAULT] = capabilities.evseState.state.fault, +} + +local EVSE_SUPPLY_STATE_ENUM_MAP = { + [clusters.EnergyEvse.types.SupplyStateEnum.DISABLED] = capabilities.evseState.supplyState.disabled, + [clusters.EnergyEvse.types.SupplyStateEnum.CHARGING_ENABLED] = capabilities.evseState.supplyState.chargingEnabled, + [clusters.EnergyEvse.types.SupplyStateEnum.DISCHARGING_ENABLED] = capabilities.evseState.supplyState.dischargingEnabled, + [clusters.EnergyEvse.types.SupplyStateEnum.DISABLED_ERROR] = capabilities.evseState.supplyState.disabledError, + [clusters.EnergyEvse.types.SupplyStateEnum.DISABLED_DIAGNOSTICS] = capabilities.evseState.supplyState.disabledDiagnostics, +} + +local EVSE_FAULT_STATE_ENUM_MAP = { + [clusters.EnergyEvse.types.FaultStateEnum.NO_ERROR] = capabilities.evseState.faultState.noError, + [clusters.EnergyEvse.types.FaultStateEnum.METER_FAILURE] = capabilities.evseState.faultState.meterFailure, + [clusters.EnergyEvse.types.FaultStateEnum.OVER_VOLTAGE] = capabilities.evseState.faultState.overVoltage, + [clusters.EnergyEvse.types.FaultStateEnum.UNDER_VOLTAGE] = capabilities.evseState.faultState.underVoltage, + [clusters.EnergyEvse.types.FaultStateEnum.OVER_CURRENT] = capabilities.evseState.faultState.overCurrent, + [clusters.EnergyEvse.types.FaultStateEnum.CONTACT_WET_FAILURE] = capabilities.evseState.faultState.contactWetFailure, + [clusters.EnergyEvse.types.FaultStateEnum.CONTACT_DRY_FAILURE] = capabilities.evseState.faultState.contactDryFailure, + [clusters.EnergyEvse.types.FaultStateEnum.GROUND_FAULT] = capabilities.evseState.faultState.groundFault, + [clusters.EnergyEvse.types.FaultStateEnum.POWER_LOSS] = capabilities.evseState.faultState.powerLoss, + [clusters.EnergyEvse.types.FaultStateEnum.POWER_QUALITY] = capabilities.evseState.faultState.powerQuality, + [clusters.EnergyEvse.types.FaultStateEnum.PILOT_SHORT_CIRCUIT] = capabilities.evseState.faultState.pilotShortCircuit, + [clusters.EnergyEvse.types.FaultStateEnum.EMERGENCY_STOP] = capabilities.evseState.faultState.emergencyStop, + [clusters.EnergyEvse.types.FaultStateEnum.EV_DISCONNECTED] = capabilities.evseState.faultState.eVDisconnected, + [clusters.EnergyEvse.types.FaultStateEnum.WRONG_POWER_SUPPLY] = capabilities.evseState.faultState.wrongPowerSupply, + [clusters.EnergyEvse.types.FaultStateEnum.LIVE_NEUTRAL_SWAP] = capabilities.evseState.faultState.liveNeutralSwap, + [clusters.EnergyEvse.types.FaultStateEnum.OVER_TEMPERATURE] = capabilities.evseState.faultState.overTemperature, + [clusters.EnergyEvse.types.FaultStateEnum.OTHER] = capabilities.evseState.faultState.other, +} + +local BATTERY_CHARGING_STATE_MAP = { + [clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING] = capabilities.chargingState.chargingState.charging, + [clusters.PowerSource.types.BatChargeStateEnum.IS_NOT_CHARGING] = capabilities.chargingState.chargingState.stopped, + [clusters.PowerSource.types.BatChargeStateEnum.IS_AT_FULL_CHARGE] = capabilities.chargingState.chargingState.fullyCharged, +} + +-- Lifecycle Handlers -- +local function device_init(driver, device) + check_field_name_updates(device) + device:subscribe() + device:set_endpoint_to_component_fn(endpoint_to_component) + device:set_component_to_endpoint_fn(component_to_endpoint) + -- emit current time by default + local evse_eps = get_endpoints_for_dt(device, EVSE_DEVICE_TYPE_ID) or {} + if #evse_eps > 0 then + local current_time = os.time() + local current_time_iso8601 = epoch_to_iso8601(current_time) + device:emit_event(capabilities.evseChargingSession.targetEndTime(current_time_iso8601)) + end + + -- device energy reporting must be handled cumulatively, periodically, or by both simulatanously. + -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. + local electrical_energy_measurement_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID) + if #electrical_energy_measurement_eps > 0 then + local cumulative_energy_eps = embedded_cluster_utils.get_endpoints( + device, + clusters.ElectricalEnergyMeasurement.ID, + {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY} + ) + if #cumulative_energy_eps == 0 then device:set_field(CUMULATIVE_REPORTS_NOT_SUPPORTED, true, {persist = false}) end + end +end + +local function device_added(driver, device) + local evse_eps = get_endpoints_for_dt(device, EVSE_DEVICE_TYPE_ID) or {} + if #evse_eps > 0 then + local electrical_sensor_eps = get_endpoints_for_dt(device, ELECTRICAL_SENSOR_DEVICE_TYPE_ID) or {} + local device_energy_mgmt_eps = get_endpoints_for_dt(device, DEVICE_ENERGY_MANAGEMENT_DEVICE_TYPE_ID) or {} + local component_to_endpoint_map = { + ["electricalSensor"] = electrical_sensor_eps[1], + ["deviceEnergyManagement"] = device_energy_mgmt_eps[1] + } + device:set_field(COMPONENT_TO_ENDPOINT_MAP, component_to_endpoint_map, { persist = true }) + end +end + +local function do_configure(driver, device) + local evse_eps = get_endpoints_for_dt(device, EVSE_DEVICE_TYPE_ID) or {} + if #evse_eps > 0 then + local power_meas_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalPowerMeasurement.ID) or {} + local energy_meas_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID) or {} + local device_energy_mgmt_eps = embedded_cluster_utils.get_endpoints(device, clusters.DeviceEnergyManagementMode) or {} + local profile_name = "evse" + + -- As per spec, at least one of the electrical energy measurement or electrical power measurement clusters are to be supported. + if #energy_meas_eps > 0 then + profile_name = profile_name .. "-energy-meas" + end + if #power_meas_eps > 0 then + profile_name = profile_name .. "-power-meas" + end + + if #device_energy_mgmt_eps > 0 then + profile_name = profile_name .. "-energy-mgmt-mode" + end + + device.log.info_with({ hub_logs = true }, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({ profile = profile_name }) + end +end + +local function info_changed(driver, device) + for cap_id, attributes in pairs(matter_driver_template.subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + device:subscribe() +end + +local function device_removed(driver, device) + device.log.info("device removed") +end + +-- Matter Handlers -- +local function charging_readiness_state_handler(driver, device, evse_state, evse_supply_state) + local event = capabilities.evseChargingSession.chargingState.stopped({state_change = true}) + if evse_supply_state.NAME == capabilities.evseState.supplyState.disabledError.NAME or + evse_supply_state.NAME == capabilities.evseState.supplyState.disabledDiagnostics.NAME or + evse_state.NAME == capabilities.evseState.state.fault.NAME then + event = capabilities.evseChargingSession.chargingState.disabled({state_change = true}) + elseif evse_supply_state.NAME == capabilities.evseState.supplyState.chargingEnabled.NAME then + event = capabilities.evseChargingSession.chargingState.charging({state_change = true}) + end + device:emit_event(event) +end + +local function evse_state_handler(driver, device, ib, response) + local evse_state = ib.data.value + local latest_supply_state = device:get_latest_state( + "main", + capabilities.evseState.ID, + capabilities.evseState.supplyState.NAME + ) + local event = EVSE_STATE_ENUM_MAP[evse_state] + if event then + device:emit_event_for_endpoint(ib.endpoint_id, event()) + charging_readiness_state_handler(driver, device, event, {NAME=latest_supply_state}) + else + log.warn("evse_state_handler invalid EVSE State: " .. evse_state) + end +end + +local function evse_supply_state_handler(driver, device, ib, response) + local evse_supply_state = ib.data.value + + local latest_evse_state = device:get_latest_state( + device:endpoint_to_component(ib.endopint_id), + capabilities.evseState.ID, + capabilities.evseState.state.NAME + ) + local event = EVSE_SUPPLY_STATE_ENUM_MAP[evse_supply_state] + if event then + device:emit_event_for_endpoint(ib.endpoint_id, event()) + charging_readiness_state_handler(driver, device, {NAME=latest_evse_state}, event) + else + log.warn("evse_supply_state_handler invalid EVSE Supply State: " .. evse_supply_state) + end +end + +local function evse_fault_state_handler(driver, device, ib, response) + local evse_fault_state = ib.data.value + local event = EVSE_FAULT_STATE_ENUM_MAP[evse_fault_state] + if event then + device:emit_event_for_endpoint(ib.endpoint_id, event()) + return + end + log.warn("Invalid EVSE fault state received: " .. evse_fault_state) +end + +local function evse_charging_enabled_until_handler(driver, device, ib, response) + local ep = ib.endpoint_id + local targetEndTime = ib.data.value + if targetEndTime ~= nil then + if targetEndTime == 0 then --if we get 0 we update with current time. + targetEndTime = os.time() + end + targetEndTime = epoch_to_iso8601(targetEndTime) + device:emit_event_for_endpoint(ep, capabilities.evseChargingSession.targetEndTime(targetEndTime)) + return + end + log.warn("Charging enabled handler received an invalid target end time, not reporting") +end + +local function evse_current_limit_handler(event) + return function(driver, device, ib, response) + local data = ib.data.value + local ep = ib.endpoint_id + if data then + device:emit_event_for_endpoint(ep, event(data)) + return + end + log.warn("Failed to emit capability for " .. event.NAME) + end +end + +local function evse_session_duration_handler(driver, device, ib, response) + local session_duration = ib.data.value + local endpoint_id = ib.endpoint_id + if session_duration then + device:emit_event_for_endpoint(endpoint_id, capabilities.evseChargingSession.sessionTime(session_duration)) + return + end + log.warn("evse_session_duration_handler received invalid EVSE Session Duration") +end + +local function evse_session_energy_charged_handler(driver, device, ib, response) + local charged_energy = ib.data.value + local endpoint_id = ib.endpoint_id + if charged_energy then + device:emit_event_for_endpoint(endpoint_id, capabilities.evseChargingSession.energyDelivered(charged_energy)) + return + end + log.warn("evse_session_energy_charged_handler received invalid EVSE Session Energy Charged") +end + +local function power_mode_handler(driver, device, ib, response) + local power_mode = ib.data.value + local endpoint_id = ib.endpoint_id + + if power_mode == clusters.ElectricalPowerMeasurement.types.PowerModeEnum.AC then + device:emit_event_for_endpoint(endpoint_id, capabilities.powerSource.powerSource.mains()) + elseif power_mode == clusters.ElectricalPowerMeasurement.types.PowerModeEnum.DC then + device:emit_event_for_endpoint(endpoint_id, capabilities.powerSource.powerSource.dc()) + else + device:emit_event_for_endpoint(endpoint_id, capabilities.powerSource.powerSource.unknown()) + end +end + +local function energy_evse_supported_modes_attr_handler(driver, device, ib, response) + local supportedEvseModes = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 11 then + clusters.EnergyEvseMode.types.ModeOptionStruct:augment_type(mode) + end + table.insert(supportedEvseModes, mode.elements.label.value) + end + set_field_for_endpoint(device, SUPPORTED_EVSE_MODES, ib.endpoint_id, supportedEvseModes, { persist = true }) + local event = capabilities.mode.supportedModes(supportedEvseModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supportedEvseModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function energy_evse_mode_attr_handler(driver, device, ib, response) + local supportedEvseModes = get_field_for_endpoint(device, SUPPORTED_EVSE_MODES, ib.endpoint_id) or {} + local currentMode = ib.data.value + for i, mode in ipairs(supportedEvseModes) do + if i - 1 == currentMode then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode)) + break + end + end +end + +local function device_energy_mgmt_supported_modes_attr_handler(driver, device, ib, response) + local supportedDeviceEnergyMgmtModes = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 12 then + clusters.DeviceEnergyManagementMode.types.ModeOptionStruct:augment_type(mode) + end + table.insert(supportedDeviceEnergyMgmtModes, mode.elements.label.value) + end + set_field_for_endpoint(device, SUPPORTED_DEVICE_ENERGY_MANAGEMENT_MODES, ib.endpoint_id, supportedDeviceEnergyMgmtModes, { persist = true }) + local event = capabilities.mode.supportedModes(supportedDeviceEnergyMgmtModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supportedDeviceEnergyMgmtModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function device_energy_mgmt_mode_attr_handler(driver, device, ib, response) + local supportedDeviceEnergyMgmtModes = get_field_for_endpoint(device, SUPPORTED_DEVICE_ENERGY_MANAGEMENT_MODES, ib.endpoint_id) or {} + local currentMode = ib.data.value + for i, mode in ipairs(supportedDeviceEnergyMgmtModes) do + if i - 1 == currentMode then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode)) + break + end + end +end + +local function report_power_consumption_to_st_energy(device, component, latest_total_imported_energy_wh) + local current_time = os.time() + + local last_report_timestamp_field = component.id == "exportedEnergy" and LAST_EXPORTED_REPORT_TIMESTAMP or LAST_IMPORTED_REPORT_TIMESTAMP + local last_time = device:get_field(last_report_timestamp_field) or 0 + -- Ensure that the previous report was sent at least 15 minutes ago + if MINIMUM_ST_ENERGY_REPORT_INTERVAL >= (current_time - last_time) then + return + end + device:set_field(last_report_timestamp_field, current_time, { persist = true }) + + -- Calculate the energy delta between reports + local energy_delta_wh = 0.0 + local previous_imported_report = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME) + if previous_imported_report and previous_imported_report.energy then + energy_delta_wh = math.max(latest_total_imported_energy_wh - previous_imported_report.energy, 0.0) + end + + -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' + device:emit_component_event(component, capabilities.powerConsumptionReport.powerConsumption({ + start = epoch_to_iso8601(last_time), + ["end"] = epoch_to_iso8601(current_time - 1), + deltaEnergy = energy_delta_wh, + energy = latest_total_imported_energy_wh + })) +end + +local function get_component_for_energy_reports(device, cumulative_import_or_export_field) + local energyMeter_component, powerConsumption_component + if cumulative_import_or_export_field == TOTAL_CUMULATIVE_ENERGY_EXPORTED then -- this is an export report + energyMeter_component = "exportedEnergy" + powerConsumption_component = "exportedEnergy" + if #get_endpoints_for_dt(device, SOLAR_POWER_DEVICE_TYPE_ID) > 0 then + energyMeter_component = "main" + powerConsumption_component = "exportedEnergy" + end + else + energyMeter_component = "main" + powerConsumption_component = "main" + if #get_endpoints_for_dt(device, BATTERY_STORAGE_DEVICE_TYPE_ID) > 0 then + energyMeter_component = "importedEnergy" + powerConsumption_component = "importedEnergy" + elseif #get_endpoints_for_dt(device, SOLAR_POWER_DEVICE_TYPE_ID) > 0 then + energyMeter_component = "N/A" -- do not send cumulative import reports for solar power + end + end + return energyMeter_component, powerConsumption_component +end + +local function energy_report_handler_factory(is_cumulative_report, cumulative_import_or_export_field) + return function(driver, device, ib, response) + if not ib.data then return + elseif version.api < 11 then clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct:augment_type(ib.data) end + + local endpoint_id = string.format(ib.endpoint_id) + local total_cumulative_energy = device:get_field(cumulative_import_or_export_field) or {} + local energy_Wh = utils.round(ib.data.elements.energy.value / 1000) -- convert mWh to Wh + + if not is_cumulative_report then + if device:get_field(CUMULATIVE_REPORTS_NOT_SUPPORTED) ~= true then + return -- if this is a periodic report and cumulative reports ARE supported by the device, ignore the report altogether. + end + energy_Wh = energy_Wh + (total_cumulative_energy[endpoint_id] or 0) -- handle the periodic report + end + total_cumulative_energy[endpoint_id] = energy_Wh -- in the case that there are multiple electrical sensors, store them in a table. + device:set_field(cumulative_import_or_export_field, total_cumulative_energy, { persist = true }) + + local summed_total_energy = get_total(total_cumulative_energy) + local energyMeter_component, powerConsumption_component = get_component_for_energy_reports(device, cumulative_import_or_export_field) + if device.profile.components[energyMeter_component] and device:supports_capability(capabilities.energyMeter) then + device:emit_component_event(device.profile.components[energyMeter_component], capabilities.energyMeter.energy({value = summed_total_energy, unit = "Wh"})) + end + if device.profile.components[powerConsumption_component] and device:supports_capability(capabilities.powerConsumptionReport) then + report_power_consumption_to_st_energy(device, device.profile.components[powerConsumption_component], summed_total_energy) + end + end +end + +local function active_power_handler(driver, device, ib, response) + local battery_storage_eps = get_endpoints_for_dt(device, BATTERY_STORAGE_DEVICE_TYPE_ID) or {} + local solar_power_eps = get_endpoints_for_dt(device, SOLAR_POWER_DEVICE_TYPE_ID) or {} + -- Consider only Solar Power / Battery Storage devices and sum up in case there are multiple endpoints. + if (tbl_contains(solar_power_eps, ib.endpoint_id) or tbl_contains(battery_storage_eps, ib.endpoint_id)) and ib.data.value then + local endpoint_id = string.format(ib.endpoint_id) + local active_power_map = device:get_field(TOTAL_ACTIVE_POWER) or {} + local watt_value = ib.data.value / 1000 + + active_power_map[endpoint_id] = watt_value + local total_active_power = get_total(active_power_map) + device:set_field(TOTAL_ACTIVE_POWER, active_power_map) + if total_active_power ~= nil then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.powerMeter.power({ value = total_active_power, unit = "W" })) + end + end +end + +local function battery_percent_remaining_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +local function battery_charge_state_handler(driver, device, ib, response) + local charging_state = ib.data.value + if BATTERY_CHARGING_STATE_MAP[charging_state] then + device:emit_event(BATTERY_CHARGING_STATE_MAP[charging_state]()) + else + device:emit_event(capabilities.chargingState.chargingState.error()) + end +end + +-- Capability Handlers -- +local function get_latest_charging_parameters(device) + local min_charging_current = device:get_latest_state("main", capabilities.evseChargingSession.ID, + capabilities.evseChargingSession.minCurrent.NAME) or 0 + local max_charging_current = device:get_latest_state("main", capabilities.evseChargingSession.ID, + capabilities.evseChargingSession.maxCurrent.NAME) + local target_end_time_iso8601 = device:get_latest_state("main", capabilities.evseChargingSession.ID, + capabilities.evseChargingSession.targetEndTime.NAME) + return min_charging_current, max_charging_current, target_end_time_iso8601 +end + +local function handle_enable_charging(driver, device, cmd) + local ep = component_to_endpoint(device, cmd.component) + local default_min_current, default_max_current, default_charging_enabled_until_iso8601 = get_latest_charging_parameters( + device) + local charging_enabled_until_iso8601 = cmd.args.time or default_charging_enabled_until_iso8601 + local minimum_current = cmd.args.minCurrent or default_min_current + local maximum_current = cmd.args.maxCurrent or default_max_current + local charging_enabled_until_epoch_s = iso8601_to_epoch(charging_enabled_until_iso8601) + device:send(clusters.EnergyEvse.commands.EnableCharging(device, ep, charging_enabled_until_epoch_s, minimum_current, + maximum_current)) +end + +local handle_set_charging_parameters = function(cap, arg) + return function(driver, device, cmd) + if arg == "maxCurrent" and cmd.args[arg] > MAX_CHARGING_CURRENT_CONSTRAINT then + cmd.args[arg] = MAX_CHARGING_CURRENT_CONSTRAINT + log.warn_with({hub_logs=true}, "Clipping Max Current as it cannot be greater than 80A") + end + local capability_event = cap(cmd.args[arg]) + log.info("Setting value " .. (cmd.args[arg]) .. " for " .. (cap.NAME)) + device:emit_event(capability_event) + end +end + +local function handle_disable_charging(driver, device, cmd) + local ep = component_to_endpoint(device, cmd.component) + device:send(clusters.EnergyEvse.commands.Disable(device, ep)) +end + +local function handle_set_mode_command(driver, device, cmd) + local set_mode_handlers = { + ["main"] = function( ... ) + local ep = component_to_endpoint(device, cmd.component) + local supportedEvseModes = get_field_for_endpoint(device, SUPPORTED_EVSE_MODES, ep) or {} + for i, mode in ipairs(supportedEvseModes) do + if cmd.args.mode == mode then + device:send(clusters.EnergyEvseMode.commands.ChangeToMode(device, ep, i - 1)) + return + end + end + log.warn("Received request to set unsupported mode for EnergyEvseMode.") + end, + ["deviceEnergyManagement"] = function( ... ) + local ep = component_to_endpoint(device, cmd.component) + local supportedDeviceEnergyMgmtModes = get_field_for_endpoint(device, SUPPORTED_DEVICE_ENERGY_MANAGEMENT_MODES, ep) or {} + for i, mode in ipairs(supportedDeviceEnergyMgmtModes) do + if cmd.args.mode == mode then + device:send(clusters.DeviceEnergyManagementMode.commands.ChangeToMode(device, ep, i - 1)) + return + end + end + log.warn("Received request to set unsupported mode for DeviceEnergyManagementMode.") + end + } + set_mode_handlers[cmd.component]() +end + +matter_driver_template = { + NAME = "matter-energy", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + infoChanged = info_changed, + removed = device_removed + }, + matter_handlers = { + attr = { + [clusters.EnergyEvse.ID] = { + [clusters.EnergyEvse.attributes.State.ID] = evse_state_handler, + [clusters.EnergyEvse.attributes.SupplyState.ID] = evse_supply_state_handler, + [clusters.EnergyEvse.attributes.FaultState.ID] = evse_fault_state_handler, + [clusters.EnergyEvse.attributes.ChargingEnabledUntil.ID] = evse_charging_enabled_until_handler, + [clusters.EnergyEvse.attributes.MinimumChargeCurrent.ID] = evse_current_limit_handler(capabilities + .evseChargingSession.minCurrent), + [clusters.EnergyEvse.attributes.MaximumChargeCurrent.ID] = evse_current_limit_handler(capabilities + .evseChargingSession.maxCurrent), + [clusters.EnergyEvse.attributes.SessionDuration.ID] = evse_session_duration_handler, + [clusters.EnergyEvse.attributes.SessionEnergyCharged.ID] = evse_session_energy_charged_handler, + }, + [clusters.ElectricalPowerMeasurement.ID] = { + [clusters.ElectricalPowerMeasurement.attributes.PowerMode.ID] = power_mode_handler, + [clusters.ElectricalPowerMeasurement.attributes.ActivePower.ID] = active_power_handler, + }, + [clusters.EnergyEvseMode.ID] = { + [clusters.EnergyEvseMode.attributes.SupportedModes.ID] = energy_evse_supported_modes_attr_handler, + [clusters.EnergyEvseMode.attributes.CurrentMode.ID] = energy_evse_mode_attr_handler, + }, + [clusters.DeviceEnergyManagementMode.ID] = { + [clusters.DeviceEnergyManagementMode.attributes.SupportedModes.ID] = device_energy_mgmt_supported_modes_attr_handler, + [clusters.DeviceEnergyManagementMode.attributes.CurrentMode.ID] = device_energy_mgmt_mode_attr_handler, + }, + [clusters.ElectricalEnergyMeasurement.ID] = { + [clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported.ID] = energy_report_handler_factory(true, TOTAL_CUMULATIVE_ENERGY_IMPORTED), + [clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported.ID] = energy_report_handler_factory(false, TOTAL_CUMULATIVE_ENERGY_IMPORTED), + [clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyExported.ID] = energy_report_handler_factory(true, TOTAL_CUMULATIVE_ENERGY_EXPORTED), + [clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyExported.ID] = energy_report_handler_factory(false, TOTAL_CUMULATIVE_ENERGY_EXPORTED), + }, + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.BatPercentRemaining.ID] = battery_percent_remaining_attr_handler, + [clusters.PowerSource.attributes.BatChargeState.ID] = battery_charge_state_handler + } + }, + }, + subscribed_attributes = { + [capabilities.evseState.ID] = { + clusters.EnergyEvse.attributes.State, + clusters.EnergyEvse.attributes.SupplyState, + clusters.EnergyEvse.attributes.FaultState, + }, + [capabilities.evseChargingSession.ID] = { + clusters.EnergyEvse.attributes.ChargingEnabledUntil, + clusters.EnergyEvse.attributes.MinimumChargeCurrent, + clusters.EnergyEvse.attributes.MaximumChargeCurrent, + clusters.EnergyEvse.attributes.SessionDuration, + clusters.EnergyEvse.attributes.SessionEnergyCharged, + }, + [capabilities.powerSource.ID] = { + clusters.ElectricalPowerMeasurement.attributes.PowerMode, + }, + [capabilities.powerConsumptionReport.ID] = { + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyExported, + }, + [capabilities.mode.ID] = { + clusters.EnergyEvseMode.attributes.SupportedModes, + clusters.EnergyEvseMode.attributes.CurrentMode, + clusters.DeviceEnergyManagementMode.attributes.CurrentMode, + clusters.DeviceEnergyManagementMode.attributes.SupportedModes + }, + [capabilities.powerMeter.ID] = { + clusters.ElectricalPowerMeasurement.attributes.ActivePower + }, + [capabilities.energyMeter.ID] = { + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyExported + }, + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining + }, + [capabilities.chargingState.ID] = { + clusters.PowerSource.attributes.BatChargeState + } + }, + capability_handlers = { + [capabilities.evseChargingSession.ID] = { + [capabilities.evseChargingSession.commands.enableCharging.NAME] = handle_enable_charging, + [capabilities.evseChargingSession.commands.disableCharging.NAME] = handle_disable_charging, + [capabilities.evseChargingSession.commands.setTargetEndTime.NAME] = handle_set_charging_parameters(capabilities.evseChargingSession.targetEndTime, "time"), + [capabilities.evseChargingSession.commands.setMinCurrent.NAME] = handle_set_charging_parameters(capabilities.evseChargingSession.minCurrent, "minCurrent"), + [capabilities.evseChargingSession.commands.setMaxCurrent.NAME] = handle_set_charging_parameters(capabilities.evseChargingSession.maxCurrent, "maxCurrent"), + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = handle_set_mode_command, + }, + }, + supported_capabilities = { + capabilities.evseState, + capabilities.evseChargingSession, + capabilities.powerSource, + capabilities.powerConsumptionReport, + capabilities.mode, + capabilities.powerMeter, + capabilities.energyMeter, + capabilities.battery, + capabilities.chargingState + }, + shared_device_thread_enabled = true, +} + +local matter_driver = MatterDriver("matter-energy", matter_driver_template) +log.info(string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) +matter_driver:run() diff --git a/drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua b/drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua new file mode 100644 index 0000000000..329daa6bd7 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua @@ -0,0 +1,308 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local version = require "version" + +local BATTERY_STORAGE_EP = 20 + +local BATTERY_STORAGE_DEVICE_TYPE_ID = 0x0018 +local POWER_SOURCE_DEVICE_TYPE_ID = 0x0011 +local ELECTRICAL_SENSOR_DEVICE_TYPE_ID = 0x0510 + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("battery-storage.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = BATTERY_STORAGE_EP, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 15 }, -- ALL + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 7 }, -- WIRED, BAT & RECHG + }, + device_types = { + { device_type_id = BATTERY_STORAGE_DEVICE_TYPE_ID, device_type_revision = 1 }, + { device_type_id = ELECTRICAL_SENSOR_DEVICE_TYPE_ID, device_type_revision = 1 }, + { device_type_id = POWER_SOURCE_DEVICE_TYPE_ID, device_type_revision = 1 } + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyExported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.PowerSource.attributes.BatPercentRemaining, + clusters.PowerSource.attributes.BatChargeState + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Battery percentage must reported properly", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device, BATTERY_STORAGE_EP, 150 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end, + { + min_api_version = 17 + } +) +test.register_coroutine_test( + "Battery charge state must reported properly", + function() + test.socket.matter:__set_channel_ordering("strict") + test.socket.capability:__set_channel_ordering("strict") + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatChargeState:build_test_report_data( + mock_device, BATTERY_STORAGE_EP, clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.chargingState.chargingState.charging()) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatChargeState:build_test_report_data( + mock_device, BATTERY_STORAGE_EP, clusters.PowerSource.types.BatChargeStateEnum.IS_AT_FULL_CHARGE + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.chargingState.chargingState.fullyCharged()) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatChargeState:build_test_report_data( + mock_device, BATTERY_STORAGE_EP, clusters.PowerSource.types.BatChargeStateEnum.IS_NOT_CHARGING + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.chargingState.chargingState.stopped()) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatChargeState:build_test_report_data( + mock_device, BATTERY_STORAGE_EP, 10 -- Error scenario or any other state + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.chargingState.chargingState.error()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Appropriate powerMeter capability events must be sent in 'W' on receiving ActivePower events", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(mock_device, + BATTERY_STORAGE_EP, + 30000) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 30.0, unit = "W" }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Appropriate powerMeter capability events must be sent in 'W' on receiving ActivePower events", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(mock_device, + BATTERY_STORAGE_EP, + 30000) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 30.0, unit = "W" }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ensure the total cumulative energy exported powerConsumption for both endpoints is reported every 15 minutes", + function() + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyImported:build_test_report_data(mock_device, + BATTERY_STORAGE_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 100000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --100Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("importedEnergy", + capabilities.energyMeter.energy({ + value = 100, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("importedEnergy", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 100 + }) + ) + ) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyExported:build_test_report_data(mock_device, + BATTERY_STORAGE_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 400000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --400Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("exportedEnergy", + capabilities.energyMeter.energy({ + value = 400, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("exportedEnergy", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 400 + }) + ) + ) + + test.wait_for_events() + test.mock_time.advance_time(2000) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyImported:build_test_report_data(mock_device, + BATTERY_STORAGE_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 200000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --200Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("importedEnergy", + capabilities.energyMeter.energy({ + value = 200, unit = "Wh" + }))) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("importedEnergy", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 200, + deltaEnergy = 0.0, + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:48:20Z" + })) + ) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyExported:build_test_report_data(mock_device, + BATTERY_STORAGE_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 400000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --400Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("exportedEnergy", + capabilities.energyMeter.energy({ + value = 400, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("exportedEnergy", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 400, + deltaEnergy = 0.0, + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:48:20Z" + })) + ) + end, + { + test_init = function() + test_init() + end, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-energy/src/test/test_evse.lua b/drivers/SmartThings/matter-energy/src/test/test_evse.lua new file mode 100644 index 0000000000..5614b0be18 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/test/test_evse.lua @@ -0,0 +1,548 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local EVSE_EP = 1 +local ELECTRICAL_SENSOR_EP = 2 +local DEVICE_ENERGY_MANAGEMENT_DEVICE_EP = 3 + +clusters.EnergyEvse = require "EnergyEvse" +clusters.EnergyEvseMode = require "EnergyEvseMode" +clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" +clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" +clusters.DeviceEnergyManagementMode = require "DeviceEnergyManagementMode" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("evse-power-meas-energy-mgmt-mode.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = EVSE_EP, + clusters = { + { cluster_id = clusters.EnergyEvse.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.EnergyEvseMode.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x050C, device_type_revision = 1 } -- EVSE + } + }, + { + endpoint_id = ELECTRICAL_SENSOR_EP, + clusters = { + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 } -- Electrical Sensor + } + }, + { + endpoint_id = DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, + clusters = { + { cluster_id = clusters.DeviceEnergyManagementMode.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x050D, device_type_revision = 1 } -- Device Energy Management + } + }, + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.EnergyEvse.attributes.State, + clusters.EnergyEvse.attributes.SupplyState, + clusters.EnergyEvse.attributes.FaultState, + clusters.EnergyEvse.attributes.ChargingEnabledUntil, + clusters.EnergyEvse.attributes.MinimumChargeCurrent, + clusters.EnergyEvse.attributes.MaximumChargeCurrent, + clusters.EnergyEvse.attributes.SessionDuration, + clusters.EnergyEvse.attributes.SessionEnergyCharged, + clusters.ElectricalPowerMeasurement.attributes.PowerMode, + clusters.EnergyEvseMode.attributes.SupportedModes, + clusters.EnergyEvseMode.attributes.CurrentMode, + clusters.DeviceEnergyManagementMode.attributes.CurrentMode, + clusters.DeviceEnergyManagementMode.attributes.SupportedModes, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.evseChargingSession.targetEndTime("1970-01-01T00:00:00Z"))) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ profile = "evse-power-meas" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Assert component to endpoint map", + function() + local component_to_endpoint_map = mock_device:get_field("__component_to_endpoint_map") + assert(component_to_endpoint_map["electricalSensor"] == ELECTRICAL_SENSOR_EP, "Electrical Sensor Endpoint must be 2") + assert(component_to_endpoint_map["deviceEnergyManagement"] == DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, + "Device Energy Management Endpoint must be 3") + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse Supply State must trigger appropriate evseState supplystate capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.SupplyState:build_test_report_data(mock_device, EVSE_EP, + clusters.EnergyEvse.attributes.SupplyState.CHARGING_ENABLED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseState.supplyState.chargingEnabled()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.chargingState.charging({state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse State must trigger appropriate evseState EvseState capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.SupplyState:build_test_report_data(mock_device, EVSE_EP, + clusters.EnergyEvse.attributes.SupplyState.CHARGING_ENABLED) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseState.supplyState.chargingEnabled()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.chargingState.charging({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.State:build_test_report_data(mock_device, EVSE_EP, + clusters.EnergyEvse.attributes.State.PLUGGED_IN_DEMAND) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseState.state.pluggedInDemand()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.chargingState.charging({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse Fault State must trigger appropriate evseState faultState capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.FaultState:build_test_report_data(mock_device, EVSE_EP, + clusters.EnergyEvse.attributes.FaultState.GROUND_FAULT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseState.faultState.groundFault()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse ChargingEnabledUntil in epoch must trigger appropriate evseChargingSession targetEndTime capability event in iso8601 format", --1724399242 in epoch to 2024-08-23T07:47:22Z in iso8601 + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.ChargingEnabledUntil:build_test_report_data(mock_device, EVSE_EP, 1724399242) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.targetEndTime("2024-08-23T07:47:22Z")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse MinimumChargeCurrent constraint must trigger appropriate evseChargingSession minCurrent capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.MinimumChargeCurrent:build_test_report_data(mock_device, EVSE_EP, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.minCurrent(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse MaximumChargeCurrent constraint must trigger appropriate evseChargingSession maxCurrent capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.MaximumChargeCurrent:build_test_report_data(mock_device, EVSE_EP, 10000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.maxCurrent(10000)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse SessionDuration must trigger appropriate evseChargingSession sessionTime capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.SessionDuration:build_test_report_data(mock_device, EVSE_EP, 9000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.sessionTime(9000)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvse SessionEnergyCharged must trigger appropriate evseChargingSession energyDelivered capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvse.attributes.SessionEnergyCharged:build_test_report_data(mock_device, EVSE_EP, 900000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.evseChargingSession.energyDelivered(900000)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ElectricalPowerMeasurement PowerMode must trigger appropriate powerSource capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.PowerMode:build_test_report_data(mock_device, ELECTRICAL_SENSOR_EP, + clusters.ElectricalPowerMeasurement.attributes.PowerMode.AC) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("electricalSensor", + capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EnergyEvseMode SupportedModes must be registered. CurrentMode must trigger approriate mode capability event. Command to setMode should trigger appropriate changeToMode matter command", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvseMode.attributes.SupportedModes:build_test_report_data(mock_device, EVSE_EP, { + clusters.EnergyEvseMode.types.ModeOptionStruct({ + ["label"] = "Auto-Scheduled", + ["mode"] = 0, + ["mode_tags"] = { + clusters.EnergyEvseMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.EnergyEvseMode.types.ModeOptionStruct({ + ["label"] = "Manual", + ["mode"] = 1, + ["mode_tags"] = { + clusters.EnergyEvseMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }) + }) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.supportedModes({ "Auto-Scheduled", "Manual" }, { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.supportedArguments({ "Auto-Scheduled", "Manual" }, { visibility = { displayed = false } })) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.EnergyEvseMode.attributes.CurrentMode:build_test_report_data(mock_device, EVSE_EP, 1) --1 is the index for Manual EnergyEvse mode + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.mode("Manual")) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "Auto-Scheduled" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.EnergyEvseMode.commands.ChangeToMode(mock_device, EVSE_EP, 0) --Index is Auto-Scheduled + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "DeviceEnergyManagementMode SupportedModes must be registered. CurrentMode must trigger approriate mode capability event. Command to setMode should trigger appropriate changeToMode matter command", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DeviceEnergyManagementMode.attributes.SupportedModes:build_test_report_data(mock_device, + DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, { + clusters.DeviceEnergyManagementMode.types.ModeOptionStruct({ + ["label"] = "Grid Energy Management", + ["mode"] = 0, + ["mode_tags"] = { + clusters.DeviceEnergyManagementMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.DeviceEnergyManagementMode.types.ModeOptionStruct({ + ["label"] = "Home Energy Management", + ["mode"] = 1, + ["mode_tags"] = { + clusters.DeviceEnergyManagementMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }), + clusters.DeviceEnergyManagementMode.types.ModeOptionStruct({ + ["label"] = "Full Energy Management", + ["mode"] = 2, + ["mode_tags"] = { + clusters.DeviceEnergyManagementMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 2 }) + } + }) + }) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("deviceEnergyManagement", + capabilities.mode.supportedModes( + { "Grid Energy Management", "Home Energy Management", "Full Energy Management" }, + { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("deviceEnergyManagement", + capabilities.mode.supportedArguments( + { "Grid Energy Management", "Home Energy Management", "Full Energy Management" }, + { visibility = { displayed = false } })) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DeviceEnergyManagementMode.attributes.CurrentMode:build_test_report_data(mock_device, + DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, 1) --1 is the index for Home Energy Management mode + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("deviceEnergyManagement", + capabilities.mode.mode("Home Energy Management")) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "deviceEnergyManagement", command = "setMode", args = { "Full Energy Management" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.DeviceEnergyManagementMode.commands.ChangeToMode(mock_device, DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, 2) --Index is Full Energy Management + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "deviceEnergyManagement", command = "setMode", args = { "Grid Energy Management" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.DeviceEnergyManagementMode.commands.ChangeToMode(mock_device, DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, 0) --Index is Grid Energy Management + } + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua b/drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua new file mode 100644 index 0000000000..f3b3b6c700 --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua @@ -0,0 +1,159 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local EVSE_EP = 10 +local ELECTRICAL_SENSOR_EP_ONE = 20 +local ELECTRICAL_SENSOR_EP_TWO = 30 + +clusters.EnergyEvse = require "EnergyEvse" +clusters.EnergyEvseMode = require "EnergyEvseMode" +clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" +clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" +clusters.DeviceEnergyManagementMode = require "DeviceEnergyManagementMode" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("evse-energy-meas.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = EVSE_EP, + clusters = { + { cluster_id = clusters.EnergyEvse.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.EnergyEvseMode.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x050C, device_type_revision = 1 } -- EVSE + } + }, + { + endpoint_id = ELECTRICAL_SENSOR_EP_ONE, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 5 }, --CUME & IMPE + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 } -- Electrical Sensor + } + }, + { + endpoint_id = ELECTRICAL_SENSOR_EP_TWO, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 5 }, --CUME & IMPE + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 } -- Electrical Sensor + } + }, + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.EnergyEvse.attributes.State, + clusters.EnergyEvse.attributes.SupplyState, + clusters.EnergyEvse.attributes.FaultState, + clusters.EnergyEvse.attributes.ChargingEnabledUntil, + clusters.EnergyEvse.attributes.MinimumChargeCurrent, + clusters.EnergyEvse.attributes.MaximumChargeCurrent, + clusters.EnergyEvse.attributes.SessionDuration, + clusters.EnergyEvse.attributes.SessionEnergyCharged, + clusters.EnergyEvseMode.attributes.SupportedModes, + clusters.EnergyEvseMode.attributes.CurrentMode, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyExported, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.evseChargingSession.targetEndTime("1970-01-01T00:00:00Z"))) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ profile = "evse-energy-meas" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Assert profile applied over doConfigure", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "evse-energy-meas" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ensure the total accumulated powerConsumption for both endpoints is reported", + function() + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyImported:build_test_report_data(mock_device, + ELECTRICAL_SENSOR_EP_ONE, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 100000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0 })) }) --100Wh + + test.wait_for_events() + test.mock_time.advance_time(901) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyImported:build_test_report_data(mock_device, + ELECTRICAL_SENSOR_EP_TWO, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 150000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0 })) }) --150Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 250, + deltaEnergy = 0.0, + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z" + })) + ) + end, + { + test_init = function() + test_init() + end, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-energy/src/test/test_solar_power.lua b/drivers/SmartThings/matter-energy/src/test/test_solar_power.lua new file mode 100644 index 0000000000..8e4e3c4e9a --- /dev/null +++ b/drivers/SmartThings/matter-energy/src/test/test_solar_power.lua @@ -0,0 +1,204 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local version = require "version" + +local SOLAR_POWER_EP_ONE = 20 +local SOLAR_POWER_EP_TWO = 30 + +local SOLAR_POWER_DEVICE_TYPE_ID = 0x0017 +local ELECTRICAL_SENSOR_DEVICE_TYPE_ID = 0x0510 + +if version.api < 11 then +clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" +clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("solar-power.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = SOLAR_POWER_EP_ONE, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER" , feature_map = 6}, --CUME & PERE + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = SOLAR_POWER_DEVICE_TYPE_ID, device_type_revision = 1 }, -- SOLAR POWER + { device_type_id = ELECTRICAL_SENSOR_DEVICE_TYPE_ID, device_type_revision = 1 } -- ELECTRICAL_SENSOR + } + }, + { + endpoint_id = SOLAR_POWER_EP_TWO, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER" , feature_map = 6}, --CUME & PERE + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = SOLAR_POWER_DEVICE_TYPE_ID, device_type_revision = 1 }, -- SOLAR POWER + { device_type_id = ELECTRICAL_SENSOR_DEVICE_TYPE_ID, device_type_revision = 1 } -- ELECTRICAL_SENSOR + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyExported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Appropriate powerMeter capability events must be sent in 'W' on receiving ActivePower events", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(mock_device, + SOLAR_POWER_EP_ONE, + 15000) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 15.0, unit = "W" }))) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(mock_device, + SOLAR_POWER_EP_TWO, + 16000) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 31.0, unit = "W" }))) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(mock_device, + SOLAR_POWER_EP_TWO, + 20000) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 35.0, unit = "W" }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ensure the total cumulative energy exported powerConsumption for both endpoints is reported", + function() + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyExported:build_test_report_data(mock_device, + SOLAR_POWER_EP_ONE, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 100000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --100Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 100, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("exportedEnergy", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 100, + deltaEnergy = 0.0, + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z" + }) + ) + ) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyExported:build_test_report_data(mock_device, + SOLAR_POWER_EP_TWO, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 150000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --150Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 250, unit = "Wh" + })) + ) + end, + { + test_init = function() + test_init() + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ensure energyMeter is not reported incase we recieve CumulativeEnergyImported events for Solar Power device", + function() + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyExported:build_test_report_data(mock_device, + SOLAR_POWER_EP_ONE, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 100000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --100Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 100, unit = "Wh" + })) + ) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyImported:build_test_report_data(mock_device, + SOLAR_POWER_EP_ONE, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 100000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --100Wh + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-hrap/config.yml b/drivers/SmartThings/matter-hrap/config.yml new file mode 100644 index 0000000000..21d79bc8ec --- /dev/null +++ b/drivers/SmartThings/matter-hrap/config.yml @@ -0,0 +1,6 @@ +name: 'Matter HRAP' +packageKey: 'matter-hrap' +permissions: + matter: {} +description: "SmartThings driver for Matter HRAP devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/matter-hrap/fingerprints.yml b/drivers/SmartThings/matter-hrap/fingerprints.yml new file mode 100644 index 0000000000..7b768709c2 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/fingerprints.yml @@ -0,0 +1,11 @@ +matterGeneric: + - id: "matter/network-manager" + deviceLabel: Matter Network Infrastructure Manager + deviceTypes: + - id: 0x0090 # NIM + deviceProfileName: network-infrastructure-manager + - id: "matter/thread-border-router" + deviceLabel: Matter Thread Border Router + deviceTypes: + - id: 0x0091 # TBR + deviceProfileName: thread-border-router diff --git a/drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml b/drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml new file mode 100644 index 0000000000..725676c502 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml @@ -0,0 +1,16 @@ +name: network-infrastructure-manager +components: +- id: main + capabilities: + - id: threadBorderRouter + version: 1 + - id: threadNetwork + version: 1 + - id: wifiInformation + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Networking diff --git a/drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml b/drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml new file mode 100644 index 0000000000..b879dd2e76 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml @@ -0,0 +1,14 @@ +name: thread-border-router +components: +- id: main + capabilities: + - id: threadBorderRouter + version: 1 + - id: threadNetwork + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Networking diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua new file mode 100644 index 0000000000..0cf8facc53 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua @@ -0,0 +1,103 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local DatasetResponse = {} + +DatasetResponse.NAME = "DatasetResponse" +DatasetResponse.ID = 0x0002 +DatasetResponse.field_defs = { + { + name = "dataset", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.OctetString1", + }, +} + +function DatasetResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function DatasetResponse:build_test_command_response(device, endpoint_id, dataset, interaction_status) + local function init(self, device, endpoint_id, dataset) + local out = {} + local args = {dataset} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = DatasetResponse, + __tostring = DatasetResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + false, + true + ) + end + local self_request = init(self, device, endpoint_id, dataset) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function DatasetResponse:init() + return nil +end + +function DatasetResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function DatasetResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(DatasetResponse, {__call = DatasetResponse.init}) + +return DatasetResponse diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua new file mode 100644 index 0000000000..3c8bee49fa --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua @@ -0,0 +1,23 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("ThreadBorderRouterManagement.client.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local ThreadBorderRouterManagementClientCommands = {} + +function ThreadBorderRouterManagementClientCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ThreadBorderRouterManagementClientCommands, command_mt) + +return ThreadBorderRouterManagementClientCommands + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua new file mode 100644 index 0000000000..3b3485d03d --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua @@ -0,0 +1,148 @@ +local cluster_base = require "st.matter.cluster_base" +local ThreadBorderRouterManagementServerAttributes = require "ThreadBorderRouterManagement.server.attributes" +local ThreadBorderRouterManagementServerCommands = require "ThreadBorderRouterManagement.server.commands" +local ThreadBorderRouterManagementClientCommands = require "ThreadBorderRouterManagement.client.commands" +local ThreadBorderRouterManagementTypes = require "ThreadBorderRouterManagement.types" + +local ThreadBorderRouterManagement = {} + +ThreadBorderRouterManagement.ID = 0x0452 +ThreadBorderRouterManagement.NAME = "ThreadBorderRouterManagement" +ThreadBorderRouterManagement.server = {} +ThreadBorderRouterManagement.client = {} +ThreadBorderRouterManagement.server.attributes = ThreadBorderRouterManagementServerAttributes:set_parent_cluster(ThreadBorderRouterManagement) +ThreadBorderRouterManagement.server.commands = ThreadBorderRouterManagementServerCommands:set_parent_cluster(ThreadBorderRouterManagement) +ThreadBorderRouterManagement.client.commands = ThreadBorderRouterManagementClientCommands:set_parent_cluster(ThreadBorderRouterManagement) +ThreadBorderRouterManagement.types = ThreadBorderRouterManagementTypes + +function ThreadBorderRouterManagement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "BorderRouterName", + [0x0001] = "BorderAgentID", + [0x0002] = "ThreadVersion", + [0x0003] = "InterfaceEnabled", + [0x0004] = "ActiveDatasetTimestamp", + [0x0005] = "PendingDatasetTimestamp", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ThreadBorderRouterManagement:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "GetActiveDatasetRequest", + [0x0001] = "GetPendingDatasetRequest", + [0x0003] = "SetActiveDatasetRequest", + [0x0004] = "SetPendingDatasetRequest", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function ThreadBorderRouterManagement:get_client_command_by_id(command_id) + local client_id_map = { + [0x0002] = "DatasetResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +ThreadBorderRouterManagement.attribute_direction_map = { + ["BorderRouterName"] = "server", + ["BorderAgentID"] = "server", + ["ThreadVersion"] = "server", + ["InterfaceEnabled"] = "server", + ["ActiveDatasetTimestamp"] = "server", + ["PendingDatasetTimestamp"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.server.attributes") + if has_aliases then + for alias, _ in pairs(aliases) do + ThreadBorderRouterManagement.attribute_direction_map[alias] = "server" + end + end +end + +ThreadBorderRouterManagement.command_direction_map = { + ["GetActiveDatasetRequest"] = "server", + ["GetPendingDatasetRequest"] = "server", + ["SetActiveDatasetRequest"] = "server", + ["SetPendingDatasetRequest"] = "server", + ["DatasetResponse"] = "client", +} + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.server.commands") + if has_aliases then + for alias, _ in pairs(aliases) do + ThreadBorderRouterManagement.command_direction_map[alias] = "server" + end + end +end + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.client.commands") + if has_aliases then + for alias, _ in pairs(aliases) do + ThreadBorderRouterManagement.command_direction_map[alias] = "client" + end + end +end + +ThreadBorderRouterManagement.FeatureMap = ThreadBorderRouterManagement.types.Feature + +function ThreadBorderRouterManagement.are_features_supported(feature, feature_map) + if (ThreadBorderRouterManagement.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ThreadBorderRouterManagement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ThreadBorderRouterManagement.NAME)) + end + return ThreadBorderRouterManagement[direction].attributes[key] +end +ThreadBorderRouterManagement.attributes = {} +setmetatable(ThreadBorderRouterManagement.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ThreadBorderRouterManagement.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ThreadBorderRouterManagement.NAME)) + end + return ThreadBorderRouterManagement[direction].commands[key] +end +ThreadBorderRouterManagement.commands = {} +setmetatable(ThreadBorderRouterManagement.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return ThreadBorderRouterManagement.server.events[key] +end +ThreadBorderRouterManagement.events = {} +setmetatable(ThreadBorderRouterManagement.events, event_helper_mt) + +setmetatable(ThreadBorderRouterManagement, {__index = cluster_base}) + +return ThreadBorderRouterManagement + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua new file mode 100644 index 0000000000..b0dc67b590 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ActiveDatasetTimestamp = { + ID = 0x0004, + NAME = "ActiveDatasetTimestamp", + base_type = require "st.matter.data_types.Uint64", +} + +function ActiveDatasetTimestamp:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ActiveDatasetTimestamp:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActiveDatasetTimestamp:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActiveDatasetTimestamp:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ActiveDatasetTimestamp:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ActiveDatasetTimestamp:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ActiveDatasetTimestamp, {__call = ActiveDatasetTimestamp.new_value, __index = ActiveDatasetTimestamp.base_type}) +return ActiveDatasetTimestamp + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua new file mode 100644 index 0000000000..33fd11c111 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua @@ -0,0 +1,69 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local BorderRouterName = { + ID = 0x0000, + NAME = "BorderRouterName", + base_type = require "st.matter.data_types.UTF8String1", +} + +function BorderRouterName:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function BorderRouterName:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function BorderRouterName:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function BorderRouterName:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function BorderRouterName:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function BorderRouterName:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(BorderRouterName, {__call = BorderRouterName.new_value, __index = BorderRouterName.base_type}) +return BorderRouterName + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua new file mode 100644 index 0000000000..c2676bfaf0 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua @@ -0,0 +1,69 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local InterfaceEnabled = { + ID = 0x0003, + NAME = "InterfaceEnabled", + base_type = require "st.matter.data_types.Boolean", +} + +function InterfaceEnabled:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function InterfaceEnabled:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function InterfaceEnabled:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function InterfaceEnabled:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function InterfaceEnabled:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function InterfaceEnabled:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(InterfaceEnabled, {__call = InterfaceEnabled.new_value, __index = InterfaceEnabled.base_type}) +return InterfaceEnabled + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua new file mode 100644 index 0000000000..6630c863e9 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ThreadVersion = { + ID = 0x0002, + NAME = "ThreadVersion", + base_type = require "st.matter.data_types.Uint16", +} + +function ThreadVersion:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ThreadVersion:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ThreadVersion:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ThreadVersion:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ThreadVersion:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ThreadVersion:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ThreadVersion, {__call = ThreadVersion.new_value, __index = ThreadVersion.base_type}) +return ThreadVersion + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua new file mode 100644 index 0000000000..96cad47fdd --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua @@ -0,0 +1,24 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("ThreadBorderRouterManagement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ThreadBorderRouterManagementServerAttributes = {} + +function ThreadBorderRouterManagementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ThreadBorderRouterManagementServerAttributes, attr_mt) + +return ThreadBorderRouterManagementServerAttributes + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua new file mode 100644 index 0000000000..b63982575e --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetActiveDatasetRequest = {} + +GetActiveDatasetRequest.NAME = "GetActiveDatasetRequest" +GetActiveDatasetRequest.ID = 0x0000 +GetActiveDatasetRequest.field_defs = { +} + +function GetActiveDatasetRequest:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetActiveDatasetRequest, + __tostring = GetActiveDatasetRequest.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function GetActiveDatasetRequest:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetActiveDatasetRequest:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetActiveDatasetRequest:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetActiveDatasetRequest, {__call = GetActiveDatasetRequest.init}) + +return GetActiveDatasetRequest diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua new file mode 100644 index 0000000000..ec75068d8e --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua @@ -0,0 +1,28 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("ThreadBorderRouterManagement.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local ThreadBorderRouterManagementServerCommands = {} + +function ThreadBorderRouterManagementServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ThreadBorderRouterManagementServerCommands, command_mt) + +local status, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.server.commands") +if status then + aliases:add_to_class(ThreadBorderRouterManagementServerCommands) +end + +return ThreadBorderRouterManagementServerCommands + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua new file mode 100644 index 0000000000..c8116cd481 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua @@ -0,0 +1,54 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.PAN_CHANGE = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + PAN_CHANGE = 0x0001, +} + +Feature.is_pan_change_set = function(self) + return (self.value & self.PAN_CHANGE) ~= 0 +end + +Feature.set_pan_change = function(self) + if self.value ~= nil then + self.value = self.value | self.PAN_CHANGE + else + self.value = self.PAN_CHANGE + end +end + +Feature.unset_pan_change = function(self) + self.value = self.value & (~self.PAN_CHANGE & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.PAN_CHANGE + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_pan_change_set = Feature.is_pan_change_set, + set_pan_change = Feature.set_pan_change, + unset_pan_change = Feature.unset_pan_change, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua new file mode 100644 index 0000000000..3aece4eef2 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua @@ -0,0 +1,15 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("ThreadBorderRouterManagement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ThreadBorderRouterManagementTypes = {} + +setmetatable(ThreadBorderRouterManagementTypes, types_mt) + +return ThreadBorderRouterManagementTypes + diff --git a/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua new file mode 100644 index 0000000000..061ee5854f --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua @@ -0,0 +1,58 @@ +local cluster_base = require "st.matter.cluster_base" +local WiFiNetworkManagementServerAttributes = require "WiFiNetworkManagement.server.attributes" + +local WiFiNetworkManagement = {} + +WiFiNetworkManagement.ID = 0x0451 +WiFiNetworkManagement.NAME = "WiFiNetworkManagement" +WiFiNetworkManagement.server = {} +WiFiNetworkManagement.client = {} +WiFiNetworkManagement.server.attributes = WiFiNetworkManagementServerAttributes:set_parent_cluster(WiFiNetworkManagement) + +function WiFiNetworkManagement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Ssid", + [0x0001] = "PassphraseSurrogate", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +WiFiNetworkManagement.attribute_direction_map = { + ["Ssid"] = "server", + ["PassphraseSurrogate"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.WiFiNetworkManagement.server.attributes") + if has_aliases then + for alias, _ in pairs(aliases) do + WiFiNetworkManagement.attribute_direction_map[alias] = "server" + end + end +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = WiFiNetworkManagement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, WiFiNetworkManagement.NAME)) + end + return WiFiNetworkManagement[direction].attributes[key] +end +WiFiNetworkManagement.attributes = {} +setmetatable(WiFiNetworkManagement.attributes, attribute_helper_mt) + +setmetatable(WiFiNetworkManagement, {__index = cluster_base}) + +return WiFiNetworkManagement + diff --git a/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua new file mode 100644 index 0000000000..e7a2a43fb5 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Ssid = { + ID = 0x0000, + NAME = "Ssid", + base_type = require "st.matter.data_types.OctetString1", +} + +function Ssid:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Ssid:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Ssid:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Ssid:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Ssid:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Ssid:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Ssid, {__call = Ssid.new_value, __index = Ssid.base_type}) +return Ssid + diff --git a/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua new file mode 100644 index 0000000000..de0b55c8b1 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua @@ -0,0 +1,24 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("WiFiNetworkManagement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local WiFiNetworkManagementServerAttributes = {} + +function WiFiNetworkManagementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(WiFiNetworkManagementServerAttributes, attr_mt) + +return WiFiNetworkManagementServerAttributes + diff --git a/drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua b/drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua new file mode 100644 index 0000000000..ca36cd7562 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua @@ -0,0 +1,62 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.matter.clusters" +local utils = require "st.utils" +local version = require "version" + +if version.api < 13 then + clusters.ThreadBorderRouterManagement = require "ThreadBorderRouterManagement" +end + +local embedded_cluster_utils = {} + +local embedded_clusters_api_13 = { + [clusters.ThreadBorderRouterManagement.ID] = clusters.ThreadBorderRouterManagement, +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 13 and embedded_clusters_api_13[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters_api_13[cluster_id] + if not opts then opts = {} end + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end +end + +return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-hrap/src/init.lua b/drivers/SmartThings/matter-hrap/src/init.lua new file mode 100644 index 0000000000..df05e7ce83 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/init.lua @@ -0,0 +1,217 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local MatterDriver = require "st.matter.driver" +local data_types = require "st.matter.data_types" +local im = require "st.matter.interaction_model" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local lustre_utils = require "lustre.utils" +local st_utils = require "st.utils" +local version = require "version" +local log = require "log" + +local CURRENT_ACTIVE_DATASET_TIMESTAMP = "__CURRENT_ACTIVE_DATASET_TIMESTAMP" +local GET_ACTIVE_DATASET_RETRY_ATTEMPTS = "__GET_ACTIVE_DATASET_RETRY_ATTEMPTS" + +-- Include driver-side definitions when lua libs api version is <13 +if version.api < 13 then + clusters.ThreadBorderRouterManagement = require "ThreadBorderRouterManagement" + clusters.WiFiNetworkManagement = require "WiFiNetworkManagement" +end + + +--[[ ATTRIBUTE HANDLERS ]]-- + +local function border_router_name_attribute_handler(driver, device, ib) + -- per the spec, the recommended attribute format is ._meshcop._udp. This logic removes the MeshCoP suffix IFF it is present + local meshCop_name = ib.data.value + local terminal_display_char = (string.find(meshCop_name, "._meshcop._udp") or 64) - 1 -- where 64-1=63, the maximum allowed length for BorderRouterName + local display_name = string.sub(meshCop_name, 1, terminal_display_char) + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.borderRouterName({ value = display_name })) +end + +local function ssid_attribute_handler(driver, device, ib) + if (ib.data.value == string.char(data_types.Null.ID) or ib.data.value == nil) then -- Matter TLV-encoded NULL or Lua-encoded NULL + device.log.info("No primary Wi-Fi network is available") + return + end + local valid_utf8, utf8_err = lustre_utils.validate_utf8(ib.data.value) + if valid_utf8 then + device:emit_event_for_endpoint(ib.endpoint, capabilities.wifiInformation.ssid({ value = ib.data.value })) + else + device.log.info("UTF-8 validation of SSID failed: Error: '"..utf8_err.."'") + end +end + +local function thread_interface_enabled_attribute_handler(driver, device, ib) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.threadInterfaceState("enabled")) + else + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.threadInterfaceState("disabled")) + end +end + +-- Spec uses TLV encoding of Thread Version, which should be mapped to a more human-readable name +local VERSION_TLV_MAP = { + [1] = "1.0.0", + [2] = "1.1.0", + [3] = "1.2.0", + [4] = "1.3.0", + [5] = "1.4.0", +} + +local function thread_version_attribute_handler(driver, device, ib) + local version_name = VERSION_TLV_MAP[ib.data.value] + if version_name then + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.threadVersion({ value = version_name })) + else + device.log.warn("The received TLV-encoded Thread version does not have a provided mapping to a human-readable version format") + end +end + +local function active_dataset_timestamp_handler(driver, device, ib) + if not ib.data.value then + device.log.info("No Thread operational dataset configured") + elseif ib.data.value ~= device:get_field(CURRENT_ACTIVE_DATASET_TIMESTAMP) then + device:send(clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest(device, ib.endpoint_id)) + device:set_field(CURRENT_ACTIVE_DATASET_TIMESTAMP, ib.data.value, { persist = true }) + end +end + + +--[[ COMMAND HANDLERS ]]-- + +local threadNetwork = capabilities.threadNetwork +local TLV_TYPE_ATTR_MAP = { + [0] = threadNetwork.channel, + [1] = threadNetwork.panId, + [2] = threadNetwork.extendedPanId, + [3] = threadNetwork.networkName, + -- [4] intentionally omitted, refers to the MeshCoP PSKc + [5] = threadNetwork.networkKey, +} + +local function dataset_response_handler(driver, device, ib) + if not ib.info_block.data.elements.dataset then + device.log.debug_with({ hub_logs = true }, "Received empty Thread operational dataset") + return + end + + local operational_dataset_length = ib.info_block.data.elements.dataset.byte_length + local spec_defined_max_dataset_length = 254 + if operational_dataset_length > spec_defined_max_dataset_length then + device.log.error_with({ hub_logs = true }, "Received Thread operational dataset is too long") + return + end + + -- parse dataset + local operational_dataset = ib.info_block.data.elements.dataset.value + local cur_byte = 1 + while cur_byte + 1 <= operational_dataset_length do + local tlv_type = string.byte(operational_dataset, cur_byte) + local tlv_length = string.byte(operational_dataset, cur_byte + 1) + if (cur_byte + 1 + tlv_length) > operational_dataset_length then + device.log.error_with({ hub_logs = true }, "Received Thread operational dataset has a malformed TLV encoding") + return + end + local tlv_mapped_attr = TLV_TYPE_ATTR_MAP[tlv_type] + if tlv_mapped_attr then + -- extract the value from a TLV-encoded message. Message format: byte tag + byte length + length byte value + local tlv_value = operational_dataset:sub(cur_byte + 2, cur_byte + 1 + tlv_length) + -- format data as required by threadNetwork attribute properties + if tlv_mapped_attr == threadNetwork.channel or tlv_mapped_attr == threadNetwork.panId then + tlv_value = st_utils.deserialize_int(tlv_value, tlv_length) + elseif tlv_mapped_attr ~= threadNetwork.networkName then + tlv_value = st_utils.bytes_to_hex_string(tlv_value) + end + device:emit_event(tlv_mapped_attr({ value = tlv_value })) + end + cur_byte = cur_byte + 2 + tlv_length + end +end + +local function get_active_dataset_response_handler(driver, device, ib) + if ib.status == im.InteractionResponse.Status.FAILURE then + -- per spec, on a GetActiveDatasetRequest failure, a failure response is sent over the same command. + -- on failure, retry the read up to 3 times before failing out. + local retries_attempted = device:get_field(GET_ACTIVE_DATASET_RETRY_ATTEMPTS) or 0 + if retries_attempted < 3 then + device.log.error_with({ hub_logs = true }, "Failed to retrieve Thread operational dataset. Retrying " .. retries_attempted + 1 .. "/3") + device:set_field(GET_ACTIVE_DATASET_RETRY_ATTEMPTS, retries_attempted + 1) + else + -- do not retry again, but reset the count to 0. + device:set_field(GET_ACTIVE_DATASET_RETRY_ATTEMPTS, 0) + end + elseif ib.status == im.InteractionResponse.Status.UNSUPPORTED_ACCESS then + device.log.error_with({ hub_logs = true }, + "Failed to retrieve Thread operational dataset, since the GetActiveDatasetRequest command was not executed over CASE" + ) + end +end + + +--[[ LIFECYCLE HANDLERS ]]-- + +local function device_init(driver, device) + device:subscribe() +end + + +--[[ MATTER DRIVER TEMPLATE ]]-- + +local matter_driver_template = { + lifecycle_handlers = { + init = device_init, + }, + matter_handlers = { + attr = { + [clusters.WiFiNetworkManagement.ID] = { + [clusters.WiFiNetworkManagement.attributes.Ssid.ID] = ssid_attribute_handler, + }, + [clusters.ThreadBorderRouterManagement.ID] = { + [clusters.ThreadBorderRouterManagement.attributes.BorderRouterName.ID] = border_router_name_attribute_handler, + [clusters.ThreadBorderRouterManagement.attributes.ThreadVersion.ID] = thread_version_attribute_handler, + [clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled.ID] = thread_interface_enabled_attribute_handler, + [clusters.ThreadBorderRouterManagement.attributes.ActiveDatasetTimestamp.ID] = active_dataset_timestamp_handler, + } + }, + cmd_response = { + [clusters.ThreadBorderRouterManagement.ID] = { + [clusters.ThreadBorderRouterManagement.client.commands.DatasetResponse.ID] = dataset_response_handler, + [clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest.ID] = get_active_dataset_response_handler, + } + } + }, + subscribed_attributes = { + [capabilities.threadBorderRouter.ID] = { + clusters.ThreadBorderRouterManagement.attributes.ActiveDatasetTimestamp, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion, + }, + [capabilities.wifiInformation.ID] = { + clusters.WiFiNetworkManagement.attributes.Ssid, + } + }, + supported_capabilities = { + capabilities.threadBorderRouter, + capabilities.threadNetwork, + capabilities.wifiInformation, + } +} + +local matter_driver = MatterDriver("matter-hrap", matter_driver_template) +log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) +matter_driver:run() diff --git a/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua b/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua new file mode 100644 index 0000000000..af8315375f --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua @@ -0,0 +1,363 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local data_types = require "st.matter.data_types" +local capabilities = require "st.capabilities" + +local clusters = require "st.matter.clusters" +clusters.ThreadBorderRouterManagement = require "ThreadBorderRouterManagement" +clusters.WifiNetworkMangement = require "WiFiNetworkManagement" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("network-infrastructure-manager.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.ThreadBorderRouterManagement.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.WifiNetworkMangement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0090, device_type_revision = 1,} -- Network Infrastructure Manager + } + } + } +}) + +local cluster_subscribe_list = { + clusters.ThreadBorderRouterManagement.attributes.ActiveDatasetTimestamp, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion, + clusters.WifiNetworkMangement.attributes.Ssid, +} + +local function test_init() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + 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( + "ThreadVersion should display the correct stringified version", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 3 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadVersion({ value = "1.2.0" })) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 4 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadVersion({ value = "1.3.0" })) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 5 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadVersion({ value = "1.4.0" })) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 6 + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "InterfaceEnabled should correctly display enabled or disabled", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled:build_test_report_data(mock_device, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadInterfaceState("enabled")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled:build_test_report_data(mock_device, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadInterfaceState("disabled")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "BorderRouterName should correctly display the given name", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName:build_test_report_data(mock_device, 1, "john foo._meshcop._udp") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.borderRouterName({ value = "john foo"})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName:build_test_report_data(mock_device, 1, "jane bar._meshcop._udp") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.borderRouterName({ value = "jane bar"})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName:build_test_report_data(mock_device, 1, "john foo no suffix") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.borderRouterName({ value = "john foo no suffix"})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "wifiInformation capability should correctly display the Ssid", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, "test name for ssid!") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.wifiInformation.ssid({ value = "test name for ssid!" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Null-valued ssid (TLV 0x14) should correctly fail", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, string.char(data_types.Null.ID)) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ssid inputs using non-UTF8 encoding should not display an Ssid", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, string.char(0xC0)) -- 0xC0 never appears in utf8 + } + } + }, + { + min_api_version = 17 + } +) + +local hex_dataset = [[ +0E 08 00 00 68 87 D0 B2 00 00 00 03 00 00 18 35 +06 00 04 00 1F FF C0 02 08 25 31 25 A9 B2 16 7F +35 07 08 FD 6E D1 57 02 B4 CD BF 05 10 33 AF 36 +F8 13 8E 8F F9 50 6D 67 22 9B FD F2 40 03 0D 53 +54 2D 35 30 33 32 30 30 31 31 39 36 01 02 D9 78 +04 10 E2 29 D8 2A 84 B2 7D A1 AC 8D D8 71 64 AC +66 7F 0C 04 02 A0 FF F8 +]] + +local serializable_hex_dataset = hex_dataset:gsub("%s+", ""):gsub("..", function(cc) + return string.char(tonumber(cc, 16)) +end) + +test.register_coroutine_test( + "Thread DatasetResponse parsing should emit the correct capability events on an ActiveDatasetTimestamp update. Else, nothing should happen", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.attributes.ActiveDatasetTimestamp:build_test_report_data( + mock_device, + 1, + 1 + ) + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest(mock_device, 1), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.client.commands.DatasetResponse:build_test_command_response( + mock_device, + 1, + serializable_hex_dataset + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.channel({ value = 24 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.extendedPanId({ value = "253125a9b2167f35" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkKey({ value = "33af36f8138e8ff9506d67229bfdf240" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkName({ value = "ST-5032001196" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.panId({ value = 55672 })) + ) + test.wait_for_events() + + -- after some amount of time, a device init occurs or we re-subscribe for other reasons. + -- Since no change to the ActiveDatasetTimestamp has occurred, no re-read should occur + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.attributes.ActiveDatasetTimestamp:build_test_report_data( + mock_device, + 1, + 1 + ) + }) + test.wait_for_events() + + -- after some more amount of time, a device init occurs or we re-subscribe for other reasons. + -- This time, their ActiveDatasetTimestamp has updated, so we should re-read the operational dataset. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.attributes.ActiveDatasetTimestamp:build_test_report_data( + mock_device, + 1, + 2 + ) + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest(mock_device, 1), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.client.commands.DatasetResponse:build_test_command_response( + mock_device, + 1, + serializable_hex_dataset + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.channel({ value = 24 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.extendedPanId({ value = "253125a9b2167f35" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkKey({ value = "33af36f8138e8ff9506d67229bfdf240" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkName({ value = "ST-5032001196" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.panId({ value = 55672 })) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/fingerprints.yml b/drivers/SmartThings/matter-lock/fingerprints.yml index 9aa1095135..7eb674bce9 100755 --- a/drivers/SmartThings/matter-lock/fingerprints.yml +++ b/drivers/SmartThings/matter-lock/fingerprints.yml @@ -1,18 +1,211 @@ matterManufacturer: -#Nuki - - id: "Nuki Smart Lock Pro" - deviceLabel: Nuki Smart Lock Pro - vendorId: 0x135d - productId: 0xb1 - deviceProfileName: lock-nocodes-notamper + #Aqara + - id: "4447/10242" + deviceLabel: Aqara Smart Lock U200 + vendorId: 0x115F + productId: 0x2802 + deviceProfileName: lock-user-pin + - id: "4447/10241" + deviceLabel: Aqara Smart Lock U300 + vendorId: 0x115F + productId: 0x2801 + deviceProfileName: lock-user-pin + - id: "4447/10247" + deviceLabel: Aqara Smart Lock U200 Lite + vendorId: 0x115F + productId: 0x2807 + deviceProfileName: lock-battery + - id: "4447/10346" + deviceLabel: Aqara Smart Lock U200 US + vendorId: 0x115F + productId: 0x286A + deviceProfileName: lock-user-pin + - id: "4447/10244" + deviceLabel: Aqara Smart Lock U400 + vendorId: 0x115F + productId: 0x2804 + deviceProfileName: lock + - id: "4447/10254" + deviceLabel: Aqara Smart Lock U500 + vendorId: 0x115F + productId: 0x280E + deviceProfileName: lock-modular + - id: "4447/10255" + deviceLabel: Aqara Smart Lock U500 Rim + vendorId: 0x115F + productId: 0x280F + deviceProfileName: lock-modular + - id: "4447/10256" + deviceLabel: Aqara Smart Lock U500 Glass Door + vendorId: 0x115F + productId: 0x2810 + deviceProfileName: lock-modular + - id: "4447/10314" + deviceLabel: Aqara Smart Lock U600 + vendorId: 0x115F + productId: 0x284A + deviceProfileName: lock-modular + - id: "4447/10253" + deviceLabel: Aqara Smart Lock J200 + vendorId: 0x115F + productId: 0x280D + deviceProfileName: lock-modular + #Eufy + - id: "5427/1" + deviceLabel: eufy Smart Lock E31 + vendorId: 0x1533 + productId: 0x0001 + deviceProfileName: lock-user-pin-battery + - id: "5427/2" + deviceLabel: eufy Smart Lock E30 + vendorId: 0x1533 + productId: 0x0002 + deviceProfileName: lock-user-pin-battery + - id: "5427/3" + deviceLabel: eufy Smart Lock C34 + vendorId: 0x1533 + productId: 0x0003 + deviceProfileName: lock-user-pin-battery + - id: "5427/15" + deviceLabel: eufy FamiLock S3 Max + vendorId: 0x1533 + productId: 0x000F + deviceProfileName: lock-user-pin-battery + - id: "5427/16" + deviceLabel: eufy FamiLock S3 + vendorId: 0x1533 + productId: 0x0010 + deviceProfileName: lock-user-pin-battery + - id: "5427/17" + deviceLabel: eufy FamiLock E34 + vendorId: 0x1533 + productId: 0x0011 + deviceProfileName: lock-user-pin-battery + - id: "5427/18" + deviceLabel: eufy FamiLock E35 + vendorId: 0x1533 + productId: 0x0012 + deviceProfileName: lock-user-pin-battery + - id: "5427/22" + deviceLabel: eufy FamiLock E32 + vendorId: 0x1533 + productId: 0x0016 + deviceProfileName: lock-user-pin-battery + - id: 5427/20 + deviceLabel: eufy FamiLock E40 + vendorId: 0x1533 + productId: 0x0014 + deviceProfileName: lock-user-pin-battery + #Kwikset + - id: "5153/66" + deviceLabel: Kwikset Halo Select Plus + vendorId: 0x1421 + productId: 0x0042 + deviceProfileName: lock-user-pin-battery + - id: "5153/65" + deviceLabel: Halo Select Plus + vendorId: 0x1421 + productId: 0x0041 + deviceProfileName: lock-user-pin-battery + - id: "5153/129" + deviceLabel: Kwikset Aura Reach + vendorId: 0x1421 + productId: 0x0081 + deviceProfileName: lock-user-pin-battery + #Level + - id: "4767/1" + deviceLabel: Level Lock Plus (Matter) + vendorId: 0x129F + productId: 0x0001 + deviceProfileName: lock + - id: "4767/3" + deviceLabel: Level Bolt (Matter) + vendorId: 0x129F + productId: 0x0003 + deviceProfileName: lock + - id: "4767/4" + deviceLabel: Level Lock Pro + vendorId: 0x129F + productId: 0x0004 + deviceProfileName: lock + - id: "4767/5" + deviceLabel: Level Lock (Matter) + vendorId: 0x129F + productId: 0x0005 + deviceProfileName: lock + #Nuki + - id: "4957/161" + deviceLabel: Nuki Smart Lock Ultra + vendorId: 0x135D + productId: 0x00A1 + deviceProfileName: lock - id: "Nuki Smart Lock" deviceLabel: Nuki Smart Lock vendorId: 0x135d productId: 0xb0 - deviceProfileName: lock-nocodes-notamper + deviceProfileName: lock + - id: "Nuki Smart Lock Pro" + deviceLabel: Nuki Smart Lock Pro + vendorId: 0x135d + productId: 0xb1 + deviceProfileName: lock + - id: "4957/178" + deviceLabel: Nuki Smart Lock Pro + vendorId: 0x135D + productId: 0x00B2 + deviceProfileName: lock + - id: "4957/193" + deviceLabel: Nuki Smart Lock Go + vendorId: 0x135D + productId: 0x00C1 + deviceProfileName: lock + #U-tec + - id: "5247/1" + deviceLabel: ULTRALOQ Bolt Fingerprint Smart Matter Deadbolt + vendorId: 0x147F + productId: 0x0001 + deviceProfileName: lock-user-pin-battery + - id: "5247/8" + deviceLabel: ULTRALOQ Bolt Smart Matter Door Lock + vendorId: 0x147F + productId: 0x0008 + deviceProfileName: lock-user-pin-battery + - id: "5247/7" + deviceLabel: ULTRALOQ Bolt Pro Smart Matter Door Lock + vendorId: 0x147F + productId: 0x0007 + deviceProfileName: lock-modular + - id: "5247/16" + deviceLabel: ULTRALOQ Latch 5 Pro Smart Matter Door Lock + vendorId: 0x147F + productId: 0x0010 + deviceProfileName: lock-modular + #Yale + - id: "4125/33040" + deviceLabel: Yale Lock with Matter + vendorId: 0x101D + productId: 0x8110 + deviceProfileName: lock-user-pin-schedule-battery + #Dreame + - id: "5420/38144" + deviceLabel: Dreame NAVO Smart Lock A10 + vendorId: 0x152C + productId: 0x9500 + deviceProfileName: lock + - id: "5420/38145" + deviceLabel: Dreame NAVO Smart Lock E10 + vendorId: 0x152C + productId: 0x9501 + deviceProfileName: lock + #Lockly + - id: "5212/2" + deviceLabel: Lockly Smart Lock + vendorId: 0x145C + productId: 0x0002 + deviceProfileName: lock-modular matterGeneric: - id: "matter/door-lock" deviceLabel: Matter Door Lock deviceTypes: - id: 0x000A # Door Lock - deviceProfileName: base-lock + deviceProfileName: lock diff --git a/drivers/SmartThings/matter-lock/profiles/base-lock-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/base-lock-batteryLevel.yml new file mode 100755 index 0000000000..093c741219 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/base-lock-batteryLevel.yml @@ -0,0 +1,26 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: base-lock-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockCodes + version: 1 + - id: tamperAlert + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/base-lock-nobattery.yml b/drivers/SmartThings/matter-lock/profiles/base-lock-nobattery.yml new file mode 100755 index 0000000000..c223facfbf --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/base-lock-nobattery.yml @@ -0,0 +1,24 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: base-lock-nobattery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockCodes + version: 1 + - id: tamperAlert + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/base-lock.yml b/drivers/SmartThings/matter-lock/profiles/base-lock.yml index 06e0bcd49e..7d69a5c8e4 100755 --- a/drivers/SmartThings/matter-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/matter-lock/profiles/base-lock.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: base-lock components: - id: main @@ -10,7 +11,7 @@ components: enabledValues: - locked - unlocked - - unknown + - not fully locked - id: lockCodes version: 1 - id: tamperAlert diff --git a/drivers/SmartThings/matter-lock/profiles/lock-battery.yml b/drivers/SmartThings/matter-lock/profiles/lock-battery.yml new file mode 100644 index 0000000000..e4e7229b40 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-battery.yml @@ -0,0 +1,25 @@ +name: lock-battery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-batteryLevel.yml new file mode 100644 index 0000000000..1286fc4a53 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-batteryLevel.yml @@ -0,0 +1,25 @@ +name: lock-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-lockalarm-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-lockalarm-batteryLevel.yml new file mode 100755 index 0000000000..83b8c097f7 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-lockalarm-batteryLevel.yml @@ -0,0 +1,24 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: lock-lockalarm-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-lockalarm-nobattery.yml b/drivers/SmartThings/matter-lock/profiles/lock-lockalarm-nobattery.yml new file mode 100644 index 0000000000..135a1e1986 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-lockalarm-nobattery.yml @@ -0,0 +1,22 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: lock-lockalarm-nobattery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-lockalarm.yml b/drivers/SmartThings/matter-lock/profiles/lock-lockalarm.yml new file mode 100755 index 0000000000..ed0dd126e6 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-lockalarm.yml @@ -0,0 +1,24 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: lock-lockalarm +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml b/drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml new file mode 100644 index 0000000000..3d9e68b44c --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-modular-embedded-unlatch.yml @@ -0,0 +1,134 @@ +name: lock-modular-embedded-unlatch +components: +- id: main + capabilities: + - id: lock + version: 1 + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + optional: true + - id: lockCredentials + version: 1 + optional: true + - id: lockSchedules + version: 1 + optional: true + - id: lockAliro + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: batteryLevel + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: batteryLevel + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-modular.yml b/drivers/SmartThings/matter-lock/profiles/lock-modular.yml new file mode 100644 index 0000000000..3a8a53bf70 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-modular.yml @@ -0,0 +1,34 @@ +name: lock-modular +components: +- id: main + capabilities: + - id: lock + version: 1 + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + optional: true + - id: lockCredentials + version: 1 + optional: true + - id: lockSchedules + version: 1 + optional: true + - id: lockAliro + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper-batteryLevel.yml new file mode 100644 index 0000000000..abd55420fd --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper-batteryLevel.yml @@ -0,0 +1,22 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: lock-nocodes-notamper-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper.yml b/drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper.yml index 31aeffef13..20dec32602 100644 --- a/drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper.yml +++ b/drivers/SmartThings/matter-lock/profiles/lock-nocodes-notamper.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: lock-nocodes-notamper components: - id: main @@ -10,7 +11,7 @@ components: enabledValues: - locked - unlocked - - unknown + - not fully locked - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-lock/profiles/lock-unlatch-battery.yml b/drivers/SmartThings/matter-lock/profiles/lock-unlatch-battery.yml new file mode 100644 index 0000000000..b5c04448b8 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-unlatch-battery.yml @@ -0,0 +1,120 @@ +name: lock-unlatch-battery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: battery + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: battery + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-unlatch-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-unlatch-batteryLevel.yml new file mode 100644 index 0000000000..a7b89a4bcc --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-unlatch-batteryLevel.yml @@ -0,0 +1,120 @@ +name: lock-unlatch-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: batteryLevel + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: batteryLevel + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-unlatch.yml b/drivers/SmartThings/matter-lock/profiles/lock-unlatch.yml new file mode 100644 index 0000000000..92a5a30d06 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-unlatch.yml @@ -0,0 +1,112 @@ +name: lock-unlatch +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-battery.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-battery.yml new file mode 100644 index 0000000000..2401c4119e --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-battery.yml @@ -0,0 +1,29 @@ +name: lock-user-pin-battery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-batteryLevel.yml new file mode 100644 index 0000000000..582fcd3975 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-batteryLevel.yml @@ -0,0 +1,29 @@ +name: lock-user-pin-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-battery.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-battery.yml new file mode 100644 index 0000000000..ac52c839fa --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-battery.yml @@ -0,0 +1,31 @@ +name: lock-user-pin-schedule-battery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: lockSchedules + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-batteryLevel.yml new file mode 100644 index 0000000000..d1b8ce8a13 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-batteryLevel.yml @@ -0,0 +1,31 @@ +name: lock-user-pin-schedule-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: lockSchedules + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-battery.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-battery.yml new file mode 100644 index 0000000000..ac5392f5ae --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-battery.yml @@ -0,0 +1,126 @@ +name: lock-user-pin-schedule-unlatch-battery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: lockSchedules + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: battery + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: battery + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-batteryLevel.yml new file mode 100644 index 0000000000..d4b1d4ce4a --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch-batteryLevel.yml @@ -0,0 +1,126 @@ +name: lock-user-pin-schedule-unlatch-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: lockSchedules + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: batteryLevel + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + - component: main + capability: batteryLevel + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch.yml new file mode 100644 index 0000000000..ee380d8dd9 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule-unlatch.yml @@ -0,0 +1,118 @@ +name: lock-user-pin-schedule-unlatch +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: lockSchedules + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule.yml new file mode 100644 index 0000000000..d27423f2b8 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-schedule.yml @@ -0,0 +1,29 @@ +name: lock-user-pin-schedule +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: lockSchedules + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin-unlatch.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-unlatch.yml new file mode 100644 index 0000000000..9718179116 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin-unlatch.yml @@ -0,0 +1,116 @@ +name: lock-user-pin-unlatch +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-pin.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-pin.yml new file mode 100644 index 0000000000..8a48917392 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-pin.yml @@ -0,0 +1,27 @@ +name: lock-user-pin +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockCredentials + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-schedule-unlatch.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-schedule-unlatch.yml new file mode 100644 index 0000000000..240f31c230 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-schedule-unlatch.yml @@ -0,0 +1,116 @@ +name: lock-user-schedule-unlatch +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockSchedules + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-schedule.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-schedule.yml new file mode 100644 index 0000000000..e4e9daf65a --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-schedule.yml @@ -0,0 +1,27 @@ +name: lock-user-schedule +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: lockSchedules + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user-unlatch.yml b/drivers/SmartThings/matter-lock/profiles/lock-user-unlatch.yml new file mode 100644 index 0000000000..65322094e2 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user-unlatch.yml @@ -0,0 +1,114 @@ +name: lock-user-unlatch +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - unlatched + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +deviceConfig: + dashboard: + states: + - component: main + capability: lock + version: 1 + actions: + - component: main + capability: lock + version: 1 + visibleCondition: { + "capability": "lock", + "version": "1", + "component": "main", + "value": "lock.value", + "operator": "DOES_NOT_EQUAL", + "operand": "unlatched" + } + detailView: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + patch: + - op: add + path: /1 + value: + capability: lock + version: 1 + component: main + label: '{{i18n.commands.unlatch.label}}' + displayType: pushButton + pushButton: + command: unlatch + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + automation: + conditions: + - component: main + capability: lock + version: 1 + values: + - key: lock.value + alternatives: + - key: locked + type: inactive + value: '{{i18n.attributes.lock.i18n.value.locked.label}}' + - key: unlocked + value: '{{i18n.attributes.lock.i18n.value.unlocked.label}}' + - key: unlatched + value: '{{i18n.attributes.lock.i18n.value.unlatched.label}}' + - key: not fully locked + value: '{{i18n.attributes.lock.i18n.value.not fully locked.label}}' + - component: main + capability: lockAlarm + version: 1 + - component: main + capability: remoteControlStatus + version: 1 + actions: + - component: main + capability: lock + version: 1 + values: + - key: '{{enumCommands}}' + alternatives: + - key: lock + type: inactive + value: '{{i18n.commands.lock.label}}' + - key: unlock + value: '{{i18n.commands.unlock.label}}' + - key: unlatch + value: '{{i18n.commands.unlatch.label}}' diff --git a/drivers/SmartThings/matter-lock/profiles/lock-user.yml b/drivers/SmartThings/matter-lock/profiles/lock-user.yml new file mode 100644 index 0000000000..07ca0dd74e --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-user.yml @@ -0,0 +1,25 @@ +name: lock-user +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: lockUsers + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-without-codes-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/lock-without-codes-batteryLevel.yml new file mode 100755 index 0000000000..2d577cd19b --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-without-codes-batteryLevel.yml @@ -0,0 +1,24 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: lock-without-codes-batteryLevel +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: tamperAlert + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-without-codes-nobattery.yml b/drivers/SmartThings/matter-lock/profiles/lock-without-codes-nobattery.yml new file mode 100755 index 0000000000..059b65e9f9 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock-without-codes-nobattery.yml @@ -0,0 +1,22 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: lock-without-codes-nobattery +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: tamperAlert + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/lock-without-codes.yml b/drivers/SmartThings/matter-lock/profiles/lock-without-codes.yml index 35944d5d3d..86bc5e45d3 100755 --- a/drivers/SmartThings/matter-lock/profiles/lock-without-codes.yml +++ b/drivers/SmartThings/matter-lock/profiles/lock-without-codes.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: lock-without-codes components: - id: main @@ -10,7 +11,7 @@ components: enabledValues: - locked - unlocked - - unknown + - not fully locked - id: battery version: 1 - id: tamperAlert diff --git a/drivers/SmartThings/matter-lock/profiles/lock.yml b/drivers/SmartThings/matter-lock/profiles/lock.yml new file mode 100644 index 0000000000..9f0bf30a43 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/lock.yml @@ -0,0 +1,23 @@ +name: lock +components: +- id: main + capabilities: + - id: lock + version: 1 + config: + values: + - key: "lock.value" + enabledValues: + - locked + - unlocked + - not fully locked + - id: lockAlarm + version: 1 + - id: remoteControlStatus + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/nonfunctional-lock-batteryLevel.yml b/drivers/SmartThings/matter-lock/profiles/nonfunctional-lock-batteryLevel.yml new file mode 100644 index 0000000000..cf342d9548 --- /dev/null +++ b/drivers/SmartThings/matter-lock/profiles/nonfunctional-lock-batteryLevel.yml @@ -0,0 +1,17 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: nonfunctional-lock-batteryLevel +components: +- id: main + capabilities: + - id: lockCodes + version: 1 + - id: tamperAlert + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock diff --git a/drivers/SmartThings/matter-lock/profiles/nonfunctional-lock.yml b/drivers/SmartThings/matter-lock/profiles/nonfunctional-lock.yml index 1990cbc025..e662780d55 100644 --- a/drivers/SmartThings/matter-lock/profiles/nonfunctional-lock.yml +++ b/drivers/SmartThings/matter-lock/profiles/nonfunctional-lock.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: nonfunctional-lock components: - id: main diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetCredentialStatusResponse.lua b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetCredentialStatusResponse.lua new file mode 100644 index 0000000000..70d0a6eff1 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetCredentialStatusResponse.lua @@ -0,0 +1,129 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetCredentialStatusResponse = {} + +GetCredentialStatusResponse.NAME = "GetCredentialStatusResponse" +GetCredentialStatusResponse.ID = 0x0025 +GetCredentialStatusResponse.field_defs = { + { + name = "credential_exists", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "user_index", + field_id = 1, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "creator_fabric_index", + field_id = 2, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "last_modified_fabric_index", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "next_credential_index", + field_id = 4, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function GetCredentialStatusResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetCredentialStatusResponse:build_test_command_response(device, endpoint_id, credential_exists, user_index, creator_fabric_index, last_modified_fabric_index, next_credential_index, interaction_status) + local function init(self, device, endpoint_id, credential_exists, user_index, creator_fabric_index, last_modified_fabric_index, next_credential_index) + local out = {} + local args = {credential_exists, user_index, creator_fabric_index, last_modified_fabric_index, next_credential_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetCredentialStatusResponse, + __tostring = GetCredentialStatusResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) + end + local self_request = init(self, device, endpoint_id, credential_exists, user_index, creator_fabric_index, last_modified_fabric_index, next_credential_index) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function GetCredentialStatusResponse:init() + return nil +end + +function GetCredentialStatusResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetCredentialStatusResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetCredentialStatusResponse, {__call = GetCredentialStatusResponse.init}) + +return GetCredentialStatusResponse diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetUserResponse.lua b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetUserResponse.lua new file mode 100644 index 0000000000..6ee0bd8792 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetUserResponse.lua @@ -0,0 +1,165 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetUserResponse = {} + +GetUserResponse.NAME = "GetUserResponse" +GetUserResponse.ID = 0x001C +GetUserResponse.field_defs = { + { + name = "user_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "user_name", + field_id = 1, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "user_uniqueid", + field_id = 2, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "user_status", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.UserStatusEnum", + }, + { + name = "user_type", + field_id = 4, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.UserTypeEnum", + }, + { + name = "credential_rule", + field_id = 5, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.CredentialRuleEnum", + }, + { + name = "credentials", + field_id = 6, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "DoorLock.types.CredentialStruct", + }, + { + name = "creator_fabric_index", + field_id = 7, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "last_modified_fabric_index", + field_id = 8, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "next_user_index", + field_id = 9, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function GetUserResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetUserResponse:build_test_command_response(device, endpoint_id, user_index, user_name, user_uniqueid, user_status, user_type, credential_rule, credentials, creator_fabric_index, last_modified_fabric_index, next_user_index, interaction_status) + local function init(self, device, endpoint_id, user_index, user_name, user_uniqueid, user_status, user_type, credential_rule, credentials, creator_fabric_index, last_modified_fabric_index, next_user_index) + local out = {} + local args = {user_index, user_name, user_uniqueid, user_status, user_type, credential_rule, credentials, creator_fabric_index, last_modified_fabric_index, next_user_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetUserResponse, + __tostring = GetUserResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) + end + local self_request = init(self, device, endpoint_id, user_index, user_name, user_uniqueid, user_status, user_type, credential_rule, credentials, creator_fabric_index, last_modified_fabric_index, next_user_index) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function GetUserResponse:init() + return nil +end + +function GetUserResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetUserResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetUserResponse, {__call = GetUserResponse.init}) + +return GetUserResponse diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetWeekDayScheduleResponse.lua b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetWeekDayScheduleResponse.lua new file mode 100644 index 0000000000..86b53a686e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetWeekDayScheduleResponse.lua @@ -0,0 +1,150 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetWeekDayScheduleResponse = {} + +GetWeekDayScheduleResponse.NAME = "GetWeekDayScheduleResponse" +GetWeekDayScheduleResponse.ID = 0x000C +GetWeekDayScheduleResponse.field_defs = { + { + name = "week_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "status", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DlStatus", + }, + { + name = "days_mask", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "DoorLock.types.DaysMaskMap", + }, + { + name = "start_hour", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "start_minute", + field_id = 5, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "end_hour", + field_id = 6, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "end_minute", + field_id = 7, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function GetWeekDayScheduleResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetWeekDayScheduleResponse:build_test_command_response(device, endpoint_id, week_day_index, user_index, status, days_mask, start_hour, start_minute, end_hour, end_minute, interaction_status) + local function init(self, device, endpoint_id, week_day_index, user_index, status, days_mask, start_hour, start_minute, end_hour, end_minute) + local out = {} + local args = {week_day_index, user_index, status, days_mask, start_hour, start_minute, end_hour, end_minute} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetWeekDayScheduleResponse, + __tostring = GetWeekDayScheduleResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) + end + local self_request = init(self, device, endpoint_id, week_day_index, user_index, status, days_mask, start_hour, start_minute, end_hour, end_minute) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function GetWeekDayScheduleResponse:init() + return nil +end + +function GetWeekDayScheduleResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetWeekDayScheduleResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetWeekDayScheduleResponse, {__call = GetWeekDayScheduleResponse.init}) + +return GetWeekDayScheduleResponse diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetYearDayScheduleResponse.lua b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetYearDayScheduleResponse.lua new file mode 100644 index 0000000000..ecb591477e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/GetYearDayScheduleResponse.lua @@ -0,0 +1,129 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetYearDayScheduleResponse = {} + +GetYearDayScheduleResponse.NAME = "GetYearDayScheduleResponse" +GetYearDayScheduleResponse.ID = 0x000F +GetYearDayScheduleResponse.field_defs = { + { + name = "year_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "status", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DlStatus", + }, + { + name = "local_start_time", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "local_end_time", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, +} + +function GetYearDayScheduleResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetYearDayScheduleResponse:build_test_command_response(device, endpoint_id, year_day_index, user_index, status, local_start_time, local_end_time, interaction_status) + local function init(self, device, endpoint_id, year_day_index, user_index, status, local_start_time, local_end_time) + local out = {} + local args = {year_day_index, user_index, status, local_start_time, local_end_time} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetYearDayScheduleResponse, + __tostring = GetYearDayScheduleResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) + end + local self_request = init(self, device, endpoint_id, year_day_index, user_index, status, local_start_time, local_end_time) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function GetYearDayScheduleResponse:init() + return nil +end + +function GetYearDayScheduleResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetYearDayScheduleResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetYearDayScheduleResponse, {__call = GetYearDayScheduleResponse.init}) + +return GetYearDayScheduleResponse diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/SetCredentialResponse.lua b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/SetCredentialResponse.lua new file mode 100644 index 0000000000..eaf1658d14 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/SetCredentialResponse.lua @@ -0,0 +1,115 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetCredentialResponse = {} + +SetCredentialResponse.NAME = "SetCredentialResponse" +SetCredentialResponse.ID = 0x0023 +SetCredentialResponse.field_defs = { + { + name = "status", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DlStatus", + }, + { + name = "user_index", + field_id = 1, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "next_credential_index", + field_id = 2, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function SetCredentialResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetCredentialResponse:build_test_command_response(device, endpoint_id, status, user_index, next_credential_index, interaction_status) + local function init(self, device, endpoint_id, status, user_index, next_credential_index) + local out = {} + local args = {status, user_index, next_credential_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetCredentialResponse, + __tostring = SetCredentialResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) + end + local self_request = init(self, device, endpoint_id, status, user_index, next_credential_index) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function SetCredentialResponse:init() + return nil +end + +function SetCredentialResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetCredentialResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetCredentialResponse, {__call = SetCredentialResponse.init}) + +return SetCredentialResponse diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/init.lua new file mode 100644 index 0000000000..9148db5f96 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/client/commands/init.lua @@ -0,0 +1,23 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("DoorLock.client.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local DoorLockClientCommands = {} + +function DoorLockClientCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DoorLockClientCommands, command_mt) + +return DoorLockClientCommands + diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/init.lua new file mode 100644 index 0000000000..20cf12d0ea --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/init.lua @@ -0,0 +1,262 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local cluster_base = require "st.matter.cluster_base" +local DoorLockServerAttributes = require "DoorLock.server.attributes" +local DoorLockServerCommands = require "DoorLock.server.commands" +local DoorLockClientCommands = require "DoorLock.client.commands" +local DoorLockEvents = require "DoorLock.server.events" +local DoorLockTypes = require "DoorLock.types" + +local DoorLock = {} + +DoorLock.ID = 0x0101 +DoorLock.NAME = "DoorLock" +DoorLock.server = {} +DoorLock.client = {} +DoorLock.server.attributes = DoorLockServerAttributes:set_parent_cluster(DoorLock) +DoorLock.server.commands = DoorLockServerCommands:set_parent_cluster(DoorLock) +DoorLock.client.commands = DoorLockClientCommands:set_parent_cluster(DoorLock) +DoorLock.server.events = DoorLockEvents:set_parent_cluster(DoorLock) +DoorLock.types = DoorLockTypes + +function DoorLock:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "LockState", + [0x0001] = "LockType", + [0x0002] = "ActuatorEnabled", + [0x0003] = "DoorState", + [0x0004] = "DoorOpenEvents", + [0x0005] = "DoorClosedEvents", + [0x0006] = "OpenPeriod", + [0x0011] = "NumberOfTotalUsersSupported", + [0x0012] = "NumberOfPINUsersSupported", + [0x0013] = "NumberOfRFIDUsersSupported", + [0x0014] = "NumberOfWeekDaySchedulesSupportedPerUser", + [0x0015] = "NumberOfYearDaySchedulesSupportedPerUser", + [0x0016] = "NumberOfHolidaySchedulesSupported", + [0x0017] = "MaxPINCodeLength", + [0x0018] = "MinPINCodeLength", + [0x0019] = "MaxRFIDCodeLength", + [0x001A] = "MinRFIDCodeLength", + [0x001B] = "CredentialRulesSupport", + [0x001C] = "NumberOfCredentialsSupportedPerUser", + [0x0021] = "Language", + [0x0022] = "LEDSettings", + [0x0023] = "AutoRelockTime", + [0x0024] = "SoundVolume", + [0x0025] = "OperatingMode", + [0x0026] = "SupportedOperatingModes", + [0x0027] = "DefaultConfigurationRegister", + [0x0028] = "EnableLocalProgramming", + [0x0029] = "EnableOneTouchLocking", + [0x002A] = "EnableInsideStatusLED", + [0x002B] = "EnablePrivacyModeButton", + [0x002C] = "LocalProgrammingFeatures", + [0x0030] = "WrongCodeEntryLimit", + [0x0031] = "UserCodeTemporaryDisableTime", + [0x0032] = "SendPINOverTheAir", + [0x0033] = "RequirePINforRemoteOperation", + [0x0035] = "ExpiringUserTimeout", + [0x0080] = "AliroReaderVerificationKey", + [0x0081] = "AliroReaderGroupIdentifier", + [0x0082] = "AliroReaderGroupSubIdentifier", + [0x0083] = "AliroExpeditedTransactionSupportedProtocolVersions", + [0x0084] = "AliroGroupResolvingKey", + [0x0085] = "AliroSupportedBLEUWBProtocolVersions", + [0x0086] = "AliroBLEAdvertisingVersion", + [0x0087] = "NumberOfAliroCredentialIssuerKeysSupported", + [0x0088] = "NumberOfAliroEndpointKeysSupported", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function DoorLock:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "LockDoor", + [0x0001] = "UnlockDoor", + [0x0003] = "UnlockWithTimeout", + [0x000B] = "SetWeekDaySchedule", + [0x000C] = "GetWeekDaySchedule", + [0x000D] = "ClearWeekDaySchedule", + [0x000E] = "SetYearDaySchedule", + [0x000F] = "GetYearDaySchedule", + [0x0010] = "ClearYearDaySchedule", + [0x0011] = "SetHolidaySchedule", + [0x0012] = "GetHolidaySchedule", + [0x0013] = "ClearHolidaySchedule", + [0x001A] = "SetUser", + [0x001B] = "GetUser", + [0x001D] = "ClearUser", + [0x0022] = "SetCredential", + [0x0024] = "GetCredentialStatus", + [0x0026] = "ClearCredential", + [0x0027] = "UnboltDoor", + [0x0028] = "SetAliroReaderConfig", + [0x0029] = "ClearAliroReaderConfig", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function DoorLock:get_client_command_by_id(command_id) + local client_id_map = { + [0x000C] = "GetWeekDayScheduleResponse", + [0x000F] = "GetYearDayScheduleResponse", + [0x0012] = "GetHolidayScheduleResponse", + [0x001C] = "GetUserResponse", + [0x0023] = "SetCredentialResponse", + [0x0025] = "GetCredentialStatusResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +function DoorLock:get_event_by_id(event_id) + local event_id_map = { + [0x0000] = "DoorLockAlarm", + [0x0001] = "DoorStateChange", + [0x0002] = "LockOperation", + [0x0003] = "LockOperationError", + [0x0004] = "LockUserChange", + } + if event_id_map[event_id] ~= nil then + return self.server.events[event_id_map[event_id]] + end + return nil +end + +DoorLock.attribute_direction_map = { + ["LockState"] = "server", + ["LockType"] = "server", + ["ActuatorEnabled"] = "server", + ["DoorState"] = "server", + ["DoorOpenEvents"] = "server", + ["DoorClosedEvents"] = "server", + ["OpenPeriod"] = "server", + ["NumberOfTotalUsersSupported"] = "server", + ["NumberOfPINUsersSupported"] = "server", + ["NumberOfRFIDUsersSupported"] = "server", + ["NumberOfWeekDaySchedulesSupportedPerUser"] = "server", + ["NumberOfYearDaySchedulesSupportedPerUser"] = "server", + ["NumberOfHolidaySchedulesSupported"] = "server", + ["MaxPINCodeLength"] = "server", + ["MinPINCodeLength"] = "server", + ["MaxRFIDCodeLength"] = "server", + ["MinRFIDCodeLength"] = "server", + ["CredentialRulesSupport"] = "server", + ["NumberOfCredentialsSupportedPerUser"] = "server", + ["Language"] = "server", + ["LEDSettings"] = "server", + ["AutoRelockTime"] = "server", + ["SoundVolume"] = "server", + ["OperatingMode"] = "server", + ["SupportedOperatingModes"] = "server", + ["DefaultConfigurationRegister"] = "server", + ["EnableLocalProgramming"] = "server", + ["EnableOneTouchLocking"] = "server", + ["EnableInsideStatusLED"] = "server", + ["EnablePrivacyModeButton"] = "server", + ["LocalProgrammingFeatures"] = "server", + ["WrongCodeEntryLimit"] = "server", + ["UserCodeTemporaryDisableTime"] = "server", + ["SendPINOverTheAir"] = "server", + ["RequirePINforRemoteOperation"] = "server", + ["ExpiringUserTimeout"] = "server", + ["AliroReaderVerificationKey"] = "server", + ["AliroReaderGroupIdentifier"] = "server", + ["AliroReaderGroupSubIdentifier"] = "server", + ["AliroExpeditedTransactionSupportedProtocolVersions"] = "server", + ["AliroGroupResolvingKey"] = "server", + ["AliroSupportedBLEUWBProtocolVersions"] = "server", + ["AliroBLEAdvertisingVersion"] = "server", + ["NumberOfAliroCredentialIssuerKeysSupported"] = "server", + ["NumberOfAliroEndpointKeysSupported"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +DoorLock.command_direction_map = { + ["LockDoor"] = "server", + ["UnlockDoor"] = "server", + ["UnlockWithTimeout"] = "server", + ["SetWeekDaySchedule"] = "server", + ["GetWeekDaySchedule"] = "server", + ["ClearWeekDaySchedule"] = "server", + ["SetYearDaySchedule"] = "server", + ["GetYearDaySchedule"] = "server", + ["ClearYearDaySchedule"] = "server", + ["SetHolidaySchedule"] = "server", + ["GetHolidaySchedule"] = "server", + ["ClearHolidaySchedule"] = "server", + ["SetUser"] = "server", + ["GetUser"] = "server", + ["ClearUser"] = "server", + ["SetCredential"] = "server", + ["GetCredentialStatus"] = "server", + ["ClearCredential"] = "server", + ["UnboltDoor"] = "server", + ["SetAliroReaderConfig"] = "server", + ["ClearAliroReaderConfig"] = "server", + ["GetWeekDayScheduleResponse"] = "client", + ["GetYearDayScheduleResponse"] = "client", + ["GetHolidayScheduleResponse"] = "client", + ["GetUserResponse"] = "client", + ["SetCredentialResponse"] = "client", + ["GetCredentialStatusResponse"] = "client", +} + +DoorLock.FeatureMap = DoorLock.types.Feature + +function DoorLock.are_features_supported(feature, feature_map) + if (DoorLock.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = DoorLock.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, DoorLock.NAME)) + end + return DoorLock[direction].attributes[key] +end +DoorLock.attributes = {} +setmetatable(DoorLock.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = DoorLock.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, DoorLock.NAME)) + end + return DoorLock[direction].commands[key] +end +DoorLock.commands = {} +setmetatable(DoorLock.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return DoorLock.server.events[key] +end +DoorLock.events = {} +setmetatable(DoorLock.events, event_helper_mt) + +setmetatable(DoorLock, {__index = cluster_base}) + +return DoorLock diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..fe8e273558 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/ActuatorEnabled.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/ActuatorEnabled.lua new file mode 100644 index 0000000000..359261a551 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/ActuatorEnabled.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ActuatorEnabled = { + ID = 0x0002, + NAME = "ActuatorEnabled", + base_type = require "st.matter.data_types.Boolean", +} + +function ActuatorEnabled:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ActuatorEnabled:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActuatorEnabled:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ActuatorEnabled:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ActuatorEnabled:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ActuatorEnabled:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ActuatorEnabled, {__call = ActuatorEnabled.new_value, __index = ActuatorEnabled.base_type}) +return ActuatorEnabled \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroBLEAdvertisingVersion.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroBLEAdvertisingVersion.lua new file mode 100644 index 0000000000..582babc3cb --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroBLEAdvertisingVersion.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AliroBLEAdvertisingVersion = { + ID = 0x0086, + NAME = "AliroBLEAdvertisingVersion", + base_type = require "st.matter.data_types.Uint8", +} + +function AliroBLEAdvertisingVersion:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AliroBLEAdvertisingVersion:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroBLEAdvertisingVersion:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroBLEAdvertisingVersion:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AliroBLEAdvertisingVersion:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AliroBLEAdvertisingVersion:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AliroBLEAdvertisingVersion, {__call = AliroBLEAdvertisingVersion.new_value, __index = AliroBLEAdvertisingVersion.base_type}) +return AliroBLEAdvertisingVersion \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroExpeditedTransactionSupportedProtocolVersions.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroExpeditedTransactionSupportedProtocolVersions.lua new file mode 100644 index 0000000000..1e5ed5f7f5 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroExpeditedTransactionSupportedProtocolVersions.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AliroExpeditedTransactionSupportedProtocolVersions = { + ID = 0x0083, + NAME = "AliroExpeditedTransactionSupportedProtocolVersions", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.OctetString1", +} + +function AliroExpeditedTransactionSupportedProtocolVersions:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AliroExpeditedTransactionSupportedProtocolVersions.element_type) + end +end + +function AliroExpeditedTransactionSupportedProtocolVersions:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AliroExpeditedTransactionSupportedProtocolVersions:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroExpeditedTransactionSupportedProtocolVersions:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroExpeditedTransactionSupportedProtocolVersions:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AliroExpeditedTransactionSupportedProtocolVersions:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AliroExpeditedTransactionSupportedProtocolVersions:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AliroExpeditedTransactionSupportedProtocolVersions, {__call = AliroExpeditedTransactionSupportedProtocolVersions.new_value, __index = AliroExpeditedTransactionSupportedProtocolVersions.base_type}) +return AliroExpeditedTransactionSupportedProtocolVersions \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroGroupResolvingKey.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroGroupResolvingKey.lua new file mode 100644 index 0000000000..517cc25aeb --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroGroupResolvingKey.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AliroGroupResolvingKey = { + ID = 0x0084, + NAME = "AliroGroupResolvingKey", + base_type = require "st.matter.data_types.OctetString1", +} + +function AliroGroupResolvingKey:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AliroGroupResolvingKey:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroGroupResolvingKey:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroGroupResolvingKey:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AliroGroupResolvingKey:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AliroGroupResolvingKey:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AliroGroupResolvingKey, {__call = AliroGroupResolvingKey.new_value, __index = AliroGroupResolvingKey.base_type}) +return AliroGroupResolvingKey \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupIdentifier.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupIdentifier.lua new file mode 100644 index 0000000000..bce6f2c049 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupIdentifier.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AliroReaderGroupIdentifier = { + ID = 0x0081, + NAME = "AliroReaderGroupIdentifier", + base_type = require "st.matter.data_types.OctetString1", +} + +function AliroReaderGroupIdentifier:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AliroReaderGroupIdentifier:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroReaderGroupIdentifier:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroReaderGroupIdentifier:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AliroReaderGroupIdentifier:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AliroReaderGroupIdentifier:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AliroReaderGroupIdentifier, {__call = AliroReaderGroupIdentifier.new_value, __index = AliroReaderGroupIdentifier.base_type}) +return AliroReaderGroupIdentifier \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupSubIdentifier.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupSubIdentifier.lua new file mode 100644 index 0000000000..867dbcce93 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderGroupSubIdentifier.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AliroReaderGroupSubIdentifier = { + ID = 0x0082, + NAME = "AliroReaderGroupSubIdentifier", + base_type = require "st.matter.data_types.OctetString1", +} + +function AliroReaderGroupSubIdentifier:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AliroReaderGroupSubIdentifier:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroReaderGroupSubIdentifier:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroReaderGroupSubIdentifier:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AliroReaderGroupSubIdentifier:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AliroReaderGroupSubIdentifier:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AliroReaderGroupSubIdentifier, {__call = AliroReaderGroupSubIdentifier.new_value, __index = AliroReaderGroupSubIdentifier.base_type}) +return AliroReaderGroupSubIdentifier \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderVerificationKey.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderVerificationKey.lua new file mode 100644 index 0000000000..ccf8ac37a0 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AliroReaderVerificationKey.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AliroReaderVerificationKey = { + ID = 0x0080, + NAME = "AliroReaderVerificationKey", + base_type = require "st.matter.data_types.OctetString1", +} + +function AliroReaderVerificationKey:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AliroReaderVerificationKey:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroReaderVerificationKey:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AliroReaderVerificationKey:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AliroReaderVerificationKey:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AliroReaderVerificationKey:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AliroReaderVerificationKey, {__call = AliroReaderVerificationKey.new_value, __index = AliroReaderVerificationKey.base_type}) +return AliroReaderVerificationKey \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..c191ec3d5a --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/AttributeList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AttributeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AttributeList.element_type) + end +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) +return AttributeList \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/DoorState.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/DoorState.lua new file mode 100644 index 0000000000..59fd6cbe79 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/DoorState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local DoorState = { + ID = 0x0003, + NAME = "DoorState", + base_type = require "DoorLock.types.DoorStateEnum", +} + +function DoorState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function DoorState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function DoorState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function DoorState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function DoorState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function DoorState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(DoorState, {__call = DoorState.new_value, __index = DoorState.base_type}) +return DoorState \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/EventList.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/EventList.lua new file mode 100644 index 0000000000..616bbf92bc --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/EventList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local EventList = { + ID = 0xFFFA, + NAME = "EventList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function EventList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, EventList.element_type) + end +end + +function EventList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function EventList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EventList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EventList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EventList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EventList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(EventList, {__call = EventList.new_value, __index = EventList.base_type}) +return EventList \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/LockState.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/LockState.lua new file mode 100644 index 0000000000..5d7615e2c6 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/LockState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LockState = { + ID = 0x0000, + NAME = "LockState", + base_type = require "DoorLock.types.DlLockState", +} + +function LockState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function LockState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LockState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LockState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LockState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LockState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LockState, {__call = LockState.new_value, __index = LockState.base_type}) +return LockState \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MaxPINCodeLength.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MaxPINCodeLength.lua new file mode 100644 index 0000000000..efdef6065c --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MaxPINCodeLength.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MaxPINCodeLength = { + ID = 0x0017, + NAME = "MaxPINCodeLength", + base_type = require "st.matter.data_types.Uint8", +} + +function MaxPINCodeLength:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function MaxPINCodeLength:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MaxPINCodeLength:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MaxPINCodeLength:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MaxPINCodeLength:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MaxPINCodeLength:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MaxPINCodeLength, {__call = MaxPINCodeLength.new_value, __index = MaxPINCodeLength.base_type}) +return MaxPINCodeLength \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MinPINCodeLength.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MinPINCodeLength.lua new file mode 100644 index 0000000000..f94a777a64 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/MinPINCodeLength.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MinPINCodeLength = { + ID = 0x0018, + NAME = "MinPINCodeLength", + base_type = require "st.matter.data_types.Uint8", +} + +function MinPINCodeLength:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function MinPINCodeLength:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MinPINCodeLength:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MinPINCodeLength:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MinPINCodeLength:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MinPINCodeLength:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MinPINCodeLength, {__call = MinPINCodeLength.new_value, __index = MinPINCodeLength.base_type}) +return MinPINCodeLength \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroCredentialIssuerKeysSupported.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroCredentialIssuerKeysSupported.lua new file mode 100644 index 0000000000..f28e356e3c --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroCredentialIssuerKeysSupported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfAliroCredentialIssuerKeysSupported = { + ID = 0x0087, + NAME = "NumberOfAliroCredentialIssuerKeysSupported", + base_type = require "st.matter.data_types.Uint16", +} + +function NumberOfAliroCredentialIssuerKeysSupported:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function NumberOfAliroCredentialIssuerKeysSupported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfAliroCredentialIssuerKeysSupported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfAliroCredentialIssuerKeysSupported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfAliroCredentialIssuerKeysSupported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfAliroCredentialIssuerKeysSupported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(NumberOfAliroCredentialIssuerKeysSupported, {__call = NumberOfAliroCredentialIssuerKeysSupported.new_value, __index = NumberOfAliroCredentialIssuerKeysSupported.base_type}) +return NumberOfAliroCredentialIssuerKeysSupported \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroEndpointKeysSupported.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroEndpointKeysSupported.lua new file mode 100644 index 0000000000..b40d6c7df4 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfAliroEndpointKeysSupported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfAliroEndpointKeysSupported = { + ID = 0x0088, + NAME = "NumberOfAliroEndpointKeysSupported", + base_type = require "st.matter.data_types.Uint16", +} + +function NumberOfAliroEndpointKeysSupported:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function NumberOfAliroEndpointKeysSupported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfAliroEndpointKeysSupported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfAliroEndpointKeysSupported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfAliroEndpointKeysSupported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfAliroEndpointKeysSupported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(NumberOfAliroEndpointKeysSupported, {__call = NumberOfAliroEndpointKeysSupported.new_value, __index = NumberOfAliroEndpointKeysSupported.base_type}) +return NumberOfAliroEndpointKeysSupported \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfCredentialsSupportedPerUser.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfCredentialsSupportedPerUser.lua new file mode 100644 index 0000000000..48f08cf19c --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfCredentialsSupportedPerUser.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfCredentialsSupportedPerUser = { + ID = 0x001C, + NAME = "NumberOfCredentialsSupportedPerUser", + base_type = require "st.matter.data_types.Uint8", +} + +function NumberOfCredentialsSupportedPerUser:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function NumberOfCredentialsSupportedPerUser:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfCredentialsSupportedPerUser:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfCredentialsSupportedPerUser:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfCredentialsSupportedPerUser:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfCredentialsSupportedPerUser:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(NumberOfCredentialsSupportedPerUser, {__call = NumberOfCredentialsSupportedPerUser.new_value, __index = NumberOfCredentialsSupportedPerUser.base_type}) +return NumberOfCredentialsSupportedPerUser \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfPINUsersSupported.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfPINUsersSupported.lua new file mode 100644 index 0000000000..c9a2da141d --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfPINUsersSupported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfPINUsersSupported = { + ID = 0x0012, + NAME = "NumberOfPINUsersSupported", + base_type = require "st.matter.data_types.Uint16", +} + +function NumberOfPINUsersSupported:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function NumberOfPINUsersSupported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfPINUsersSupported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfPINUsersSupported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfPINUsersSupported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfPINUsersSupported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(NumberOfPINUsersSupported, {__call = NumberOfPINUsersSupported.new_value, __index = NumberOfPINUsersSupported.base_type}) +return NumberOfPINUsersSupported \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfTotalUsersSupported.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfTotalUsersSupported.lua new file mode 100644 index 0000000000..ff0cd93791 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfTotalUsersSupported.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfTotalUsersSupported = { + ID = 0x0011, + NAME = "NumberOfTotalUsersSupported", + base_type = require "st.matter.data_types.Uint16", +} + +function NumberOfTotalUsersSupported:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function NumberOfTotalUsersSupported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfTotalUsersSupported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfTotalUsersSupported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfTotalUsersSupported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfTotalUsersSupported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(NumberOfTotalUsersSupported, {__call = NumberOfTotalUsersSupported.new_value, __index = NumberOfTotalUsersSupported.base_type}) +return NumberOfTotalUsersSupported \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfWeekDaySchedulesSupportedPerUser.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfWeekDaySchedulesSupportedPerUser.lua new file mode 100644 index 0000000000..f5878babee --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfWeekDaySchedulesSupportedPerUser.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfWeekDaySchedulesSupportedPerUser = { + ID = 0x0014, + NAME = "NumberOfWeekDaySchedulesSupportedPerUser", + base_type = require "st.matter.data_types.Uint8", +} + +function NumberOfWeekDaySchedulesSupportedPerUser:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function NumberOfWeekDaySchedulesSupportedPerUser:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfWeekDaySchedulesSupportedPerUser:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfWeekDaySchedulesSupportedPerUser:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfWeekDaySchedulesSupportedPerUser:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfWeekDaySchedulesSupportedPerUser:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(NumberOfWeekDaySchedulesSupportedPerUser, {__call = NumberOfWeekDaySchedulesSupportedPerUser.new_value, __index = NumberOfWeekDaySchedulesSupportedPerUser.base_type}) +return NumberOfWeekDaySchedulesSupportedPerUser \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfYearDaySchedulesSupportedPerUser.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfYearDaySchedulesSupportedPerUser.lua new file mode 100644 index 0000000000..4039dab170 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/NumberOfYearDaySchedulesSupportedPerUser.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local NumberOfYearDaySchedulesSupportedPerUser = { + ID = 0x0015, + NAME = "NumberOfYearDaySchedulesSupportedPerUser", + base_type = require "st.matter.data_types.Uint8", +} + +function NumberOfYearDaySchedulesSupportedPerUser:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function NumberOfYearDaySchedulesSupportedPerUser:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfYearDaySchedulesSupportedPerUser:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function NumberOfYearDaySchedulesSupportedPerUser:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function NumberOfYearDaySchedulesSupportedPerUser:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function NumberOfYearDaySchedulesSupportedPerUser:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(NumberOfYearDaySchedulesSupportedPerUser, {__call = NumberOfYearDaySchedulesSupportedPerUser.new_value, __index = NumberOfYearDaySchedulesSupportedPerUser.base_type}) +return NumberOfYearDaySchedulesSupportedPerUser \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/OperatingMode.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/OperatingMode.lua new file mode 100644 index 0000000000..79823fb0e4 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/OperatingMode.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperatingMode = { + ID = 0x0025, + NAME = "OperatingMode", + base_type = require "DoorLock.types.OperatingModeEnum", +} + +function OperatingMode:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OperatingMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperatingMode:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function OperatingMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperatingMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperatingMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperatingMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OperatingMode, {__call = OperatingMode.new_value, __index = OperatingMode.base_type}) +return OperatingMode \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/RequirePINforRemoteOperation.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/RequirePINforRemoteOperation.lua new file mode 100644 index 0000000000..6512d9e530 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/RequirePINforRemoteOperation.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local RequirePINforRemoteOperation = { + ID = 0x0033, + NAME = "RequirePINforRemoteOperation", + base_type = require "st.matter.data_types.Boolean", +} + +function RequirePINforRemoteOperation:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function RequirePINforRemoteOperation:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function RequirePINforRemoteOperation:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function RequirePINforRemoteOperation:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function RequirePINforRemoteOperation:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function RequirePINforRemoteOperation:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function RequirePINforRemoteOperation:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(RequirePINforRemoteOperation, {__call = RequirePINforRemoteOperation.new_value, __index = RequirePINforRemoteOperation.base_type}) +return RequirePINforRemoteOperation \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SendPINOverTheAir.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SendPINOverTheAir.lua new file mode 100644 index 0000000000..5b52a9b583 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SendPINOverTheAir.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SendPINOverTheAir = { + ID = 0x0032, + NAME = "SendPINOverTheAir", + base_type = require "st.matter.data_types.Boolean", +} + +function SendPINOverTheAir:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SendPINOverTheAir:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SendPINOverTheAir:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function SendPINOverTheAir:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SendPINOverTheAir:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SendPINOverTheAir:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SendPINOverTheAir:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SendPINOverTheAir, {__call = SendPINOverTheAir.new_value, __index = SendPINOverTheAir.base_type}) +return SendPINOverTheAir \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SupportedOperatingModes.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SupportedOperatingModes.lua new file mode 100644 index 0000000000..7859d8346e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/SupportedOperatingModes.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedOperatingModes = { + ID = 0x0026, + NAME = "SupportedOperatingModes", + base_type = require "DoorLock.types.DlSupportedOperatingModes", +} + +function SupportedOperatingModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedOperatingModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedOperatingModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedOperatingModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedOperatingModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedOperatingModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedOperatingModes, {__call = SupportedOperatingModes.new_value, __index = SupportedOperatingModes.base_type}) +return SupportedOperatingModes \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/UserCodeTemporaryDisableTime.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/UserCodeTemporaryDisableTime.lua new file mode 100644 index 0000000000..26226e667c --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/UserCodeTemporaryDisableTime.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local UserCodeTemporaryDisableTime = { + ID = 0x0031, + NAME = "UserCodeTemporaryDisableTime", + base_type = require "st.matter.data_types.Uint8", +} + +function UserCodeTemporaryDisableTime:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function UserCodeTemporaryDisableTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function UserCodeTemporaryDisableTime:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function UserCodeTemporaryDisableTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function UserCodeTemporaryDisableTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function UserCodeTemporaryDisableTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function UserCodeTemporaryDisableTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(UserCodeTemporaryDisableTime, {__call = UserCodeTemporaryDisableTime.new_value, __index = UserCodeTemporaryDisableTime.base_type}) +return UserCodeTemporaryDisableTime \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/WrongCodeEntryLimit.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/WrongCodeEntryLimit.lua new file mode 100644 index 0000000000..2109ef3945 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/WrongCodeEntryLimit.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local WrongCodeEntryLimit = { + ID = 0x0030, + NAME = "WrongCodeEntryLimit", + base_type = require "st.matter.data_types.Uint8", +} + +function WrongCodeEntryLimit:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function WrongCodeEntryLimit:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function WrongCodeEntryLimit:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function WrongCodeEntryLimit:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function WrongCodeEntryLimit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function WrongCodeEntryLimit:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function WrongCodeEntryLimit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(WrongCodeEntryLimit, {__call = WrongCodeEntryLimit.new_value, __index = WrongCodeEntryLimit.base_type}) +return WrongCodeEntryLimit \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/init.lua new file mode 100644 index 0000000000..9e35b63132 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("DoorLock.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local DoorLockServerAttributes = {} + +function DoorLockServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DoorLockServerAttributes, attr_mt) + +return DoorLockServerAttributes \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearAliroReaderConfig.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearAliroReaderConfig.lua new file mode 100644 index 0000000000..007104e3ec --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearAliroReaderConfig.lua @@ -0,0 +1,91 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ClearAliroReaderConfig = {} + +ClearAliroReaderConfig.NAME = "ClearAliroReaderConfig" +ClearAliroReaderConfig.ID = 0x0029 +ClearAliroReaderConfig.field_defs = { +} + +function ClearAliroReaderConfig:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ClearAliroReaderConfig:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ClearAliroReaderConfig, + __tostring = ClearAliroReaderConfig.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function ClearAliroReaderConfig:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ClearAliroReaderConfig:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ClearAliroReaderConfig:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ClearAliroReaderConfig, {__call = ClearAliroReaderConfig.init}) + +return ClearAliroReaderConfig \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearCredential.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearCredential.lua new file mode 100644 index 0000000000..07244d15a5 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearCredential.lua @@ -0,0 +1,98 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ClearCredential = {} + +ClearCredential.NAME = "ClearCredential" +ClearCredential.ID = 0x0026 +ClearCredential.field_defs = { + { + name = "credential", + field_id = 0, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.CredentialStruct", + }, +} + +function ClearCredential:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ClearCredential:init(device, endpoint_id, credential) + local out = {} + local args = {credential} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ClearCredential, + __tostring = ClearCredential.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function ClearCredential:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ClearCredential:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ClearCredential:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ClearCredential, {__call = ClearCredential.init}) + +return ClearCredential \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearUser.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearUser.lua new file mode 100644 index 0000000000..2e5b7ba51b --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearUser.lua @@ -0,0 +1,98 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ClearUser = {} + +ClearUser.NAME = "ClearUser" +ClearUser.ID = 0x001D +ClearUser.field_defs = { + { + name = "user_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function ClearUser:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ClearUser:init(device, endpoint_id, user_index) + local out = {} + local args = {user_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ClearUser, + __tostring = ClearUser.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function ClearUser:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ClearUser:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ClearUser:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ClearUser, {__call = ClearUser.init}) + +return ClearUser \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearWeekDaySchedule.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearWeekDaySchedule.lua new file mode 100644 index 0000000000..30ddd45933 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearWeekDaySchedule.lua @@ -0,0 +1,104 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ClearWeekDaySchedule = {} + +ClearWeekDaySchedule.NAME = "ClearWeekDaySchedule" +ClearWeekDaySchedule.ID = 0x000D +ClearWeekDaySchedule.field_defs = { + { + name = "week_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function ClearWeekDaySchedule:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ClearWeekDaySchedule:init(device, endpoint_id, week_day_index, user_index) + local out = {} + local args = {week_day_index, user_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ClearWeekDaySchedule, + __tostring = ClearWeekDaySchedule.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ClearWeekDaySchedule:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ClearWeekDaySchedule:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ClearWeekDaySchedule:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ClearWeekDaySchedule, {__call = ClearWeekDaySchedule.init}) + +return ClearWeekDaySchedule \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearYearDaySchedule.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearYearDaySchedule.lua new file mode 100644 index 0000000000..ba9380b114 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/ClearYearDaySchedule.lua @@ -0,0 +1,104 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ClearYearDaySchedule = {} + +ClearYearDaySchedule.NAME = "ClearYearDaySchedule" +ClearYearDaySchedule.ID = 0x0010 +ClearYearDaySchedule.field_defs = { + { + name = "year_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function ClearYearDaySchedule:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ClearYearDaySchedule:init(device, endpoint_id, year_day_index, user_index) + local out = {} + local args = {year_day_index, user_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ClearYearDaySchedule, + __tostring = ClearYearDaySchedule.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ClearYearDaySchedule:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ClearYearDaySchedule:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ClearYearDaySchedule:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ClearYearDaySchedule, {__call = ClearYearDaySchedule.init}) + +return ClearYearDaySchedule \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetCredentialStatus.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetCredentialStatus.lua new file mode 100644 index 0000000000..9b1d37355e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetCredentialStatus.lua @@ -0,0 +1,86 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetCredentialStatus = {} + +GetCredentialStatus.NAME = "GetCredentialStatus" +GetCredentialStatus.ID = 0x0024 +GetCredentialStatus.field_defs = { + { + name = "credential", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.CredentialStruct", + }, +} + +function GetCredentialStatus:init(device, endpoint_id, credential) + local out = {} + local args = {credential} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetCredentialStatus, + __tostring = GetCredentialStatus.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function GetCredentialStatus:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetCredentialStatus:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetCredentialStatus:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetCredentialStatus, {__call = GetCredentialStatus.init}) + +return GetCredentialStatus \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetUser.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetUser.lua new file mode 100644 index 0000000000..ecdf60bdcd --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetUser.lua @@ -0,0 +1,86 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetUser = {} + +GetUser.NAME = "GetUser" +GetUser.ID = 0x001B +GetUser.field_defs = { + { + name = "user_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function GetUser:init(device, endpoint_id, user_index) + local out = {} + local args = {user_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetUser, + __tostring = GetUser.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function GetUser:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetUser:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetUser:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetUser, {__call = GetUser.init}) + +return GetUser \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetWeekDaySchedule.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetWeekDaySchedule.lua new file mode 100644 index 0000000000..817c57cda4 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetWeekDaySchedule.lua @@ -0,0 +1,93 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetWeekDaySchedule = {} + +GetWeekDaySchedule.NAME = "GetWeekDaySchedule" +GetWeekDaySchedule.ID = 0x000C +GetWeekDaySchedule.field_defs = { + { + name = "week_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function GetWeekDaySchedule:init(device, endpoint_id, week_day_index, user_index) + local out = {} + local args = {week_day_index, user_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetWeekDaySchedule, + __tostring = GetWeekDaySchedule.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function GetWeekDaySchedule:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetWeekDaySchedule:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetWeekDaySchedule:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetWeekDaySchedule, {__call = GetWeekDaySchedule.init}) + +return GetWeekDaySchedule \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetYearDaySchedule.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetYearDaySchedule.lua new file mode 100644 index 0000000000..35ea38a3f8 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/GetYearDaySchedule.lua @@ -0,0 +1,93 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetYearDaySchedule = {} + +GetYearDaySchedule.NAME = "GetYearDaySchedule" +GetYearDaySchedule.ID = 0x000F +GetYearDaySchedule.field_defs = { + { + name = "year_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function GetYearDaySchedule:init(device, endpoint_id, year_day_index, user_index) + local out = {} + local args = {year_day_index, user_index} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GetYearDaySchedule, + __tostring = GetYearDaySchedule.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function GetYearDaySchedule:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetYearDaySchedule:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GetYearDaySchedule:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetYearDaySchedule, {__call = GetYearDaySchedule.init}) + +return GetYearDaySchedule \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/LockDoor.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/LockDoor.lua new file mode 100644 index 0000000000..9440546cab --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/LockDoor.lua @@ -0,0 +1,98 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LockDoor = {} + +LockDoor.NAME = "LockDoor" +LockDoor.ID = 0x0000 +LockDoor.field_defs = { + { + name = "pin_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.OctetString1", + }, +} + +function LockDoor:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function LockDoor:init(device, endpoint_id, pin_code) + local out = {} + local args = {pin_code} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = LockDoor, + __tostring = LockDoor.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function LockDoor:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LockDoor:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function LockDoor:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LockDoor, {__call = LockDoor.init}) + +return LockDoor \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetAliroReaderConfig.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetAliroReaderConfig.lua new file mode 100644 index 0000000000..8317db64b7 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetAliroReaderConfig.lua @@ -0,0 +1,119 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetAliroReaderConfig = {} + +SetAliroReaderConfig.NAME = "SetAliroReaderConfig" +SetAliroReaderConfig.ID = 0x0028 +SetAliroReaderConfig.field_defs = { + { + name = "signing_key", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.OctetString1", + }, + { + name = "verification_key", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.OctetString1", + }, + { + name = "group_identifier", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.OctetString1", + }, + { + name = "group_resolving_key", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.OctetString1", + }, +} + +function SetAliroReaderConfig:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SetAliroReaderConfig:init(device, endpoint_id, signing_key, verification_key, group_identifier, group_resolving_key) + local out = {} + local args = {signing_key, verification_key, group_identifier, group_resolving_key} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetAliroReaderConfig, + __tostring = SetAliroReaderConfig.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function SetAliroReaderConfig:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetAliroReaderConfig:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetAliroReaderConfig:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetAliroReaderConfig, {__call = SetAliroReaderConfig.init}) + +return SetAliroReaderConfig \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetCredential.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetCredential.lua new file mode 100644 index 0000000000..ad024b9619 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetCredential.lua @@ -0,0 +1,122 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetCredential = {} + +SetCredential.NAME = "SetCredential" +SetCredential.ID = 0x0022 +SetCredential.field_defs = { + { + name = "operation_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DataOperationTypeEnum", + }, + { + name = "credential", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.CredentialStruct", + }, + { + name = "credential_data", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.OctetString2", + }, + { + name = "user_index", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "user_status", + field_id = 4, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.UserStatusEnum", + }, + { + name = "user_type", + field_id = 5, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.UserTypeEnum", + }, +} + +function SetCredential:init(device, endpoint_id, operation_type, credential, credential_data, user_index, user_status, user_type) + local out = {} + local args = {operation_type, credential, credential_data, user_index, user_status, user_type} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetCredential, + __tostring = SetCredential.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function SetCredential:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetCredential:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetCredential:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetCredential, {__call = SetCredential.init}) + +return SetCredential \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetUser.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetUser.lua new file mode 100644 index 0000000000..a115db2b57 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetUser.lua @@ -0,0 +1,140 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetUser = {} + +SetUser.NAME = "SetUser" +SetUser.ID = 0x001A +SetUser.field_defs = { + { + name = "operation_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DataOperationTypeEnum", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "user_name", + field_id = 2, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "user_uniqueid", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "user_status", + field_id = 4, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.UserStatusEnum", + }, + { + name = "user_type", + field_id = 5, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.UserTypeEnum", + }, + { + name = "credential_rule", + field_id = 6, + is_nullable = true, + is_optional = false, + data_type = require "DoorLock.types.CredentialRuleEnum", + }, +} + +function SetUser:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SetUser:init(device, endpoint_id, operation_type, user_index, user_name, user_uniqueid, user_status, user_type, credential_rule) + local out = {} + local args = {operation_type, user_index, user_name, user_uniqueid, user_status, user_type, credential_rule} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetUser, + __tostring = SetUser.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function SetUser:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetUser:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetUser:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetUser, {__call = SetUser.init}) + +return SetUser \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetWeekDaySchedule.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetWeekDaySchedule.lua new file mode 100644 index 0000000000..c13f677303 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetWeekDaySchedule.lua @@ -0,0 +1,139 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetWeekDaySchedule = {} + +SetWeekDaySchedule.NAME = "SetWeekDaySchedule" +SetWeekDaySchedule.ID = 0x000B +SetWeekDaySchedule.field_defs = { + { + name = "week_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "days_mask", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DaysMaskMap", + }, + { + name = "start_hour", + field_id = 3, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "start_minute", + field_id = 4, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "end_hour", + field_id = 5, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "end_minute", + field_id = 6, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function SetWeekDaySchedule:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SetWeekDaySchedule:init(device, endpoint_id, week_day_index, user_index, days_mask, start_hour, start_minute, end_hour, end_minute) + local out = {} + local args = {week_day_index, user_index, days_mask, start_hour, start_minute, end_hour, end_minute} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetWeekDaySchedule, + __tostring = SetWeekDaySchedule.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function SetWeekDaySchedule:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetWeekDaySchedule:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetWeekDaySchedule:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetWeekDaySchedule, {__call = SetWeekDaySchedule.init}) + +return SetWeekDaySchedule \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetYearDaySchedule.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetYearDaySchedule.lua new file mode 100644 index 0000000000..0e65d0027e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/SetYearDaySchedule.lua @@ -0,0 +1,118 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetYearDaySchedule = {} + +SetYearDaySchedule.NAME = "SetYearDaySchedule" +SetYearDaySchedule.ID = 0x000E +SetYearDaySchedule.field_defs = { + { + name = "year_day_index", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "user_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "local_start_time", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "local_end_time", + field_id = 3, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, +} + +function SetYearDaySchedule:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SetYearDaySchedule:init(device, endpoint_id, year_day_index, user_index, local_start_time, local_end_time) + local out = {} + local args = {year_day_index, user_index, local_start_time, local_end_time} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetYearDaySchedule, + __tostring = SetYearDaySchedule.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function SetYearDaySchedule:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetYearDaySchedule:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetYearDaySchedule:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetYearDaySchedule, {__call = SetYearDaySchedule.init}) + +return SetYearDaySchedule \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/UnlockDoor.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/UnlockDoor.lua new file mode 100644 index 0000000000..32bfd9fb3d --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/UnlockDoor.lua @@ -0,0 +1,98 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local UnlockDoor = {} + +UnlockDoor.NAME = "UnlockDoor" +UnlockDoor.ID = 0x0001 +UnlockDoor.field_defs = { + { + name = "pin_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.OctetString1", + }, +} + +function UnlockDoor:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --tlv + status + ) +end + +function UnlockDoor:init(device, endpoint_id, pin_code) + local out = {} + local args = {pin_code} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = UnlockDoor, + __tostring = UnlockDoor.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function UnlockDoor:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function UnlockDoor:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function UnlockDoor:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(UnlockDoor, {__call = UnlockDoor.init}) + +return UnlockDoor \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/init.lua new file mode 100644 index 0000000000..ee8db4a85f --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("DoorLock.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local DoorLockServerCommands = {} + +function DoorLockServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DoorLockServerCommands, command_mt) + +return DoorLockServerCommands \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorLockAlarm.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorLockAlarm.lua new file mode 100644 index 0000000000..da5f1ed0d1 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorLockAlarm.lua @@ -0,0 +1,100 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local DoorLockAlarm = { + ID = 0x0000, + NAME = "DoorLockAlarm", + base_type = data_types.Structure, +} + +DoorLockAlarm.field_defs = { + { + name = "alarm_code", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.AlarmCodeEnum", + }, +} + +function DoorLockAlarm:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function DoorLockAlarm:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function DoorLockAlarm:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function DoorLockAlarm:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "DoorLockAlarmEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function DoorLockAlarm:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function DoorLockAlarm:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return DoorLockAlarm \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorStateChange.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorStateChange.lua new file mode 100644 index 0000000000..da106194be --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/DoorStateChange.lua @@ -0,0 +1,100 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local DoorStateChange = { + ID = 0x0001, + NAME = "DoorStateChange", + base_type = data_types.Structure, +} + +DoorStateChange.field_defs = { + { + name = "door_state", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DoorStateEnum", + }, +} + +function DoorStateChange:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function DoorStateChange:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function DoorStateChange:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function DoorStateChange:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "DoorStateChangeEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function DoorStateChange:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function DoorStateChange:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return DoorStateChange \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperation.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperation.lua new file mode 100644 index 0000000000..8171c09c2e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperation.lua @@ -0,0 +1,136 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local LockOperation = { + ID = 0x0002, + NAME = "LockOperation", + base_type = data_types.Structure, +} + +LockOperation.field_defs = { + { + name = "lock_operation_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.LockOperationTypeEnum", + }, + { + name = "operation_source", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.OperationSourceEnum", + }, + { + name = "user_index", + field_id = 2, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "fabric_index", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "source_node", + field_id = 4, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "credentials", + field_id = 5, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Array", + element_type = require "DoorLock.types.CredentialStruct", + }, +} + +function LockOperation:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function LockOperation:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function LockOperation:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function LockOperation:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "LockOperationEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LockOperation:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function LockOperation:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return LockOperation \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperationError.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperationError.lua new file mode 100644 index 0000000000..a5363437e4 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockOperationError.lua @@ -0,0 +1,143 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local LockOperationError = { + ID = 0x0003, + NAME = "LockOperationError", + base_type = data_types.Structure, +} + +LockOperationError.field_defs = { + { + name = "lock_operation_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.LockOperationTypeEnum", + }, + { + name = "operation_source", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.OperationSourceEnum", + }, + { + name = "operation_error", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.OperationErrorEnum", + }, + { + name = "user_index", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "fabric_index", + field_id = 4, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "source_node", + field_id = 5, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "credentials", + field_id = 6, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Array", + element_type = require "DoorLock.types.CredentialStruct", + }, +} + +function LockOperationError:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function LockOperationError:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function LockOperationError:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function LockOperationError:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "LockOperationErrorEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LockOperationError:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function LockOperationError:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return LockOperationError \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockUserChange.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockUserChange.lua new file mode 100644 index 0000000000..2566f27c06 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/LockUserChange.lua @@ -0,0 +1,142 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local LockUserChange = { + ID = 0x0004, + NAME = "LockUserChange", + base_type = data_types.Structure, +} + +LockUserChange.field_defs = { + { + name = "lock_data_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.LockDataTypeEnum", + }, + { + name = "data_operation_type", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.DataOperationTypeEnum", + }, + { + name = "operation_source", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.OperationSourceEnum", + }, + { + name = "user_index", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "fabric_index", + field_id = 4, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "source_node", + field_id = 5, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "data_index", + field_id = 6, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +function LockUserChange:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function LockUserChange:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function LockUserChange:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function LockUserChange:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "LockUserChangeEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LockUserChange:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function LockUserChange:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return LockUserChange \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/server/events/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/init.lua new file mode 100644 index 0000000000..0079543a93 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/server/events/init.lua @@ -0,0 +1,23 @@ +local event_mt = {} +event_mt.__event_cache = {} +event_mt.__index = function(self, key) + if event_mt.__event_cache[key] == nil then + local req_loc = string.format("DoorLock.server.events.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + event_mt.__event_cache[key] = raw_def + end + return event_mt.__event_cache[key] +end + +local DoorLockEvents = {} + +function DoorLockEvents:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DoorLockEvents, event_mt) + +return DoorLockEvents \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/AlarmCodeEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/AlarmCodeEnum.lua new file mode 100644 index 0000000000..06460249f2 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/AlarmCodeEnum.lua @@ -0,0 +1,45 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local AlarmCodeEnum = {} +local new_mt = UintABC.new_mt({NAME = "AlarmCodeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.LOCK_JAMMED] = "LOCK_JAMMED", + [self.LOCK_FACTORY_RESET] = "LOCK_FACTORY_RESET", + [self.LOCK_RADIO_POWER_CYCLED] = "LOCK_RADIO_POWER_CYCLED", + [self.WRONG_CODE_ENTRY_LIMIT] = "WRONG_CODE_ENTRY_LIMIT", + [self.FRONT_ESCEUTCHEON_REMOVED] = "FRONT_ESCEUTCHEON_REMOVED", + [self.DOOR_FORCED_OPEN] = "DOOR_FORCED_OPEN", + [self.DOOR_AJAR] = "DOOR_AJAR", + [self.FORCED_USER] = "FORCED_USER", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.LOCK_JAMMED = 0x00 +new_mt.__index.LOCK_FACTORY_RESET = 0x01 +new_mt.__index.LOCK_RADIO_POWER_CYCLED = 0x03 +new_mt.__index.WRONG_CODE_ENTRY_LIMIT = 0x04 +new_mt.__index.FRONT_ESCEUTCHEON_REMOVED = 0x05 +new_mt.__index.DOOR_FORCED_OPEN = 0x06 +new_mt.__index.DOOR_AJAR = 0x07 +new_mt.__index.FORCED_USER = 0x08 + +AlarmCodeEnum.LOCK_JAMMED = 0x00 +AlarmCodeEnum.LOCK_FACTORY_RESET = 0x01 +AlarmCodeEnum.LOCK_RADIO_POWER_CYCLED = 0x03 +AlarmCodeEnum.WRONG_CODE_ENTRY_LIMIT = 0x04 +AlarmCodeEnum.FRONT_ESCEUTCHEON_REMOVED = 0x05 +AlarmCodeEnum.DOOR_FORCED_OPEN = 0x06 +AlarmCodeEnum.DOOR_AJAR = 0x07 +AlarmCodeEnum.FORCED_USER = 0x08 + +AlarmCodeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(AlarmCodeEnum, new_mt) + +return AlarmCodeEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialRuleEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialRuleEnum.lua new file mode 100644 index 0000000000..ead4d559b7 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialRuleEnum.lua @@ -0,0 +1,31 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local CredentialRuleEnum = {} +local new_mt = UintABC.new_mt({NAME = "CredentialRuleEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.SINGLE] = "SINGLE", + [self.DUAL] = "DUAL", + [self.TRI] = "TRI", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.SINGLE = 0x00 +new_mt.__index.DUAL = 0x01 +new_mt.__index.TRI = 0x02 + +CredentialRuleEnum.SINGLE = 0x00 +CredentialRuleEnum.DUAL = 0x01 +CredentialRuleEnum.TRI = 0x02 + +CredentialRuleEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(CredentialRuleEnum, new_mt) + +return CredentialRuleEnum + diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialStruct.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialStruct.lua new file mode 100644 index 0000000000..d58c170334 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local CredentialStruct = {} +local new_mt = StructureABC.new_mt({NAME = "CredentialStruct", ID = data_types.name_to_id_map["Structure"]}) + +CredentialStruct.field_defs = { + { + name = "credential_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "DoorLock.types.CredentialTypeEnum", + }, + { + name = "credential_index", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +CredentialStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +CredentialStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = CredentialStruct.init +new_mt.__index.serialize = CredentialStruct.serialize + +CredentialStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(CredentialStruct, new_mt) + +return CredentialStruct \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialTypeEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialTypeEnum.lua new file mode 100644 index 0000000000..0a09054948 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/CredentialTypeEnum.lua @@ -0,0 +1,48 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local CredentialTypeEnum = {} +local new_mt = UintABC.new_mt({NAME = "CredentialTypeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.PROGRAMMINGPIN] = "PROGRAMMINGPIN", + [self.PIN] = "PIN", + [self.RFID] = "RFID", + [self.FINGERPRINT] = "FINGERPRINT", + [self.FINGER_VEIN] = "FINGER_VEIN", + [self.FACE] = "FACE", + [self.ALIRO_CREDENTIAL_ISSUER_KEY] = "ALIRO_CREDENTIAL_ISSUER_KEY", + [self.ALIRO_EVICTABLE_ENDPOINT_KEY] = "ALIRO_EVICTABLE_ENDPOINT_KEY", + [self.ALIRO_NON_EVICTABLE_ENDPOINT_KEY] = "ALIRO_NON_EVICTABLE_ENDPOINT_KEY", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.PROGRAMMINGPIN = 0x00 +new_mt.__index.PIN = 0x01 +new_mt.__index.RFID = 0x02 +new_mt.__index.FINGERPRINT = 0x03 +new_mt.__index.FINGER_VEIN = 0x04 +new_mt.__index.FACE = 0x05 +new_mt.__index.ALIRO_CREDENTIAL_ISSUER_KEY = 0x06 +new_mt.__index.ALIRO_EVICTABLE_ENDPOINT_KEY = 0x07 +new_mt.__index.ALIRO_NON_EVICTABLE_ENDPOINT_KEY = 0x08 + +CredentialTypeEnum.PROGRAMMINGPIN = 0x00 +CredentialTypeEnum.PIN = 0x01 +CredentialTypeEnum.RFID = 0x02 +CredentialTypeEnum.FINGERPRINT = 0x03 +CredentialTypeEnum.FINGER_VEIN = 0x04 +CredentialTypeEnum.FACE = 0x05 +CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY = 0x06 +CredentialTypeEnum.ALIRO_EVICTABLE_ENDPOINT_KEY = 0x07 +CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY = 0x08 + +CredentialTypeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(CredentialTypeEnum, new_mt) + +return CredentialTypeEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DataOperationTypeEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DataOperationTypeEnum.lua new file mode 100644 index 0000000000..4aa540aae4 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DataOperationTypeEnum.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DataOperationTypeEnum = {} +local new_mt = UintABC.new_mt({NAME = "DataOperationTypeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.ADD] = "ADD", + [self.CLEAR] = "CLEAR", + [self.MODIFY] = "MODIFY", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.ADD = 0x00 +new_mt.__index.CLEAR = 0x01 +new_mt.__index.MODIFY = 0x02 + +DataOperationTypeEnum.ADD = 0x00 +DataOperationTypeEnum.CLEAR = 0x01 +DataOperationTypeEnum.MODIFY = 0x02 + +DataOperationTypeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DataOperationTypeEnum, new_mt) + +return DataOperationTypeEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DaysMaskMap.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DaysMaskMap.lua new file mode 100644 index 0000000000..b4c7ea7f21 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DaysMaskMap.lua @@ -0,0 +1,169 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DaysMaskMap = {} +local new_mt = UintABC.new_mt({NAME = "DaysMaskMap", ID = data_types.name_to_id_map["Uint8"]}, 1) + +DaysMaskMap.BASE_MASK = 0xFFFF +DaysMaskMap.SUNDAY = 0x0001 +DaysMaskMap.MONDAY = 0x0002 +DaysMaskMap.TUESDAY = 0x0004 +DaysMaskMap.WEDNESDAY = 0x0008 +DaysMaskMap.THURSDAY = 0x0010 +DaysMaskMap.FRIDAY = 0x0020 +DaysMaskMap.SATURDAY = 0x0040 + +DaysMaskMap.mask_fields = { + BASE_MASK = 0xFFFF, + SUNDAY = 0x0001, + MONDAY = 0x0002, + TUESDAY = 0x0004, + WEDNESDAY = 0x0008, + THURSDAY = 0x0010, + FRIDAY = 0x0020, + SATURDAY = 0x0040, +} + +DaysMaskMap.is_sunday_set = function(self) + return (self.value & self.SUNDAY) ~= 0 +end + +DaysMaskMap.set_sunday = function(self) + if self.value ~= nil then + self.value = self.value | self.SUNDAY + else + self.value = self.SUNDAY + end +end + +DaysMaskMap.unset_sunday = function(self) + self.value = self.value & (~self.SUNDAY & self.BASE_MASK) +end + +DaysMaskMap.is_monday_set = function(self) + return (self.value & self.MONDAY) ~= 0 +end + +DaysMaskMap.set_monday = function(self) + if self.value ~= nil then + self.value = self.value | self.MONDAY + else + self.value = self.MONDAY + end +end + +DaysMaskMap.unset_monday = function(self) + self.value = self.value & (~self.MONDAY & self.BASE_MASK) +end + +DaysMaskMap.is_tuesday_set = function(self) + return (self.value & self.TUESDAY) ~= 0 +end + +DaysMaskMap.set_tuesday = function(self) + if self.value ~= nil then + self.value = self.value | self.TUESDAY + else + self.value = self.TUESDAY + end +end + +DaysMaskMap.unset_tuesday = function(self) + self.value = self.value & (~self.TUESDAY & self.BASE_MASK) +end + +DaysMaskMap.is_wednesday_set = function(self) + return (self.value & self.WEDNESDAY) ~= 0 +end + +DaysMaskMap.set_wednesday = function(self) + if self.value ~= nil then + self.value = self.value | self.WEDNESDAY + else + self.value = self.WEDNESDAY + end +end + +DaysMaskMap.unset_wednesday = function(self) + self.value = self.value & (~self.WEDNESDAY & self.BASE_MASK) +end + +DaysMaskMap.is_thursday_set = function(self) + return (self.value & self.THURSDAY) ~= 0 +end + +DaysMaskMap.set_thursday = function(self) + if self.value ~= nil then + self.value = self.value | self.THURSDAY + else + self.value = self.THURSDAY + end +end + +DaysMaskMap.unset_thursday = function(self) + self.value = self.value & (~self.THURSDAY & self.BASE_MASK) +end + +DaysMaskMap.is_friday_set = function(self) + return (self.value & self.FRIDAY) ~= 0 +end + +DaysMaskMap.set_friday = function(self) + if self.value ~= nil then + self.value = self.value | self.FRIDAY + else + self.value = self.FRIDAY + end +end + +DaysMaskMap.unset_friday = function(self) + self.value = self.value & (~self.FRIDAY & self.BASE_MASK) +end + +DaysMaskMap.is_saturday_set = function(self) + return (self.value & self.SATURDAY) ~= 0 +end + +DaysMaskMap.set_saturday = function(self) + if self.value ~= nil then + self.value = self.value | self.SATURDAY + else + self.value = self.SATURDAY + end +end + +DaysMaskMap.unset_saturday = function(self) + self.value = self.value & (~self.SATURDAY & self.BASE_MASK) +end + +DaysMaskMap.mask_methods = { + is_sunday_set = DaysMaskMap.is_sunday_set, + set_sunday = DaysMaskMap.set_sunday, + unset_sunday = DaysMaskMap.unset_sunday, + is_monday_set = DaysMaskMap.is_monday_set, + set_monday = DaysMaskMap.set_monday, + unset_monday = DaysMaskMap.unset_monday, + is_tuesday_set = DaysMaskMap.is_tuesday_set, + set_tuesday = DaysMaskMap.set_tuesday, + unset_tuesday = DaysMaskMap.unset_tuesday, + is_wednesday_set = DaysMaskMap.is_wednesday_set, + set_wednesday = DaysMaskMap.set_wednesday, + unset_wednesday = DaysMaskMap.unset_wednesday, + is_thursday_set = DaysMaskMap.is_thursday_set, + set_thursday = DaysMaskMap.set_thursday, + unset_thursday = DaysMaskMap.unset_thursday, + is_friday_set = DaysMaskMap.is_friday_set, + set_friday = DaysMaskMap.set_friday, + unset_friday = DaysMaskMap.unset_friday, + is_saturday_set = DaysMaskMap.is_saturday_set, + set_saturday = DaysMaskMap.set_saturday, + unset_saturday = DaysMaskMap.unset_saturday, +} + +DaysMaskMap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DaysMaskMap, new_mt) + +return DaysMaskMap \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadOperationEventMask.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadOperationEventMask.lua new file mode 100644 index 0000000000..06dd9b829b --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadOperationEventMask.lua @@ -0,0 +1,190 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlKeypadOperationEventMask = {} +local new_mt = UintABC.new_mt({NAME = "DlKeypadOperationEventMask", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlKeypadOperationEventMask.BASE_MASK = 0xFFFF +DlKeypadOperationEventMask.UNKNOWN = 0x0001 +DlKeypadOperationEventMask.LOCK = 0x0002 +DlKeypadOperationEventMask.UNLOCK = 0x0004 +DlKeypadOperationEventMask.LOCK_INVALIDPIN = 0x0008 +DlKeypadOperationEventMask.LOCK_INVALID_SCHEDULE = 0x0010 +DlKeypadOperationEventMask.UNLOCK_INVALID_CODE = 0x0020 +DlKeypadOperationEventMask.UNLOCK_INVALID_SCHEDULE = 0x0040 +DlKeypadOperationEventMask.NON_ACCESS_USER_OP_EVENT = 0x0080 + +DlKeypadOperationEventMask.mask_fields = { + BASE_MASK = 0xFFFF, + UNKNOWN = 0x0001, + LOCK = 0x0002, + UNLOCK = 0x0004, + LOCK_INVALIDPIN = 0x0008, + LOCK_INVALID_SCHEDULE = 0x0010, + UNLOCK_INVALID_CODE = 0x0020, + UNLOCK_INVALID_SCHEDULE = 0x0040, + NON_ACCESS_USER_OP_EVENT = 0x0080, +} + +DlKeypadOperationEventMask.is_unknown_set = function(self) + return (self.value & self.UNKNOWN) ~= 0 +end + +DlKeypadOperationEventMask.set_unknown = function(self) + if self.value ~= nil then + self.value = self.value | self.UNKNOWN + else + self.value = self.UNKNOWN + end +end + +DlKeypadOperationEventMask.unset_unknown = function(self) + self.value = self.value & (~self.UNKNOWN & self.BASE_MASK) +end + +DlKeypadOperationEventMask.is_lock_set = function(self) + return (self.value & self.LOCK) ~= 0 +end + +DlKeypadOperationEventMask.set_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK + else + self.value = self.LOCK + end +end + +DlKeypadOperationEventMask.unset_lock = function(self) + self.value = self.value & (~self.LOCK & self.BASE_MASK) +end + +DlKeypadOperationEventMask.is_unlock_set = function(self) + return (self.value & self.UNLOCK) ~= 0 +end + +DlKeypadOperationEventMask.set_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK + else + self.value = self.UNLOCK + end +end + +DlKeypadOperationEventMask.unset_unlock = function(self) + self.value = self.value & (~self.UNLOCK & self.BASE_MASK) +end + +DlKeypadOperationEventMask.is_lock_invalidpin_set = function(self) + return (self.value & self.LOCK_INVALIDPIN) ~= 0 +end + +DlKeypadOperationEventMask.set_lock_invalidpin = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK_INVALIDPIN + else + self.value = self.LOCK_INVALIDPIN + end +end + +DlKeypadOperationEventMask.unset_lock_invalidpin = function(self) + self.value = self.value & (~self.LOCK_INVALIDPIN & self.BASE_MASK) +end + +DlKeypadOperationEventMask.is_lock_invalid_schedule_set = function(self) + return (self.value & self.LOCK_INVALID_SCHEDULE) ~= 0 +end + +DlKeypadOperationEventMask.set_lock_invalid_schedule = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK_INVALID_SCHEDULE + else + self.value = self.LOCK_INVALID_SCHEDULE + end +end + +DlKeypadOperationEventMask.unset_lock_invalid_schedule = function(self) + self.value = self.value & (~self.LOCK_INVALID_SCHEDULE & self.BASE_MASK) +end + +DlKeypadOperationEventMask.is_unlock_invalid_code_set = function(self) + return (self.value & self.UNLOCK_INVALID_CODE) ~= 0 +end + +DlKeypadOperationEventMask.set_unlock_invalid_code = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK_INVALID_CODE + else + self.value = self.UNLOCK_INVALID_CODE + end +end + +DlKeypadOperationEventMask.unset_unlock_invalid_code = function(self) + self.value = self.value & (~self.UNLOCK_INVALID_CODE & self.BASE_MASK) +end + +DlKeypadOperationEventMask.is_unlock_invalid_schedule_set = function(self) + return (self.value & self.UNLOCK_INVALID_SCHEDULE) ~= 0 +end + +DlKeypadOperationEventMask.set_unlock_invalid_schedule = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK_INVALID_SCHEDULE + else + self.value = self.UNLOCK_INVALID_SCHEDULE + end +end + +DlKeypadOperationEventMask.unset_unlock_invalid_schedule = function(self) + self.value = self.value & (~self.UNLOCK_INVALID_SCHEDULE & self.BASE_MASK) +end + +DlKeypadOperationEventMask.is_non_access_user_op_event_set = function(self) + return (self.value & self.NON_ACCESS_USER_OP_EVENT) ~= 0 +end + +DlKeypadOperationEventMask.set_non_access_user_op_event = function(self) + if self.value ~= nil then + self.value = self.value | self.NON_ACCESS_USER_OP_EVENT + else + self.value = self.NON_ACCESS_USER_OP_EVENT + end +end + +DlKeypadOperationEventMask.unset_non_access_user_op_event = function(self) + self.value = self.value & (~self.NON_ACCESS_USER_OP_EVENT & self.BASE_MASK) +end + +DlKeypadOperationEventMask.mask_methods = { + is_unknown_set = DlKeypadOperationEventMask.is_unknown_set, + set_unknown = DlKeypadOperationEventMask.set_unknown, + unset_unknown = DlKeypadOperationEventMask.unset_unknown, + is_lock_set = DlKeypadOperationEventMask.is_lock_set, + set_lock = DlKeypadOperationEventMask.set_lock, + unset_lock = DlKeypadOperationEventMask.unset_lock, + is_unlock_set = DlKeypadOperationEventMask.is_unlock_set, + set_unlock = DlKeypadOperationEventMask.set_unlock, + unset_unlock = DlKeypadOperationEventMask.unset_unlock, + is_lock_invalidpin_set = DlKeypadOperationEventMask.is_lock_invalidpin_set, + set_lock_invalidpin = DlKeypadOperationEventMask.set_lock_invalidpin, + unset_lock_invalidpin = DlKeypadOperationEventMask.unset_lock_invalidpin, + is_lock_invalid_schedule_set = DlKeypadOperationEventMask.is_lock_invalid_schedule_set, + set_lock_invalid_schedule = DlKeypadOperationEventMask.set_lock_invalid_schedule, + unset_lock_invalid_schedule = DlKeypadOperationEventMask.unset_lock_invalid_schedule, + is_unlock_invalid_code_set = DlKeypadOperationEventMask.is_unlock_invalid_code_set, + set_unlock_invalid_code = DlKeypadOperationEventMask.set_unlock_invalid_code, + unset_unlock_invalid_code = DlKeypadOperationEventMask.unset_unlock_invalid_code, + is_unlock_invalid_schedule_set = DlKeypadOperationEventMask.is_unlock_invalid_schedule_set, + set_unlock_invalid_schedule = DlKeypadOperationEventMask.set_unlock_invalid_schedule, + unset_unlock_invalid_schedule = DlKeypadOperationEventMask.unset_unlock_invalid_schedule, + is_non_access_user_op_event_set = DlKeypadOperationEventMask.is_non_access_user_op_event_set, + set_non_access_user_op_event = DlKeypadOperationEventMask.set_non_access_user_op_event, + unset_non_access_user_op_event = DlKeypadOperationEventMask.unset_non_access_user_op_event, +} + +DlKeypadOperationEventMask.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlKeypadOperationEventMask, new_mt) + +return DlKeypadOperationEventMask \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadProgrammingEventMask.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadProgrammingEventMask.lua new file mode 100644 index 0000000000..d7b3c1c6f6 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlKeypadProgrammingEventMask.lua @@ -0,0 +1,127 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlKeypadProgrammingEventMask = {} +local new_mt = UintABC.new_mt({NAME = "DlKeypadProgrammingEventMask", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlKeypadProgrammingEventMask.BASE_MASK = 0xFFFF +DlKeypadProgrammingEventMask.UNKNOWN = 0x0001 +DlKeypadProgrammingEventMask.PROGRAMMINGPIN_CHANGED = 0x0002 +DlKeypadProgrammingEventMask.PIN_ADDED = 0x0004 +DlKeypadProgrammingEventMask.PIN_CLEARED = 0x0008 +DlKeypadProgrammingEventMask.PIN_CHANGED = 0x0010 + +DlKeypadProgrammingEventMask.mask_fields = { + BASE_MASK = 0xFFFF, + UNKNOWN = 0x0001, + PROGRAMMINGPIN_CHANGED = 0x0002, + PIN_ADDED = 0x0004, + PIN_CLEARED = 0x0008, + PIN_CHANGED = 0x0010, +} + +DlKeypadProgrammingEventMask.is_unknown_set = function(self) + return (self.value & self.UNKNOWN) ~= 0 +end + +DlKeypadProgrammingEventMask.set_unknown = function(self) + if self.value ~= nil then + self.value = self.value | self.UNKNOWN + else + self.value = self.UNKNOWN + end +end + +DlKeypadProgrammingEventMask.unset_unknown = function(self) + self.value = self.value & (~self.UNKNOWN & self.BASE_MASK) +end + +DlKeypadProgrammingEventMask.is_programmingpin_changed_set = function(self) + return (self.value & self.PROGRAMMINGPIN_CHANGED) ~= 0 +end + +DlKeypadProgrammingEventMask.set_programmingpin_changed = function(self) + if self.value ~= nil then + self.value = self.value | self.PROGRAMMINGPIN_CHANGED + else + self.value = self.PROGRAMMINGPIN_CHANGED + end +end + +DlKeypadProgrammingEventMask.unset_programmingpin_changed = function(self) + self.value = self.value & (~self.PROGRAMMINGPIN_CHANGED & self.BASE_MASK) +end + +DlKeypadProgrammingEventMask.is_pin_added_set = function(self) + return (self.value & self.PIN_ADDED) ~= 0 +end + +DlKeypadProgrammingEventMask.set_pin_added = function(self) + if self.value ~= nil then + self.value = self.value | self.PIN_ADDED + else + self.value = self.PIN_ADDED + end +end + +DlKeypadProgrammingEventMask.unset_pin_added = function(self) + self.value = self.value & (~self.PIN_ADDED & self.BASE_MASK) +end + +DlKeypadProgrammingEventMask.is_pin_cleared_set = function(self) + return (self.value & self.PIN_CLEARED) ~= 0 +end + +DlKeypadProgrammingEventMask.set_pin_cleared = function(self) + if self.value ~= nil then + self.value = self.value | self.PIN_CLEARED + else + self.value = self.PIN_CLEARED + end +end + +DlKeypadProgrammingEventMask.unset_pin_cleared = function(self) + self.value = self.value & (~self.PIN_CLEARED & self.BASE_MASK) +end + +DlKeypadProgrammingEventMask.is_pin_changed_set = function(self) + return (self.value & self.PIN_CHANGED) ~= 0 +end + +DlKeypadProgrammingEventMask.set_pin_changed = function(self) + if self.value ~= nil then + self.value = self.value | self.PIN_CHANGED + else + self.value = self.PIN_CHANGED + end +end + +DlKeypadProgrammingEventMask.unset_pin_changed = function(self) + self.value = self.value & (~self.PIN_CHANGED & self.BASE_MASK) +end + +DlKeypadProgrammingEventMask.mask_methods = { + is_unknown_set = DlKeypadProgrammingEventMask.is_unknown_set, + set_unknown = DlKeypadProgrammingEventMask.set_unknown, + unset_unknown = DlKeypadProgrammingEventMask.unset_unknown, + is_programmingpin_changed_set = DlKeypadProgrammingEventMask.is_programmingpin_changed_set, + set_programmingpin_changed = DlKeypadProgrammingEventMask.set_programmingpin_changed, + unset_programmingpin_changed = DlKeypadProgrammingEventMask.unset_programmingpin_changed, + is_pin_added_set = DlKeypadProgrammingEventMask.is_pin_added_set, + set_pin_added = DlKeypadProgrammingEventMask.set_pin_added, + unset_pin_added = DlKeypadProgrammingEventMask.unset_pin_added, + is_pin_cleared_set = DlKeypadProgrammingEventMask.is_pin_cleared_set, + set_pin_cleared = DlKeypadProgrammingEventMask.set_pin_cleared, + unset_pin_cleared = DlKeypadProgrammingEventMask.unset_pin_cleared, + is_pin_changed_set = DlKeypadProgrammingEventMask.is_pin_changed_set, + set_pin_changed = DlKeypadProgrammingEventMask.set_pin_changed, + unset_pin_changed = DlKeypadProgrammingEventMask.unset_pin_changed, +} + +DlKeypadProgrammingEventMask.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlKeypadProgrammingEventMask, new_mt) + +return DlKeypadProgrammingEventMask \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlLocalProgrammingFeatures.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlLocalProgrammingFeatures.lua new file mode 100644 index 0000000000..937a59daf9 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlLocalProgrammingFeatures.lua @@ -0,0 +1,106 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlLocalProgrammingFeatures = {} +local new_mt = UintABC.new_mt({NAME = "DlLocalProgrammingFeatures", ID = data_types.name_to_id_map["Uint8"]}, 1) + +DlLocalProgrammingFeatures.BASE_MASK = 0xFFFF +DlLocalProgrammingFeatures.ADD_USERS_CREDENTIALS_SCHEDULES_LOCALLY = 0x0001 +DlLocalProgrammingFeatures.MODIFY_USERS_CREDENTIALS_SCHEDULES_LOCALLY = 0x0002 +DlLocalProgrammingFeatures.CLEAR_USERS_CREDENTIALS_SCHEDULES_LOCALLY = 0x0004 +DlLocalProgrammingFeatures.ADJUST_LOCK_SETTINGS_LOCALLY = 0x0008 + +DlLocalProgrammingFeatures.mask_fields = { + BASE_MASK = 0xFFFF, + ADD_USERS_CREDENTIALS_SCHEDULES_LOCALLY = 0x0001, + MODIFY_USERS_CREDENTIALS_SCHEDULES_LOCALLY = 0x0002, + CLEAR_USERS_CREDENTIALS_SCHEDULES_LOCALLY = 0x0004, + ADJUST_LOCK_SETTINGS_LOCALLY = 0x0008, +} + +DlLocalProgrammingFeatures.is_add_users_credentials_schedules_locally_set = function(self) + return (self.value & self.ADD_USERS_CREDENTIALS_SCHEDULES_LOCALLY) ~= 0 +end + +DlLocalProgrammingFeatures.set_add_users_credentials_schedules_locally = function(self) + if self.value ~= nil then + self.value = self.value | self.ADD_USERS_CREDENTIALS_SCHEDULES_LOCALLY + else + self.value = self.ADD_USERS_CREDENTIALS_SCHEDULES_LOCALLY + end +end + +DlLocalProgrammingFeatures.unset_add_users_credentials_schedules_locally = function(self) + self.value = self.value & (~self.ADD_USERS_CREDENTIALS_SCHEDULES_LOCALLY & self.BASE_MASK) +end + +DlLocalProgrammingFeatures.is_modify_users_credentials_schedules_locally_set = function(self) + return (self.value & self.MODIFY_USERS_CREDENTIALS_SCHEDULES_LOCALLY) ~= 0 +end + +DlLocalProgrammingFeatures.set_modify_users_credentials_schedules_locally = function(self) + if self.value ~= nil then + self.value = self.value | self.MODIFY_USERS_CREDENTIALS_SCHEDULES_LOCALLY + else + self.value = self.MODIFY_USERS_CREDENTIALS_SCHEDULES_LOCALLY + end +end + +DlLocalProgrammingFeatures.unset_modify_users_credentials_schedules_locally = function(self) + self.value = self.value & (~self.MODIFY_USERS_CREDENTIALS_SCHEDULES_LOCALLY & self.BASE_MASK) +end + +DlLocalProgrammingFeatures.is_clear_users_credentials_schedules_locally_set = function(self) + return (self.value & self.CLEAR_USERS_CREDENTIALS_SCHEDULES_LOCALLY) ~= 0 +end + +DlLocalProgrammingFeatures.set_clear_users_credentials_schedules_locally = function(self) + if self.value ~= nil then + self.value = self.value | self.CLEAR_USERS_CREDENTIALS_SCHEDULES_LOCALLY + else + self.value = self.CLEAR_USERS_CREDENTIALS_SCHEDULES_LOCALLY + end +end + +DlLocalProgrammingFeatures.unset_clear_users_credentials_schedules_locally = function(self) + self.value = self.value & (~self.CLEAR_USERS_CREDENTIALS_SCHEDULES_LOCALLY & self.BASE_MASK) +end + +DlLocalProgrammingFeatures.is_adjust_lock_settings_locally_set = function(self) + return (self.value & self.ADJUST_LOCK_SETTINGS_LOCALLY) ~= 0 +end + +DlLocalProgrammingFeatures.set_adjust_lock_settings_locally = function(self) + if self.value ~= nil then + self.value = self.value | self.ADJUST_LOCK_SETTINGS_LOCALLY + else + self.value = self.ADJUST_LOCK_SETTINGS_LOCALLY + end +end + +DlLocalProgrammingFeatures.unset_adjust_lock_settings_locally = function(self) + self.value = self.value & (~self.ADJUST_LOCK_SETTINGS_LOCALLY & self.BASE_MASK) +end + +DlLocalProgrammingFeatures.mask_methods = { + is_add_users_credentials_schedules_locally_set = DlLocalProgrammingFeatures.is_add_users_credentials_schedules_locally_set, + set_add_users_credentials_schedules_locally = DlLocalProgrammingFeatures.set_add_users_credentials_schedules_locally, + unset_add_users_credentials_schedules_locally = DlLocalProgrammingFeatures.unset_add_users_credentials_schedules_locally, + is_modify_users_credentials_schedules_locally_set = DlLocalProgrammingFeatures.is_modify_users_credentials_schedules_locally_set, + set_modify_users_credentials_schedules_locally = DlLocalProgrammingFeatures.set_modify_users_credentials_schedules_locally, + unset_modify_users_credentials_schedules_locally = DlLocalProgrammingFeatures.unset_modify_users_credentials_schedules_locally, + is_clear_users_credentials_schedules_locally_set = DlLocalProgrammingFeatures.is_clear_users_credentials_schedules_locally_set, + set_clear_users_credentials_schedules_locally = DlLocalProgrammingFeatures.set_clear_users_credentials_schedules_locally, + unset_clear_users_credentials_schedules_locally = DlLocalProgrammingFeatures.unset_clear_users_credentials_schedules_locally, + is_adjust_lock_settings_locally_set = DlLocalProgrammingFeatures.is_adjust_lock_settings_locally_set, + set_adjust_lock_settings_locally = DlLocalProgrammingFeatures.set_adjust_lock_settings_locally, + unset_adjust_lock_settings_locally = DlLocalProgrammingFeatures.unset_adjust_lock_settings_locally, +} + +DlLocalProgrammingFeatures.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlLocalProgrammingFeatures, new_mt) + +return DlLocalProgrammingFeatures \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlLockState.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlLockState.lua new file mode 100644 index 0000000000..49fa7d2eb6 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlLockState.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlLockState = {} +local new_mt = UintABC.new_mt({NAME = "DlLockState", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NOT_FULLY_LOCKED] = "NOT_FULLY_LOCKED", + [self.LOCKED] = "LOCKED", + [self.UNLOCKED] = "UNLOCKED", + [self.UNLATCHED] = "UNLATCHED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NOT_FULLY_LOCKED = 0x00 +new_mt.__index.LOCKED = 0x01 +new_mt.__index.UNLOCKED = 0x02 +new_mt.__index.UNLATCHED = 0x03 + +DlLockState.NOT_FULLY_LOCKED = 0x00 +DlLockState.LOCKED = 0x01 +DlLockState.UNLOCKED = 0x02 +DlLockState.UNLATCHED = 0x03 + +DlLockState.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlLockState, new_mt) + +return DlLockState \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlManualOperationEventMask.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlManualOperationEventMask.lua new file mode 100644 index 0000000000..9c9803125d --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlManualOperationEventMask.lua @@ -0,0 +1,253 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlManualOperationEventMask = {} +local new_mt = UintABC.new_mt({NAME = "DlManualOperationEventMask", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlManualOperationEventMask.BASE_MASK = 0xFFFF +DlManualOperationEventMask.UNKNOWN = 0x0001 +DlManualOperationEventMask.THUMBTURN_LOCK = 0x0002 +DlManualOperationEventMask.THUMBTURN_UNLOCK = 0x0004 +DlManualOperationEventMask.ONE_TOUCH_LOCK = 0x0008 +DlManualOperationEventMask.KEY_LOCK = 0x0010 +DlManualOperationEventMask.KEY_UNLOCK = 0x0020 +DlManualOperationEventMask.AUTO_LOCK = 0x0040 +DlManualOperationEventMask.SCHEDULE_LOCK = 0x0080 +DlManualOperationEventMask.SCHEDULE_UNLOCK = 0x0100 +DlManualOperationEventMask.MANUAL_LOCK = 0x0200 +DlManualOperationEventMask.MANUAL_UNLOCK = 0x0400 + +DlManualOperationEventMask.mask_fields = { + BASE_MASK = 0xFFFF, + UNKNOWN = 0x0001, + THUMBTURN_LOCK = 0x0002, + THUMBTURN_UNLOCK = 0x0004, + ONE_TOUCH_LOCK = 0x0008, + KEY_LOCK = 0x0010, + KEY_UNLOCK = 0x0020, + AUTO_LOCK = 0x0040, + SCHEDULE_LOCK = 0x0080, + SCHEDULE_UNLOCK = 0x0100, + MANUAL_LOCK = 0x0200, + MANUAL_UNLOCK = 0x0400, +} + +DlManualOperationEventMask.is_unknown_set = function(self) + return (self.value & self.UNKNOWN) ~= 0 +end + +DlManualOperationEventMask.set_unknown = function(self) + if self.value ~= nil then + self.value = self.value | self.UNKNOWN + else + self.value = self.UNKNOWN + end +end + +DlManualOperationEventMask.unset_unknown = function(self) + self.value = self.value & (~self.UNKNOWN & self.BASE_MASK) +end + +DlManualOperationEventMask.is_thumbturn_lock_set = function(self) + return (self.value & self.THUMBTURN_LOCK) ~= 0 +end + +DlManualOperationEventMask.set_thumbturn_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.THUMBTURN_LOCK + else + self.value = self.THUMBTURN_LOCK + end +end + +DlManualOperationEventMask.unset_thumbturn_lock = function(self) + self.value = self.value & (~self.THUMBTURN_LOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_thumbturn_unlock_set = function(self) + return (self.value & self.THUMBTURN_UNLOCK) ~= 0 +end + +DlManualOperationEventMask.set_thumbturn_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.THUMBTURN_UNLOCK + else + self.value = self.THUMBTURN_UNLOCK + end +end + +DlManualOperationEventMask.unset_thumbturn_unlock = function(self) + self.value = self.value & (~self.THUMBTURN_UNLOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_one_touch_lock_set = function(self) + return (self.value & self.ONE_TOUCH_LOCK) ~= 0 +end + +DlManualOperationEventMask.set_one_touch_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.ONE_TOUCH_LOCK + else + self.value = self.ONE_TOUCH_LOCK + end +end + +DlManualOperationEventMask.unset_one_touch_lock = function(self) + self.value = self.value & (~self.ONE_TOUCH_LOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_key_lock_set = function(self) + return (self.value & self.KEY_LOCK) ~= 0 +end + +DlManualOperationEventMask.set_key_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.KEY_LOCK + else + self.value = self.KEY_LOCK + end +end + +DlManualOperationEventMask.unset_key_lock = function(self) + self.value = self.value & (~self.KEY_LOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_key_unlock_set = function(self) + return (self.value & self.KEY_UNLOCK) ~= 0 +end + +DlManualOperationEventMask.set_key_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.KEY_UNLOCK + else + self.value = self.KEY_UNLOCK + end +end + +DlManualOperationEventMask.unset_key_unlock = function(self) + self.value = self.value & (~self.KEY_UNLOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_auto_lock_set = function(self) + return (self.value & self.AUTO_LOCK) ~= 0 +end + +DlManualOperationEventMask.set_auto_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.AUTO_LOCK + else + self.value = self.AUTO_LOCK + end +end + +DlManualOperationEventMask.unset_auto_lock = function(self) + self.value = self.value & (~self.AUTO_LOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_schedule_lock_set = function(self) + return (self.value & self.SCHEDULE_LOCK) ~= 0 +end + +DlManualOperationEventMask.set_schedule_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.SCHEDULE_LOCK + else + self.value = self.SCHEDULE_LOCK + end +end + +DlManualOperationEventMask.unset_schedule_lock = function(self) + self.value = self.value & (~self.SCHEDULE_LOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_schedule_unlock_set = function(self) + return (self.value & self.SCHEDULE_UNLOCK) ~= 0 +end + +DlManualOperationEventMask.set_schedule_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.SCHEDULE_UNLOCK + else + self.value = self.SCHEDULE_UNLOCK + end +end + +DlManualOperationEventMask.unset_schedule_unlock = function(self) + self.value = self.value & (~self.SCHEDULE_UNLOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_manual_lock_set = function(self) + return (self.value & self.MANUAL_LOCK) ~= 0 +end + +DlManualOperationEventMask.set_manual_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.MANUAL_LOCK + else + self.value = self.MANUAL_LOCK + end +end + +DlManualOperationEventMask.unset_manual_lock = function(self) + self.value = self.value & (~self.MANUAL_LOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.is_manual_unlock_set = function(self) + return (self.value & self.MANUAL_UNLOCK) ~= 0 +end + +DlManualOperationEventMask.set_manual_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.MANUAL_UNLOCK + else + self.value = self.MANUAL_UNLOCK + end +end + +DlManualOperationEventMask.unset_manual_unlock = function(self) + self.value = self.value & (~self.MANUAL_UNLOCK & self.BASE_MASK) +end + +DlManualOperationEventMask.mask_methods = { + is_unknown_set = DlManualOperationEventMask.is_unknown_set, + set_unknown = DlManualOperationEventMask.set_unknown, + unset_unknown = DlManualOperationEventMask.unset_unknown, + is_thumbturn_lock_set = DlManualOperationEventMask.is_thumbturn_lock_set, + set_thumbturn_lock = DlManualOperationEventMask.set_thumbturn_lock, + unset_thumbturn_lock = DlManualOperationEventMask.unset_thumbturn_lock, + is_thumbturn_unlock_set = DlManualOperationEventMask.is_thumbturn_unlock_set, + set_thumbturn_unlock = DlManualOperationEventMask.set_thumbturn_unlock, + unset_thumbturn_unlock = DlManualOperationEventMask.unset_thumbturn_unlock, + is_one_touch_lock_set = DlManualOperationEventMask.is_one_touch_lock_set, + set_one_touch_lock = DlManualOperationEventMask.set_one_touch_lock, + unset_one_touch_lock = DlManualOperationEventMask.unset_one_touch_lock, + is_key_lock_set = DlManualOperationEventMask.is_key_lock_set, + set_key_lock = DlManualOperationEventMask.set_key_lock, + unset_key_lock = DlManualOperationEventMask.unset_key_lock, + is_key_unlock_set = DlManualOperationEventMask.is_key_unlock_set, + set_key_unlock = DlManualOperationEventMask.set_key_unlock, + unset_key_unlock = DlManualOperationEventMask.unset_key_unlock, + is_auto_lock_set = DlManualOperationEventMask.is_auto_lock_set, + set_auto_lock = DlManualOperationEventMask.set_auto_lock, + unset_auto_lock = DlManualOperationEventMask.unset_auto_lock, + is_schedule_lock_set = DlManualOperationEventMask.is_schedule_lock_set, + set_schedule_lock = DlManualOperationEventMask.set_schedule_lock, + unset_schedule_lock = DlManualOperationEventMask.unset_schedule_lock, + is_schedule_unlock_set = DlManualOperationEventMask.is_schedule_unlock_set, + set_schedule_unlock = DlManualOperationEventMask.set_schedule_unlock, + unset_schedule_unlock = DlManualOperationEventMask.unset_schedule_unlock, + is_manual_lock_set = DlManualOperationEventMask.is_manual_lock_set, + set_manual_lock = DlManualOperationEventMask.set_manual_lock, + unset_manual_lock = DlManualOperationEventMask.unset_manual_lock, + is_manual_unlock_set = DlManualOperationEventMask.is_manual_unlock_set, + set_manual_unlock = DlManualOperationEventMask.set_manual_unlock, + unset_manual_unlock = DlManualOperationEventMask.unset_manual_unlock, +} + +DlManualOperationEventMask.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlManualOperationEventMask, new_mt) + +return DlManualOperationEventMask \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDOperationEventMask.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDOperationEventMask.lua new file mode 100644 index 0000000000..3877280e5e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDOperationEventMask.lua @@ -0,0 +1,169 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlRFIDOperationEventMask = {} +local new_mt = UintABC.new_mt({NAME = "DlRFIDOperationEventMask", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlRFIDOperationEventMask.BASE_MASK = 0xFFFF +DlRFIDOperationEventMask.UNKNOWN = 0x0001 +DlRFIDOperationEventMask.LOCK = 0x0002 +DlRFIDOperationEventMask.UNLOCK = 0x0004 +DlRFIDOperationEventMask.LOCK_INVALIDRFID = 0x0008 +DlRFIDOperationEventMask.LOCK_INVALID_SCHEDULE = 0x0010 +DlRFIDOperationEventMask.UNLOCK_INVALIDRFID = 0x0020 +DlRFIDOperationEventMask.UNLOCK_INVALID_SCHEDULE = 0x0040 + +DlRFIDOperationEventMask.mask_fields = { + BASE_MASK = 0xFFFF, + UNKNOWN = 0x0001, + LOCK = 0x0002, + UNLOCK = 0x0004, + LOCK_INVALIDRFID = 0x0008, + LOCK_INVALID_SCHEDULE = 0x0010, + UNLOCK_INVALIDRFID = 0x0020, + UNLOCK_INVALID_SCHEDULE = 0x0040, +} + +DlRFIDOperationEventMask.is_unknown_set = function(self) + return (self.value & self.UNKNOWN) ~= 0 +end + +DlRFIDOperationEventMask.set_unknown = function(self) + if self.value ~= nil then + self.value = self.value | self.UNKNOWN + else + self.value = self.UNKNOWN + end +end + +DlRFIDOperationEventMask.unset_unknown = function(self) + self.value = self.value & (~self.UNKNOWN & self.BASE_MASK) +end + +DlRFIDOperationEventMask.is_lock_set = function(self) + return (self.value & self.LOCK) ~= 0 +end + +DlRFIDOperationEventMask.set_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK + else + self.value = self.LOCK + end +end + +DlRFIDOperationEventMask.unset_lock = function(self) + self.value = self.value & (~self.LOCK & self.BASE_MASK) +end + +DlRFIDOperationEventMask.is_unlock_set = function(self) + return (self.value & self.UNLOCK) ~= 0 +end + +DlRFIDOperationEventMask.set_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK + else + self.value = self.UNLOCK + end +end + +DlRFIDOperationEventMask.unset_unlock = function(self) + self.value = self.value & (~self.UNLOCK & self.BASE_MASK) +end + +DlRFIDOperationEventMask.is_lock_invalidrfid_set = function(self) + return (self.value & self.LOCK_INVALIDRFID) ~= 0 +end + +DlRFIDOperationEventMask.set_lock_invalidrfid = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK_INVALIDRFID + else + self.value = self.LOCK_INVALIDRFID + end +end + +DlRFIDOperationEventMask.unset_lock_invalidrfid = function(self) + self.value = self.value & (~self.LOCK_INVALIDRFID & self.BASE_MASK) +end + +DlRFIDOperationEventMask.is_lock_invalid_schedule_set = function(self) + return (self.value & self.LOCK_INVALID_SCHEDULE) ~= 0 +end + +DlRFIDOperationEventMask.set_lock_invalid_schedule = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK_INVALID_SCHEDULE + else + self.value = self.LOCK_INVALID_SCHEDULE + end +end + +DlRFIDOperationEventMask.unset_lock_invalid_schedule = function(self) + self.value = self.value & (~self.LOCK_INVALID_SCHEDULE & self.BASE_MASK) +end + +DlRFIDOperationEventMask.is_unlock_invalidrfid_set = function(self) + return (self.value & self.UNLOCK_INVALIDRFID) ~= 0 +end + +DlRFIDOperationEventMask.set_unlock_invalidrfid = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK_INVALIDRFID + else + self.value = self.UNLOCK_INVALIDRFID + end +end + +DlRFIDOperationEventMask.unset_unlock_invalidrfid = function(self) + self.value = self.value & (~self.UNLOCK_INVALIDRFID & self.BASE_MASK) +end + +DlRFIDOperationEventMask.is_unlock_invalid_schedule_set = function(self) + return (self.value & self.UNLOCK_INVALID_SCHEDULE) ~= 0 +end + +DlRFIDOperationEventMask.set_unlock_invalid_schedule = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK_INVALID_SCHEDULE + else + self.value = self.UNLOCK_INVALID_SCHEDULE + end +end + +DlRFIDOperationEventMask.unset_unlock_invalid_schedule = function(self) + self.value = self.value & (~self.UNLOCK_INVALID_SCHEDULE & self.BASE_MASK) +end + +DlRFIDOperationEventMask.mask_methods = { + is_unknown_set = DlRFIDOperationEventMask.is_unknown_set, + set_unknown = DlRFIDOperationEventMask.set_unknown, + unset_unknown = DlRFIDOperationEventMask.unset_unknown, + is_lock_set = DlRFIDOperationEventMask.is_lock_set, + set_lock = DlRFIDOperationEventMask.set_lock, + unset_lock = DlRFIDOperationEventMask.unset_lock, + is_unlock_set = DlRFIDOperationEventMask.is_unlock_set, + set_unlock = DlRFIDOperationEventMask.set_unlock, + unset_unlock = DlRFIDOperationEventMask.unset_unlock, + is_lock_invalidrfid_set = DlRFIDOperationEventMask.is_lock_invalidrfid_set, + set_lock_invalidrfid = DlRFIDOperationEventMask.set_lock_invalidrfid, + unset_lock_invalidrfid = DlRFIDOperationEventMask.unset_lock_invalidrfid, + is_lock_invalid_schedule_set = DlRFIDOperationEventMask.is_lock_invalid_schedule_set, + set_lock_invalid_schedule = DlRFIDOperationEventMask.set_lock_invalid_schedule, + unset_lock_invalid_schedule = DlRFIDOperationEventMask.unset_lock_invalid_schedule, + is_unlock_invalidrfid_set = DlRFIDOperationEventMask.is_unlock_invalidrfid_set, + set_unlock_invalidrfid = DlRFIDOperationEventMask.set_unlock_invalidrfid, + unset_unlock_invalidrfid = DlRFIDOperationEventMask.unset_unlock_invalidrfid, + is_unlock_invalid_schedule_set = DlRFIDOperationEventMask.is_unlock_invalid_schedule_set, + set_unlock_invalid_schedule = DlRFIDOperationEventMask.set_unlock_invalid_schedule, + unset_unlock_invalid_schedule = DlRFIDOperationEventMask.unset_unlock_invalid_schedule, +} + +DlRFIDOperationEventMask.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlRFIDOperationEventMask, new_mt) + +return DlRFIDOperationEventMask \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDProgrammingEventMask.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDProgrammingEventMask.lua new file mode 100644 index 0000000000..439f0ebbff --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRFIDProgrammingEventMask.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlRFIDProgrammingEventMask = {} +local new_mt = UintABC.new_mt({NAME = "DlRFIDProgrammingEventMask", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlRFIDProgrammingEventMask.BASE_MASK = 0xFFFF +DlRFIDProgrammingEventMask.UNKNOWN = 0x0001 +DlRFIDProgrammingEventMask.RFID_CODE_ADDED = 0x0020 +DlRFIDProgrammingEventMask.RFID_CODE_CLEARED = 0x0040 + +DlRFIDProgrammingEventMask.mask_fields = { + BASE_MASK = 0xFFFF, + UNKNOWN = 0x0001, + RFID_CODE_ADDED = 0x0020, + RFID_CODE_CLEARED = 0x0040, +} + +DlRFIDProgrammingEventMask.is_unknown_set = function(self) + return (self.value & self.UNKNOWN) ~= 0 +end + +DlRFIDProgrammingEventMask.set_unknown = function(self) + if self.value ~= nil then + self.value = self.value | self.UNKNOWN + else + self.value = self.UNKNOWN + end +end + +DlRFIDProgrammingEventMask.unset_unknown = function(self) + self.value = self.value & (~self.UNKNOWN & self.BASE_MASK) +end + +DlRFIDProgrammingEventMask.is_rfid_code_added_set = function(self) + return (self.value & self.RFID_CODE_ADDED) ~= 0 +end + +DlRFIDProgrammingEventMask.set_rfid_code_added = function(self) + if self.value ~= nil then + self.value = self.value | self.RFID_CODE_ADDED + else + self.value = self.RFID_CODE_ADDED + end +end + +DlRFIDProgrammingEventMask.unset_rfid_code_added = function(self) + self.value = self.value & (~self.RFID_CODE_ADDED & self.BASE_MASK) +end + +DlRFIDProgrammingEventMask.is_rfid_code_cleared_set = function(self) + return (self.value & self.RFID_CODE_CLEARED) ~= 0 +end + +DlRFIDProgrammingEventMask.set_rfid_code_cleared = function(self) + if self.value ~= nil then + self.value = self.value | self.RFID_CODE_CLEARED + else + self.value = self.RFID_CODE_CLEARED + end +end + +DlRFIDProgrammingEventMask.unset_rfid_code_cleared = function(self) + self.value = self.value & (~self.RFID_CODE_CLEARED & self.BASE_MASK) +end + +DlRFIDProgrammingEventMask.mask_methods = { + is_unknown_set = DlRFIDProgrammingEventMask.is_unknown_set, + set_unknown = DlRFIDProgrammingEventMask.set_unknown, + unset_unknown = DlRFIDProgrammingEventMask.unset_unknown, + is_rfid_code_added_set = DlRFIDProgrammingEventMask.is_rfid_code_added_set, + set_rfid_code_added = DlRFIDProgrammingEventMask.set_rfid_code_added, + unset_rfid_code_added = DlRFIDProgrammingEventMask.unset_rfid_code_added, + is_rfid_code_cleared_set = DlRFIDProgrammingEventMask.is_rfid_code_cleared_set, + set_rfid_code_cleared = DlRFIDProgrammingEventMask.set_rfid_code_cleared, + unset_rfid_code_cleared = DlRFIDProgrammingEventMask.unset_rfid_code_cleared, +} + +DlRFIDProgrammingEventMask.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlRFIDProgrammingEventMask, new_mt) + +return DlRFIDProgrammingEventMask \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteOperationEventMask.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteOperationEventMask.lua new file mode 100644 index 0000000000..0dfed6af72 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteOperationEventMask.lua @@ -0,0 +1,169 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlRemoteOperationEventMask = {} +local new_mt = UintABC.new_mt({NAME = "DlRemoteOperationEventMask", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlRemoteOperationEventMask.BASE_MASK = 0xFFFF +DlRemoteOperationEventMask.UNKNOWN = 0x0001 +DlRemoteOperationEventMask.LOCK = 0x0002 +DlRemoteOperationEventMask.UNLOCK = 0x0004 +DlRemoteOperationEventMask.LOCK_INVALID_CODE = 0x0008 +DlRemoteOperationEventMask.LOCK_INVALID_SCHEDULE = 0x0010 +DlRemoteOperationEventMask.UNLOCK_INVALID_CODE = 0x0020 +DlRemoteOperationEventMask.UNLOCK_INVALID_SCHEDULE = 0x0040 + +DlRemoteOperationEventMask.mask_fields = { + BASE_MASK = 0xFFFF, + UNKNOWN = 0x0001, + LOCK = 0x0002, + UNLOCK = 0x0004, + LOCK_INVALID_CODE = 0x0008, + LOCK_INVALID_SCHEDULE = 0x0010, + UNLOCK_INVALID_CODE = 0x0020, + UNLOCK_INVALID_SCHEDULE = 0x0040, +} + +DlRemoteOperationEventMask.is_unknown_set = function(self) + return (self.value & self.UNKNOWN) ~= 0 +end + +DlRemoteOperationEventMask.set_unknown = function(self) + if self.value ~= nil then + self.value = self.value | self.UNKNOWN + else + self.value = self.UNKNOWN + end +end + +DlRemoteOperationEventMask.unset_unknown = function(self) + self.value = self.value & (~self.UNKNOWN & self.BASE_MASK) +end + +DlRemoteOperationEventMask.is_lock_set = function(self) + return (self.value & self.LOCK) ~= 0 +end + +DlRemoteOperationEventMask.set_lock = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK + else + self.value = self.LOCK + end +end + +DlRemoteOperationEventMask.unset_lock = function(self) + self.value = self.value & (~self.LOCK & self.BASE_MASK) +end + +DlRemoteOperationEventMask.is_unlock_set = function(self) + return (self.value & self.UNLOCK) ~= 0 +end + +DlRemoteOperationEventMask.set_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK + else + self.value = self.UNLOCK + end +end + +DlRemoteOperationEventMask.unset_unlock = function(self) + self.value = self.value & (~self.UNLOCK & self.BASE_MASK) +end + +DlRemoteOperationEventMask.is_lock_invalid_code_set = function(self) + return (self.value & self.LOCK_INVALID_CODE) ~= 0 +end + +DlRemoteOperationEventMask.set_lock_invalid_code = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK_INVALID_CODE + else + self.value = self.LOCK_INVALID_CODE + end +end + +DlRemoteOperationEventMask.unset_lock_invalid_code = function(self) + self.value = self.value & (~self.LOCK_INVALID_CODE & self.BASE_MASK) +end + +DlRemoteOperationEventMask.is_lock_invalid_schedule_set = function(self) + return (self.value & self.LOCK_INVALID_SCHEDULE) ~= 0 +end + +DlRemoteOperationEventMask.set_lock_invalid_schedule = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCK_INVALID_SCHEDULE + else + self.value = self.LOCK_INVALID_SCHEDULE + end +end + +DlRemoteOperationEventMask.unset_lock_invalid_schedule = function(self) + self.value = self.value & (~self.LOCK_INVALID_SCHEDULE & self.BASE_MASK) +end + +DlRemoteOperationEventMask.is_unlock_invalid_code_set = function(self) + return (self.value & self.UNLOCK_INVALID_CODE) ~= 0 +end + +DlRemoteOperationEventMask.set_unlock_invalid_code = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK_INVALID_CODE + else + self.value = self.UNLOCK_INVALID_CODE + end +end + +DlRemoteOperationEventMask.unset_unlock_invalid_code = function(self) + self.value = self.value & (~self.UNLOCK_INVALID_CODE & self.BASE_MASK) +end + +DlRemoteOperationEventMask.is_unlock_invalid_schedule_set = function(self) + return (self.value & self.UNLOCK_INVALID_SCHEDULE) ~= 0 +end + +DlRemoteOperationEventMask.set_unlock_invalid_schedule = function(self) + if self.value ~= nil then + self.value = self.value | self.UNLOCK_INVALID_SCHEDULE + else + self.value = self.UNLOCK_INVALID_SCHEDULE + end +end + +DlRemoteOperationEventMask.unset_unlock_invalid_schedule = function(self) + self.value = self.value & (~self.UNLOCK_INVALID_SCHEDULE & self.BASE_MASK) +end + +DlRemoteOperationEventMask.mask_methods = { + is_unknown_set = DlRemoteOperationEventMask.is_unknown_set, + set_unknown = DlRemoteOperationEventMask.set_unknown, + unset_unknown = DlRemoteOperationEventMask.unset_unknown, + is_lock_set = DlRemoteOperationEventMask.is_lock_set, + set_lock = DlRemoteOperationEventMask.set_lock, + unset_lock = DlRemoteOperationEventMask.unset_lock, + is_unlock_set = DlRemoteOperationEventMask.is_unlock_set, + set_unlock = DlRemoteOperationEventMask.set_unlock, + unset_unlock = DlRemoteOperationEventMask.unset_unlock, + is_lock_invalid_code_set = DlRemoteOperationEventMask.is_lock_invalid_code_set, + set_lock_invalid_code = DlRemoteOperationEventMask.set_lock_invalid_code, + unset_lock_invalid_code = DlRemoteOperationEventMask.unset_lock_invalid_code, + is_lock_invalid_schedule_set = DlRemoteOperationEventMask.is_lock_invalid_schedule_set, + set_lock_invalid_schedule = DlRemoteOperationEventMask.set_lock_invalid_schedule, + unset_lock_invalid_schedule = DlRemoteOperationEventMask.unset_lock_invalid_schedule, + is_unlock_invalid_code_set = DlRemoteOperationEventMask.is_unlock_invalid_code_set, + set_unlock_invalid_code = DlRemoteOperationEventMask.set_unlock_invalid_code, + unset_unlock_invalid_code = DlRemoteOperationEventMask.unset_unlock_invalid_code, + is_unlock_invalid_schedule_set = DlRemoteOperationEventMask.is_unlock_invalid_schedule_set, + set_unlock_invalid_schedule = DlRemoteOperationEventMask.set_unlock_invalid_schedule, + unset_unlock_invalid_schedule = DlRemoteOperationEventMask.unset_unlock_invalid_schedule, +} + +DlRemoteOperationEventMask.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlRemoteOperationEventMask, new_mt) + +return DlRemoteOperationEventMask \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteProgrammingEventMask.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteProgrammingEventMask.lua new file mode 100644 index 0000000000..c0a14c5e1a --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlRemoteProgrammingEventMask.lua @@ -0,0 +1,169 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlRemoteProgrammingEventMask = {} +local new_mt = UintABC.new_mt({NAME = "DlRemoteProgrammingEventMask", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlRemoteProgrammingEventMask.BASE_MASK = 0xFFFF +DlRemoteProgrammingEventMask.UNKNOWN = 0x0001 +DlRemoteProgrammingEventMask.PROGRAMMINGPIN_CHANGED = 0x0002 +DlRemoteProgrammingEventMask.PIN_ADDED = 0x0004 +DlRemoteProgrammingEventMask.PIN_CLEARED = 0x0008 +DlRemoteProgrammingEventMask.PIN_CHANGED = 0x0010 +DlRemoteProgrammingEventMask.RFID_CODE_ADDED = 0x0020 +DlRemoteProgrammingEventMask.RFID_CODE_CLEARED = 0x0040 + +DlRemoteProgrammingEventMask.mask_fields = { + BASE_MASK = 0xFFFF, + UNKNOWN = 0x0001, + PROGRAMMINGPIN_CHANGED = 0x0002, + PIN_ADDED = 0x0004, + PIN_CLEARED = 0x0008, + PIN_CHANGED = 0x0010, + RFID_CODE_ADDED = 0x0020, + RFID_CODE_CLEARED = 0x0040, +} + +DlRemoteProgrammingEventMask.is_unknown_set = function(self) + return (self.value & self.UNKNOWN) ~= 0 +end + +DlRemoteProgrammingEventMask.set_unknown = function(self) + if self.value ~= nil then + self.value = self.value | self.UNKNOWN + else + self.value = self.UNKNOWN + end +end + +DlRemoteProgrammingEventMask.unset_unknown = function(self) + self.value = self.value & (~self.UNKNOWN & self.BASE_MASK) +end + +DlRemoteProgrammingEventMask.is_programmingpin_changed_set = function(self) + return (self.value & self.PROGRAMMINGPIN_CHANGED) ~= 0 +end + +DlRemoteProgrammingEventMask.set_programmingpin_changed = function(self) + if self.value ~= nil then + self.value = self.value | self.PROGRAMMINGPIN_CHANGED + else + self.value = self.PROGRAMMINGPIN_CHANGED + end +end + +DlRemoteProgrammingEventMask.unset_programmingpin_changed = function(self) + self.value = self.value & (~self.PROGRAMMINGPIN_CHANGED & self.BASE_MASK) +end + +DlRemoteProgrammingEventMask.is_pin_added_set = function(self) + return (self.value & self.PIN_ADDED) ~= 0 +end + +DlRemoteProgrammingEventMask.set_pin_added = function(self) + if self.value ~= nil then + self.value = self.value | self.PIN_ADDED + else + self.value = self.PIN_ADDED + end +end + +DlRemoteProgrammingEventMask.unset_pin_added = function(self) + self.value = self.value & (~self.PIN_ADDED & self.BASE_MASK) +end + +DlRemoteProgrammingEventMask.is_pin_cleared_set = function(self) + return (self.value & self.PIN_CLEARED) ~= 0 +end + +DlRemoteProgrammingEventMask.set_pin_cleared = function(self) + if self.value ~= nil then + self.value = self.value | self.PIN_CLEARED + else + self.value = self.PIN_CLEARED + end +end + +DlRemoteProgrammingEventMask.unset_pin_cleared = function(self) + self.value = self.value & (~self.PIN_CLEARED & self.BASE_MASK) +end + +DlRemoteProgrammingEventMask.is_pin_changed_set = function(self) + return (self.value & self.PIN_CHANGED) ~= 0 +end + +DlRemoteProgrammingEventMask.set_pin_changed = function(self) + if self.value ~= nil then + self.value = self.value | self.PIN_CHANGED + else + self.value = self.PIN_CHANGED + end +end + +DlRemoteProgrammingEventMask.unset_pin_changed = function(self) + self.value = self.value & (~self.PIN_CHANGED & self.BASE_MASK) +end + +DlRemoteProgrammingEventMask.is_rfid_code_added_set = function(self) + return (self.value & self.RFID_CODE_ADDED) ~= 0 +end + +DlRemoteProgrammingEventMask.set_rfid_code_added = function(self) + if self.value ~= nil then + self.value = self.value | self.RFID_CODE_ADDED + else + self.value = self.RFID_CODE_ADDED + end +end + +DlRemoteProgrammingEventMask.unset_rfid_code_added = function(self) + self.value = self.value & (~self.RFID_CODE_ADDED & self.BASE_MASK) +end + +DlRemoteProgrammingEventMask.is_rfid_code_cleared_set = function(self) + return (self.value & self.RFID_CODE_CLEARED) ~= 0 +end + +DlRemoteProgrammingEventMask.set_rfid_code_cleared = function(self) + if self.value ~= nil then + self.value = self.value | self.RFID_CODE_CLEARED + else + self.value = self.RFID_CODE_CLEARED + end +end + +DlRemoteProgrammingEventMask.unset_rfid_code_cleared = function(self) + self.value = self.value & (~self.RFID_CODE_CLEARED & self.BASE_MASK) +end + +DlRemoteProgrammingEventMask.mask_methods = { + is_unknown_set = DlRemoteProgrammingEventMask.is_unknown_set, + set_unknown = DlRemoteProgrammingEventMask.set_unknown, + unset_unknown = DlRemoteProgrammingEventMask.unset_unknown, + is_programmingpin_changed_set = DlRemoteProgrammingEventMask.is_programmingpin_changed_set, + set_programmingpin_changed = DlRemoteProgrammingEventMask.set_programmingpin_changed, + unset_programmingpin_changed = DlRemoteProgrammingEventMask.unset_programmingpin_changed, + is_pin_added_set = DlRemoteProgrammingEventMask.is_pin_added_set, + set_pin_added = DlRemoteProgrammingEventMask.set_pin_added, + unset_pin_added = DlRemoteProgrammingEventMask.unset_pin_added, + is_pin_cleared_set = DlRemoteProgrammingEventMask.is_pin_cleared_set, + set_pin_cleared = DlRemoteProgrammingEventMask.set_pin_cleared, + unset_pin_cleared = DlRemoteProgrammingEventMask.unset_pin_cleared, + is_pin_changed_set = DlRemoteProgrammingEventMask.is_pin_changed_set, + set_pin_changed = DlRemoteProgrammingEventMask.set_pin_changed, + unset_pin_changed = DlRemoteProgrammingEventMask.unset_pin_changed, + is_rfid_code_added_set = DlRemoteProgrammingEventMask.is_rfid_code_added_set, + set_rfid_code_added = DlRemoteProgrammingEventMask.set_rfid_code_added, + unset_rfid_code_added = DlRemoteProgrammingEventMask.unset_rfid_code_added, + is_rfid_code_cleared_set = DlRemoteProgrammingEventMask.is_rfid_code_cleared_set, + set_rfid_code_cleared = DlRemoteProgrammingEventMask.set_rfid_code_cleared, + unset_rfid_code_cleared = DlRemoteProgrammingEventMask.unset_rfid_code_cleared, +} + +DlRemoteProgrammingEventMask.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlRemoteProgrammingEventMask, new_mt) + +return DlRemoteProgrammingEventMask \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlStatus.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlStatus.lua new file mode 100644 index 0000000000..b681fd8018 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlStatus.lua @@ -0,0 +1,42 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlStatus = {} +local new_mt = UintABC.new_mt({NAME = "DlStatus", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.SUCCESS] = "SUCCESS", + [self.FAILURE] = "FAILURE", + [self.DUPLICATE] = "DUPLICATE", + [self.OCCUPIED] = "OCCUPIED", + [self.INVALID_FIELD] = "INVALID_FIELD", + [self.RESOURCE_EXHAUSTED] = "RESOURCE_EXHAUSTED", + [self.NOT_FOUND] = "NOT_FOUND", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.SUCCESS = 0x00 +new_mt.__index.FAILURE = 0x01 +new_mt.__index.DUPLICATE = 0x02 +new_mt.__index.OCCUPIED = 0x03 +new_mt.__index.INVALID_FIELD = 0x85 +new_mt.__index.RESOURCE_EXHAUSTED = 0x89 +new_mt.__index.NOT_FOUND = 0x8B + +DlStatus.SUCCESS = 0x00 +DlStatus.FAILURE = 0x01 +DlStatus.DUPLICATE = 0x02 +DlStatus.OCCUPIED = 0x03 +DlStatus.INVALID_FIELD = 0x85 +DlStatus.RESOURCE_EXHAUSTED = 0x89 +DlStatus.NOT_FOUND = 0x8B + +DlStatus.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlStatus, new_mt) + +return DlStatus \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DlSupportedOperatingModes.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlSupportedOperatingModes.lua new file mode 100644 index 0000000000..5e529146c2 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DlSupportedOperatingModes.lua @@ -0,0 +1,127 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DlSupportedOperatingModes = {} +local new_mt = UintABC.new_mt({NAME = "DlSupportedOperatingModes", ID = data_types.name_to_id_map["Uint16"]}, 2) + +DlSupportedOperatingModes.BASE_MASK = 0xFFFF +DlSupportedOperatingModes.NORMAL = 0x0001 +DlSupportedOperatingModes.VACATION = 0x0002 +DlSupportedOperatingModes.PRIVACY = 0x0004 +DlSupportedOperatingModes.NO_REMOTE_LOCK_UNLOCK = 0x0008 +DlSupportedOperatingModes.PASSAGE = 0x0010 + +DlSupportedOperatingModes.mask_fields = { + BASE_MASK = 0xFFFF, + NORMAL = 0x0001, + VACATION = 0x0002, + PRIVACY = 0x0004, + NO_REMOTE_LOCK_UNLOCK = 0x0008, + PASSAGE = 0x0010, +} + +DlSupportedOperatingModes.is_normal_set = function(self) + return (self.value & self.NORMAL) ~= 0 +end + +DlSupportedOperatingModes.set_normal = function(self) + if self.value ~= nil then + self.value = self.value | self.NORMAL + else + self.value = self.NORMAL + end +end + +DlSupportedOperatingModes.unset_normal = function(self) + self.value = self.value & (~self.NORMAL & self.BASE_MASK) +end + +DlSupportedOperatingModes.is_vacation_set = function(self) + return (self.value & self.VACATION) ~= 0 +end + +DlSupportedOperatingModes.set_vacation = function(self) + if self.value ~= nil then + self.value = self.value | self.VACATION + else + self.value = self.VACATION + end +end + +DlSupportedOperatingModes.unset_vacation = function(self) + self.value = self.value & (~self.VACATION & self.BASE_MASK) +end + +DlSupportedOperatingModes.is_privacy_set = function(self) + return (self.value & self.PRIVACY) ~= 0 +end + +DlSupportedOperatingModes.set_privacy = function(self) + if self.value ~= nil then + self.value = self.value | self.PRIVACY + else + self.value = self.PRIVACY + end +end + +DlSupportedOperatingModes.unset_privacy = function(self) + self.value = self.value & (~self.PRIVACY & self.BASE_MASK) +end + +DlSupportedOperatingModes.is_no_remote_lock_unlock_set = function(self) + return (self.value & self.NO_REMOTE_LOCK_UNLOCK) ~= 0 +end + +DlSupportedOperatingModes.set_no_remote_lock_unlock = function(self) + if self.value ~= nil then + self.value = self.value | self.NO_REMOTE_LOCK_UNLOCK + else + self.value = self.NO_REMOTE_LOCK_UNLOCK + end +end + +DlSupportedOperatingModes.unset_no_remote_lock_unlock = function(self) + self.value = self.value & (~self.NO_REMOTE_LOCK_UNLOCK & self.BASE_MASK) +end + +DlSupportedOperatingModes.is_passage_set = function(self) + return (self.value & self.PASSAGE) ~= 0 +end + +DlSupportedOperatingModes.set_passage = function(self) + if self.value ~= nil then + self.value = self.value | self.PASSAGE + else + self.value = self.PASSAGE + end +end + +DlSupportedOperatingModes.unset_passage = function(self) + self.value = self.value & (~self.PASSAGE & self.BASE_MASK) +end + +DlSupportedOperatingModes.mask_methods = { + is_normal_set = DlSupportedOperatingModes.is_normal_set, + set_normal = DlSupportedOperatingModes.set_normal, + unset_normal = DlSupportedOperatingModes.unset_normal, + is_vacation_set = DlSupportedOperatingModes.is_vacation_set, + set_vacation = DlSupportedOperatingModes.set_vacation, + unset_vacation = DlSupportedOperatingModes.unset_vacation, + is_privacy_set = DlSupportedOperatingModes.is_privacy_set, + set_privacy = DlSupportedOperatingModes.set_privacy, + unset_privacy = DlSupportedOperatingModes.unset_privacy, + is_no_remote_lock_unlock_set = DlSupportedOperatingModes.is_no_remote_lock_unlock_set, + set_no_remote_lock_unlock = DlSupportedOperatingModes.set_no_remote_lock_unlock, + unset_no_remote_lock_unlock = DlSupportedOperatingModes.unset_no_remote_lock_unlock, + is_passage_set = DlSupportedOperatingModes.is_passage_set, + set_passage = DlSupportedOperatingModes.set_passage, + unset_passage = DlSupportedOperatingModes.unset_passage, +} + +DlSupportedOperatingModes.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DlSupportedOperatingModes, new_mt) + +return DlSupportedOperatingModes \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockDayOfWeek.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockDayOfWeek.lua new file mode 100644 index 0000000000..9a03693d35 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockDayOfWeek.lua @@ -0,0 +1,169 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DoorLockDayOfWeek = {} +local new_mt = UintABC.new_mt({NAME = "DoorLockDayOfWeek", ID = data_types.name_to_id_map["Uint8"]}, 1) + +DoorLockDayOfWeek.BASE_MASK = 0xFFFF +DoorLockDayOfWeek.SUNDAY = 0x0001 +DoorLockDayOfWeek.MONDAY = 0x0002 +DoorLockDayOfWeek.TUESDAY = 0x0004 +DoorLockDayOfWeek.WEDNESDAY = 0x0008 +DoorLockDayOfWeek.THURSDAY = 0x0010 +DoorLockDayOfWeek.FRIDAY = 0x0020 +DoorLockDayOfWeek.SATURDAY = 0x0040 + +DoorLockDayOfWeek.mask_fields = { + BASE_MASK = 0xFFFF, + SUNDAY = 0x0001, + MONDAY = 0x0002, + TUESDAY = 0x0004, + WEDNESDAY = 0x0008, + THURSDAY = 0x0010, + FRIDAY = 0x0020, + SATURDAY = 0x0040, +} + +DoorLockDayOfWeek.is_sunday_set = function(self) + return (self.value & self.SUNDAY) ~= 0 +end + +DoorLockDayOfWeek.set_sunday = function(self) + if self.value ~= nil then + self.value = self.value | self.SUNDAY + else + self.value = self.SUNDAY + end +end + +DoorLockDayOfWeek.unset_sunday = function(self) + self.value = self.value & (~self.SUNDAY & self.BASE_MASK) +end + +DoorLockDayOfWeek.is_monday_set = function(self) + return (self.value & self.MONDAY) ~= 0 +end + +DoorLockDayOfWeek.set_monday = function(self) + if self.value ~= nil then + self.value = self.value | self.MONDAY + else + self.value = self.MONDAY + end +end + +DoorLockDayOfWeek.unset_monday = function(self) + self.value = self.value & (~self.MONDAY & self.BASE_MASK) +end + +DoorLockDayOfWeek.is_tuesday_set = function(self) + return (self.value & self.TUESDAY) ~= 0 +end + +DoorLockDayOfWeek.set_tuesday = function(self) + if self.value ~= nil then + self.value = self.value | self.TUESDAY + else + self.value = self.TUESDAY + end +end + +DoorLockDayOfWeek.unset_tuesday = function(self) + self.value = self.value & (~self.TUESDAY & self.BASE_MASK) +end + +DoorLockDayOfWeek.is_wednesday_set = function(self) + return (self.value & self.WEDNESDAY) ~= 0 +end + +DoorLockDayOfWeek.set_wednesday = function(self) + if self.value ~= nil then + self.value = self.value | self.WEDNESDAY + else + self.value = self.WEDNESDAY + end +end + +DoorLockDayOfWeek.unset_wednesday = function(self) + self.value = self.value & (~self.WEDNESDAY & self.BASE_MASK) +end + +DoorLockDayOfWeek.is_thursday_set = function(self) + return (self.value & self.THURSDAY) ~= 0 +end + +DoorLockDayOfWeek.set_thursday = function(self) + if self.value ~= nil then + self.value = self.value | self.THURSDAY + else + self.value = self.THURSDAY + end +end + +DoorLockDayOfWeek.unset_thursday = function(self) + self.value = self.value & (~self.THURSDAY & self.BASE_MASK) +end + +DoorLockDayOfWeek.is_friday_set = function(self) + return (self.value & self.FRIDAY) ~= 0 +end + +DoorLockDayOfWeek.set_friday = function(self) + if self.value ~= nil then + self.value = self.value | self.FRIDAY + else + self.value = self.FRIDAY + end +end + +DoorLockDayOfWeek.unset_friday = function(self) + self.value = self.value & (~self.FRIDAY & self.BASE_MASK) +end + +DoorLockDayOfWeek.is_saturday_set = function(self) + return (self.value & self.SATURDAY) ~= 0 +end + +DoorLockDayOfWeek.set_saturday = function(self) + if self.value ~= nil then + self.value = self.value | self.SATURDAY + else + self.value = self.SATURDAY + end +end + +DoorLockDayOfWeek.unset_saturday = function(self) + self.value = self.value & (~self.SATURDAY & self.BASE_MASK) +end + +DoorLockDayOfWeek.mask_methods = { + is_sunday_set = DoorLockDayOfWeek.is_sunday_set, + set_sunday = DoorLockDayOfWeek.set_sunday, + unset_sunday = DoorLockDayOfWeek.unset_sunday, + is_monday_set = DoorLockDayOfWeek.is_monday_set, + set_monday = DoorLockDayOfWeek.set_monday, + unset_monday = DoorLockDayOfWeek.unset_monday, + is_tuesday_set = DoorLockDayOfWeek.is_tuesday_set, + set_tuesday = DoorLockDayOfWeek.set_tuesday, + unset_tuesday = DoorLockDayOfWeek.unset_tuesday, + is_wednesday_set = DoorLockDayOfWeek.is_wednesday_set, + set_wednesday = DoorLockDayOfWeek.set_wednesday, + unset_wednesday = DoorLockDayOfWeek.unset_wednesday, + is_thursday_set = DoorLockDayOfWeek.is_thursday_set, + set_thursday = DoorLockDayOfWeek.set_thursday, + unset_thursday = DoorLockDayOfWeek.unset_thursday, + is_friday_set = DoorLockDayOfWeek.is_friday_set, + set_friday = DoorLockDayOfWeek.set_friday, + unset_friday = DoorLockDayOfWeek.unset_friday, + is_saturday_set = DoorLockDayOfWeek.is_saturday_set, + set_saturday = DoorLockDayOfWeek.set_saturday, + unset_saturday = DoorLockDayOfWeek.unset_saturday, +} + +DoorLockDayOfWeek.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DoorLockDayOfWeek, new_mt) + +return DoorLockDayOfWeek \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockOperationEventCode.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockOperationEventCode.lua new file mode 100644 index 0000000000..556fdff0cd --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockOperationEventCode.lua @@ -0,0 +1,66 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DoorLockOperationEventCode = {} +local new_mt = UintABC.new_mt({NAME = "DoorLockOperationEventCode", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN_OR_MFG_SPECIFIC] = "UNKNOWN_OR_MFG_SPECIFIC", + [self.LOCK] = "LOCK", + [self.UNLOCK] = "UNLOCK", + [self.LOCK_INVALID_PIN_OR_ID] = "LOCK_INVALID_PIN_OR_ID", + [self.LOCK_INVALID_SCHEDULE] = "LOCK_INVALID_SCHEDULE", + [self.UNLOCK_INVALID_PIN_OR_ID] = "UNLOCK_INVALID_PIN_OR_ID", + [self.UNLOCK_INVALID_SCHEDULE] = "UNLOCK_INVALID_SCHEDULE", + [self.ONE_TOUCH_LOCK] = "ONE_TOUCH_LOCK", + [self.KEY_LOCK] = "KEY_LOCK", + [self.KEY_UNLOCK] = "KEY_UNLOCK", + [self.AUTO_LOCK] = "AUTO_LOCK", + [self.SCHEDULE_LOCK] = "SCHEDULE_LOCK", + [self.SCHEDULE_UNLOCK] = "SCHEDULE_UNLOCK", + [self.MANUAL_LOCK] = "MANUAL_LOCK", + [self.MANUAL_UNLOCK] = "MANUAL_UNLOCK", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN_OR_MFG_SPECIFIC = 0x00 +new_mt.__index.LOCK = 0x01 +new_mt.__index.UNLOCK = 0x02 +new_mt.__index.LOCK_INVALID_PIN_OR_ID = 0x03 +new_mt.__index.LOCK_INVALID_SCHEDULE = 0x04 +new_mt.__index.UNLOCK_INVALID_PIN_OR_ID = 0x05 +new_mt.__index.UNLOCK_INVALID_SCHEDULE = 0x06 +new_mt.__index.ONE_TOUCH_LOCK = 0x07 +new_mt.__index.KEY_LOCK = 0x08 +new_mt.__index.KEY_UNLOCK = 0x09 +new_mt.__index.AUTO_LOCK = 0x0A +new_mt.__index.SCHEDULE_LOCK = 0x0B +new_mt.__index.SCHEDULE_UNLOCK = 0x0C +new_mt.__index.MANUAL_LOCK = 0x0D +new_mt.__index.MANUAL_UNLOCK = 0x0E + +DoorLockOperationEventCode.UNKNOWN_OR_MFG_SPECIFIC = 0x00 +DoorLockOperationEventCode.LOCK = 0x01 +DoorLockOperationEventCode.UNLOCK = 0x02 +DoorLockOperationEventCode.LOCK_INVALID_PIN_OR_ID = 0x03 +DoorLockOperationEventCode.LOCK_INVALID_SCHEDULE = 0x04 +DoorLockOperationEventCode.UNLOCK_INVALID_PIN_OR_ID = 0x05 +DoorLockOperationEventCode.UNLOCK_INVALID_SCHEDULE = 0x06 +DoorLockOperationEventCode.ONE_TOUCH_LOCK = 0x07 +DoorLockOperationEventCode.KEY_LOCK = 0x08 +DoorLockOperationEventCode.KEY_UNLOCK = 0x09 +DoorLockOperationEventCode.AUTO_LOCK = 0x0A +DoorLockOperationEventCode.SCHEDULE_LOCK = 0x0B +DoorLockOperationEventCode.SCHEDULE_UNLOCK = 0x0C +DoorLockOperationEventCode.MANUAL_LOCK = 0x0D +DoorLockOperationEventCode.MANUAL_UNLOCK = 0x0E + +DoorLockOperationEventCode.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DoorLockOperationEventCode, new_mt) + +return DoorLockOperationEventCode \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockProgrammingEventCode.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockProgrammingEventCode.lua new file mode 100644 index 0000000000..ffb91d592f --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockProgrammingEventCode.lua @@ -0,0 +1,42 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DoorLockProgrammingEventCode = {} +local new_mt = UintABC.new_mt({NAME = "DoorLockProgrammingEventCode", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN_OR_MFG_SPECIFIC] = "UNKNOWN_OR_MFG_SPECIFIC", + [self.MASTER_CODE_CHANGED] = "MASTER_CODE_CHANGED", + [self.PIN_ADDED] = "PIN_ADDED", + [self.PIN_DELETED] = "PIN_DELETED", + [self.PIN_CHANGED] = "PIN_CHANGED", + [self.ID_ADDED] = "ID_ADDED", + [self.ID_DELETED] = "ID_DELETED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN_OR_MFG_SPECIFIC = 0x00 +new_mt.__index.MASTER_CODE_CHANGED = 0x01 +new_mt.__index.PIN_ADDED = 0x02 +new_mt.__index.PIN_DELETED = 0x03 +new_mt.__index.PIN_CHANGED = 0x04 +new_mt.__index.ID_ADDED = 0x05 +new_mt.__index.ID_DELETED = 0x06 + +DoorLockProgrammingEventCode.UNKNOWN_OR_MFG_SPECIFIC = 0x00 +DoorLockProgrammingEventCode.MASTER_CODE_CHANGED = 0x01 +DoorLockProgrammingEventCode.PIN_ADDED = 0x02 +DoorLockProgrammingEventCode.PIN_DELETED = 0x03 +DoorLockProgrammingEventCode.PIN_CHANGED = 0x04 +DoorLockProgrammingEventCode.ID_ADDED = 0x05 +DoorLockProgrammingEventCode.ID_DELETED = 0x06 + +DoorLockProgrammingEventCode.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DoorLockProgrammingEventCode, new_mt) + +return DoorLockProgrammingEventCode \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockSetPinOrIdStatus.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockSetPinOrIdStatus.lua new file mode 100644 index 0000000000..6e32459354 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockSetPinOrIdStatus.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DoorLockSetPinOrIdStatus = {} +local new_mt = UintABC.new_mt({NAME = "DoorLockSetPinOrIdStatus", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.SUCCESS] = "SUCCESS", + [self.GENERAL_FAILURE] = "GENERAL_FAILURE", + [self.MEMORY_FULL] = "MEMORY_FULL", + [self.DUPLICATE_CODE_ERROR] = "DUPLICATE_CODE_ERROR", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.SUCCESS = 0x00 +new_mt.__index.GENERAL_FAILURE = 0x01 +new_mt.__index.MEMORY_FULL = 0x02 +new_mt.__index.DUPLICATE_CODE_ERROR = 0x03 + +DoorLockSetPinOrIdStatus.SUCCESS = 0x00 +DoorLockSetPinOrIdStatus.GENERAL_FAILURE = 0x01 +DoorLockSetPinOrIdStatus.MEMORY_FULL = 0x02 +DoorLockSetPinOrIdStatus.DUPLICATE_CODE_ERROR = 0x03 + +DoorLockSetPinOrIdStatus.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DoorLockSetPinOrIdStatus, new_mt) + +return DoorLockSetPinOrIdStatus \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserStatus.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserStatus.lua new file mode 100644 index 0000000000..b755a48bb5 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserStatus.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DoorLockUserStatus = {} +local new_mt = UintABC.new_mt({NAME = "DoorLockUserStatus", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.AVAILABLE] = "AVAILABLE", + [self.OCCUPIED_ENABLED] = "OCCUPIED_ENABLED", + [self.OCCUPIED_DISABLED] = "OCCUPIED_DISABLED", + [self.NOT_SUPPORTED] = "NOT_SUPPORTED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.AVAILABLE = 0x00 +new_mt.__index.OCCUPIED_ENABLED = 0x01 +new_mt.__index.OCCUPIED_DISABLED = 0x03 +new_mt.__index.NOT_SUPPORTED = 0xFF + +DoorLockUserStatus.AVAILABLE = 0x00 +DoorLockUserStatus.OCCUPIED_ENABLED = 0x01 +DoorLockUserStatus.OCCUPIED_DISABLED = 0x03 +DoorLockUserStatus.NOT_SUPPORTED = 0xFF + +DoorLockUserStatus.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DoorLockUserStatus, new_mt) + +return DoorLockUserStatus \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserType.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserType.lua new file mode 100644 index 0000000000..b8f6724f46 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorLockUserType.lua @@ -0,0 +1,39 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DoorLockUserType = {} +local new_mt = UintABC.new_mt({NAME = "DoorLockUserType", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNRESTRICTED] = "UNRESTRICTED", + [self.YEAR_DAY_SCHEDULE_USER] = "YEAR_DAY_SCHEDULE_USER", + [self.WEEK_DAY_SCHEDULE_USER] = "WEEK_DAY_SCHEDULE_USER", + [self.MASTER_USER] = "MASTER_USER", + [self.NON_ACCESS_USER] = "NON_ACCESS_USER", + [self.NOT_SUPPORTED] = "NOT_SUPPORTED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNRESTRICTED = 0x00 +new_mt.__index.YEAR_DAY_SCHEDULE_USER = 0x01 +new_mt.__index.WEEK_DAY_SCHEDULE_USER = 0x02 +new_mt.__index.MASTER_USER = 0x03 +new_mt.__index.NON_ACCESS_USER = 0x04 +new_mt.__index.NOT_SUPPORTED = 0xFF + +DoorLockUserType.UNRESTRICTED = 0x00 +DoorLockUserType.YEAR_DAY_SCHEDULE_USER = 0x01 +DoorLockUserType.WEEK_DAY_SCHEDULE_USER = 0x02 +DoorLockUserType.MASTER_USER = 0x03 +DoorLockUserType.NON_ACCESS_USER = 0x04 +DoorLockUserType.NOT_SUPPORTED = 0xFF + +DoorLockUserType.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DoorLockUserType, new_mt) + +return DoorLockUserType \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorStateEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorStateEnum.lua new file mode 100644 index 0000000000..ae01ab195c --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/DoorStateEnum.lua @@ -0,0 +1,39 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local DoorStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "DoorStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.DOOR_OPEN] = "DOOR_OPEN", + [self.DOOR_CLOSED] = "DOOR_CLOSED", + [self.DOOR_JAMMED] = "DOOR_JAMMED", + [self.DOOR_FORCED_OPEN] = "DOOR_FORCED_OPEN", + [self.DOOR_UNSPECIFIED_ERROR] = "DOOR_UNSPECIFIED_ERROR", + [self.DOOR_AJAR] = "DOOR_AJAR", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.DOOR_OPEN = 0x00 +new_mt.__index.DOOR_CLOSED = 0x01 +new_mt.__index.DOOR_JAMMED = 0x02 +new_mt.__index.DOOR_FORCED_OPEN = 0x03 +new_mt.__index.DOOR_UNSPECIFIED_ERROR = 0x04 +new_mt.__index.DOOR_AJAR = 0x05 + +DoorStateEnum.DOOR_OPEN = 0x00 +DoorStateEnum.DOOR_CLOSED = 0x01 +DoorStateEnum.DOOR_JAMMED = 0x02 +DoorStateEnum.DOOR_FORCED_OPEN = 0x03 +DoorStateEnum.DOOR_UNSPECIFIED_ERROR = 0x04 +DoorStateEnum.DOOR_AJAR = 0x05 + +DoorStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(DoorStateEnum, new_mt) + +return DoorStateEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/Feature.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/Feature.lua new file mode 100644 index 0000000000..d45915c86c --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/Feature.lua @@ -0,0 +1,361 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.PIN_CREDENTIAL = 0x0001 +Feature.RFID_CREDENTIAL = 0x0002 +Feature.FINGER_CREDENTIALS = 0x0004 +Feature.LOGGING = 0x0008 +Feature.WEEK_DAY_ACCESS_SCHEDULES = 0x0010 +Feature.DOOR_POSITION_SENSOR = 0x0020 +Feature.FACE_CREDENTIALS = 0x0040 +Feature.CREDENTIALS_OVER_THE_AIR_ACCESS = 0x0080 +Feature.USER = 0x0100 +Feature.NOTIFICATION = 0x0200 +Feature.YEAR_DAY_ACCESS_SCHEDULES = 0x0400 +Feature.HOLIDAY_SCHEDULES = 0x0800 +Feature.UNBOLT = 0x1000 +Feature.ALIRO_PROVISIONING = 0x2000 +Feature.ALIROBLEUWB = 0x4000 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + PIN_CREDENTIAL = 0x0001, + RFID_CREDENTIAL = 0x0002, + FINGER_CREDENTIALS = 0x0004, + LOGGING = 0x0008, + WEEK_DAY_ACCESS_SCHEDULES = 0x0010, + DOOR_POSITION_SENSOR = 0x0020, + FACE_CREDENTIALS = 0x0040, + CREDENTIALS_OVER_THE_AIR_ACCESS = 0x0080, + USER = 0x0100, + NOTIFICATION = 0x0200, + YEAR_DAY_ACCESS_SCHEDULES = 0x0400, + HOLIDAY_SCHEDULES = 0x0800, + UNBOLT = 0x1000, + ALIRO_PROVISIONING = 0x2000, + ALIROBLEUWB = 0x4000, +} + +Feature.is_pin_credential_set = function(self) + return (self.value & self.PIN_CREDENTIAL) ~= 0 +end + +Feature.set_pin_credential = function(self) + if self.value ~= nil then + self.value = self.value | self.PIN_CREDENTIAL + else + self.value = self.PIN_CREDENTIAL + end +end + +Feature.unset_pin_credential = function(self) + self.value = self.value & (~self.PIN_CREDENTIAL & self.BASE_MASK) +end + +Feature.is_rfid_credential_set = function(self) + return (self.value & self.RFID_CREDENTIAL) ~= 0 +end + +Feature.set_rfid_credential = function(self) + if self.value ~= nil then + self.value = self.value | self.RFID_CREDENTIAL + else + self.value = self.RFID_CREDENTIAL + end +end + +Feature.unset_rfid_credential = function(self) + self.value = self.value & (~self.RFID_CREDENTIAL & self.BASE_MASK) +end + +Feature.is_finger_credentials_set = function(self) + return (self.value & self.FINGER_CREDENTIALS) ~= 0 +end + +Feature.set_finger_credentials = function(self) + if self.value ~= nil then + self.value = self.value | self.FINGER_CREDENTIALS + else + self.value = self.FINGER_CREDENTIALS + end +end + +Feature.unset_finger_credentials = function(self) + self.value = self.value & (~self.FINGER_CREDENTIALS & self.BASE_MASK) +end + +Feature.is_logging_set = function(self) + return (self.value & self.LOGGING) ~= 0 +end + +Feature.set_logging = function(self) + if self.value ~= nil then + self.value = self.value | self.LOGGING + else + self.value = self.LOGGING + end +end + +Feature.unset_logging = function(self) + self.value = self.value & (~self.LOGGING & self.BASE_MASK) +end + +Feature.is_week_day_access_schedules_set = function(self) + return (self.value & self.WEEK_DAY_ACCESS_SCHEDULES) ~= 0 +end + +Feature.set_week_day_access_schedules = function(self) + if self.value ~= nil then + self.value = self.value | self.WEEK_DAY_ACCESS_SCHEDULES + else + self.value = self.WEEK_DAY_ACCESS_SCHEDULES + end +end + +Feature.unset_week_day_access_schedules = function(self) + self.value = self.value & (~self.WEEK_DAY_ACCESS_SCHEDULES & self.BASE_MASK) +end + +Feature.is_door_position_sensor_set = function(self) + return (self.value & self.DOOR_POSITION_SENSOR) ~= 0 +end + +Feature.set_door_position_sensor = function(self) + if self.value ~= nil then + self.value = self.value | self.DOOR_POSITION_SENSOR + else + self.value = self.DOOR_POSITION_SENSOR + end +end + +Feature.unset_door_position_sensor = function(self) + self.value = self.value & (~self.DOOR_POSITION_SENSOR & self.BASE_MASK) +end + +Feature.is_face_credentials_set = function(self) + return (self.value & self.FACE_CREDENTIALS) ~= 0 +end + +Feature.set_face_credentials = function(self) + if self.value ~= nil then + self.value = self.value | self.FACE_CREDENTIALS + else + self.value = self.FACE_CREDENTIALS + end +end + +Feature.unset_face_credentials = function(self) + self.value = self.value & (~self.FACE_CREDENTIALS & self.BASE_MASK) +end + +Feature.is_credentials_over_the_air_access_set = function(self) + return (self.value & self.CREDENTIALS_OVER_THE_AIR_ACCESS) ~= 0 +end + +Feature.set_credentials_over_the_air_access = function(self) + if self.value ~= nil then + self.value = self.value | self.CREDENTIALS_OVER_THE_AIR_ACCESS + else + self.value = self.CREDENTIALS_OVER_THE_AIR_ACCESS + end +end + +Feature.unset_credentials_over_the_air_access = function(self) + self.value = self.value & (~self.CREDENTIALS_OVER_THE_AIR_ACCESS & self.BASE_MASK) +end + +Feature.is_user_set = function(self) + return (self.value & self.USER) ~= 0 +end + +Feature.set_user = function(self) + if self.value ~= nil then + self.value = self.value | self.USER + else + self.value = self.USER + end +end + +Feature.unset_user = function(self) + self.value = self.value & (~self.USER & self.BASE_MASK) +end + +Feature.is_notification_set = function(self) + return (self.value & self.NOTIFICATION) ~= 0 +end + +Feature.set_notification = function(self) + if self.value ~= nil then + self.value = self.value | self.NOTIFICATION + else + self.value = self.NOTIFICATION + end +end + +Feature.unset_notification = function(self) + self.value = self.value & (~self.NOTIFICATION & self.BASE_MASK) +end + +Feature.is_year_day_access_schedules_set = function(self) + return (self.value & self.YEAR_DAY_ACCESS_SCHEDULES) ~= 0 +end + +Feature.set_year_day_access_schedules = function(self) + if self.value ~= nil then + self.value = self.value | self.YEAR_DAY_ACCESS_SCHEDULES + else + self.value = self.YEAR_DAY_ACCESS_SCHEDULES + end +end + +Feature.unset_year_day_access_schedules = function(self) + self.value = self.value & (~self.YEAR_DAY_ACCESS_SCHEDULES & self.BASE_MASK) +end + +Feature.is_holiday_schedules_set = function(self) + return (self.value & self.HOLIDAY_SCHEDULES) ~= 0 +end + +Feature.set_holiday_schedules = function(self) + if self.value ~= nil then + self.value = self.value | self.HOLIDAY_SCHEDULES + else + self.value = self.HOLIDAY_SCHEDULES + end +end + +Feature.unset_holiday_schedules = function(self) + self.value = self.value & (~self.HOLIDAY_SCHEDULES & self.BASE_MASK) +end + +Feature.is_unbolt_set = function(self) + return (self.value & self.UNBOLT) ~= 0 +end + +Feature.set_unbolt = function(self) + if self.value ~= nil then + self.value = self.value | self.UNBOLT + else + self.value = self.UNBOLT + end +end + +Feature.unset_unbolt = function(self) + self.value = self.value & (~self.UNBOLT & self.BASE_MASK) +end + +Feature.is_aliro_provisioning_set = function(self) + return (self.value & self.ALIRO_PROVISIONING) ~= 0 +end + +Feature.set_aliro_provisioning = function(self) + if self.value ~= nil then + self.value = self.value | self.ALIRO_PROVISIONING + else + self.value = self.ALIRO_PROVISIONING + end +end + +Feature.unset_aliro_provisioning = function(self) + self.value = self.value & (~self.ALIRO_PROVISIONING & self.BASE_MASK) +end + +Feature.is_alirobleuwb_set = function(self) + return (self.value & self.ALIROBLEUWB) ~= 0 +end + +Feature.set_alirobleuwb = function(self) + if self.value ~= nil then + self.value = self.value | self.ALIROBLEUWB + else + self.value = self.ALIROBLEUWB + end +end + +Feature.unset_alirobleuwb = function(self) + self.value = self.value & (~self.ALIROBLEUWB & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.PIN_CREDENTIAL | + Feature.RFID_CREDENTIAL | + Feature.FINGER_CREDENTIALS | + Feature.LOGGING | + Feature.WEEK_DAY_ACCESS_SCHEDULES | + Feature.DOOR_POSITION_SENSOR | + Feature.FACE_CREDENTIALS | + Feature.CREDENTIALS_OVER_THE_AIR_ACCESS | + Feature.USER | + Feature.NOTIFICATION | + Feature.YEAR_DAY_ACCESS_SCHEDULES | + Feature.HOLIDAY_SCHEDULES | + Feature.UNBOLT | + Feature.ALIRO_PROVISIONING | + Feature.ALIROBLEUWB + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_pin_credential_set = Feature.is_pin_credential_set, + set_pin_credential = Feature.set_pin_credential, + unset_pin_credential = Feature.unset_pin_credential, + is_rfid_credential_set = Feature.is_rfid_credential_set, + set_rfid_credential = Feature.set_rfid_credential, + unset_rfid_credential = Feature.unset_rfid_credential, + is_finger_credentials_set = Feature.is_finger_credentials_set, + set_finger_credentials = Feature.set_finger_credentials, + unset_finger_credentials = Feature.unset_finger_credentials, + is_logging_set = Feature.is_logging_set, + set_logging = Feature.set_logging, + unset_logging = Feature.unset_logging, + is_week_day_access_schedules_set = Feature.is_week_day_access_schedules_set, + set_week_day_access_schedules = Feature.set_week_day_access_schedules, + unset_week_day_access_schedules = Feature.unset_week_day_access_schedules, + is_door_position_sensor_set = Feature.is_door_position_sensor_set, + set_door_position_sensor = Feature.set_door_position_sensor, + unset_door_position_sensor = Feature.unset_door_position_sensor, + is_face_credentials_set = Feature.is_face_credentials_set, + set_face_credentials = Feature.set_face_credentials, + unset_face_credentials = Feature.unset_face_credentials, + is_credentials_over_the_air_access_set = Feature.is_credentials_over_the_air_access_set, + set_credentials_over_the_air_access = Feature.set_credentials_over_the_air_access, + unset_credentials_over_the_air_access = Feature.unset_credentials_over_the_air_access, + is_user_set = Feature.is_user_set, + set_user = Feature.set_user, + unset_user = Feature.unset_user, + is_notification_set = Feature.is_notification_set, + set_notification = Feature.set_notification, + unset_notification = Feature.unset_notification, + is_year_day_access_schedules_set = Feature.is_year_day_access_schedules_set, + set_year_day_access_schedules = Feature.set_year_day_access_schedules, + unset_year_day_access_schedules = Feature.unset_year_day_access_schedules, + is_holiday_schedules_set = Feature.is_holiday_schedules_set, + set_holiday_schedules = Feature.set_holiday_schedules, + unset_holiday_schedules = Feature.unset_holiday_schedules, + is_unbolt_set = Feature.is_unbolt_set, + set_unbolt = Feature.set_unbolt, + unset_unbolt = Feature.unset_unbolt, + is_aliro_provisioning_set = Feature.is_aliro_provisioning_set, + set_aliro_provisioning = Feature.set_aliro_provisioning, + unset_aliro_provisioning = Feature.unset_aliro_provisioning, + is_alirobleuwb_set = Feature.is_alirobleuwb_set, + set_alirobleuwb = Feature.set_alirobleuwb, + unset_alirobleuwb = Feature.unset_alirobleuwb, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/LockDataTypeEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/LockDataTypeEnum.lua new file mode 100644 index 0000000000..a278831c3b --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/LockDataTypeEnum.lua @@ -0,0 +1,63 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LockDataTypeEnum = {} +local new_mt = UintABC.new_mt({NAME = "LockDataTypeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNSPECIFIED] = "UNSPECIFIED", + [self.PROGRAMMING_CODE] = "PROGRAMMING_CODE", + [self.USER_INDEX] = "USER_INDEX", + [self.WEEK_DAY_SCHEDULE] = "WEEK_DAY_SCHEDULE", + [self.YEAR_DAY_SCHEDULE] = "YEAR_DAY_SCHEDULE", + [self.HOLIDAY_SCHEDULE] = "HOLIDAY_SCHEDULE", + [self.PIN] = "PIN", + [self.RFID] = "RFID", + [self.FINGERPRINT] = "FINGERPRINT", + [self.FINGER_VEIN] = "FINGER_VEIN", + [self.FACE] = "FACE", + [self.ALIRO_CREDENTIAL_ISSUER_KEY] = "ALIRO_CREDENTIAL_ISSUER_KEY", + [self.ALIRO_EVICTABLE_ENDPOINT_KEY] = "ALIRO_EVICTABLE_ENDPOINT_KEY", + [self.ALIRO_NON_EVICTABLE_ENDPOINT_KEY] = "ALIRO_NON_EVICTABLE_ENDPOINT_KEY", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNSPECIFIED = 0x00 +new_mt.__index.PROGRAMMING_CODE = 0x01 +new_mt.__index.USER_INDEX = 0x02 +new_mt.__index.WEEK_DAY_SCHEDULE = 0x03 +new_mt.__index.YEAR_DAY_SCHEDULE = 0x04 +new_mt.__index.HOLIDAY_SCHEDULE = 0x05 +new_mt.__index.PIN = 0x06 +new_mt.__index.RFID = 0x07 +new_mt.__index.FINGERPRINT = 0x08 +new_mt.__index.FINGER_VEIN = 0x09 +new_mt.__index.FACE = 0x0A +new_mt.__index.ALIRO_CREDENTIAL_ISSUER_KEY = 0x0B +new_mt.__index.ALIRO_EVICTABLE_ENDPOINT_KEY = 0x0C +new_mt.__index.ALIRO_NON_EVICTABLE_ENDPOINT_KEY = 0x0D + +LockDataTypeEnum.UNSPECIFIED = 0x00 +LockDataTypeEnum.PROGRAMMING_CODE = 0x01 +LockDataTypeEnum.USER_INDEX = 0x02 +LockDataTypeEnum.WEEK_DAY_SCHEDULE = 0x03 +LockDataTypeEnum.YEAR_DAY_SCHEDULE = 0x04 +LockDataTypeEnum.HOLIDAY_SCHEDULE = 0x05 +LockDataTypeEnum.PIN = 0x06 +LockDataTypeEnum.RFID = 0x07 +LockDataTypeEnum.FINGERPRINT = 0x08 +LockDataTypeEnum.FINGER_VEIN = 0x09 +LockDataTypeEnum.FACE = 0x0A +LockDataTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY = 0x0B +LockDataTypeEnum.ALIRO_EVICTABLE_ENDPOINT_KEY = 0x0C +LockDataTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY = 0x0D + +LockDataTypeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LockDataTypeEnum, new_mt) + +return LockDataTypeEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/LockOperationTypeEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/LockOperationTypeEnum.lua new file mode 100644 index 0000000000..3d28c27a7e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/LockOperationTypeEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LockOperationTypeEnum = {} +local new_mt = UintABC.new_mt({NAME = "LockOperationTypeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.LOCK] = "LOCK", + [self.UNLOCK] = "UNLOCK", + [self.NON_ACCESS_USER_EVENT] = "NON_ACCESS_USER_EVENT", + [self.FORCED_USER_EVENT] = "FORCED_USER_EVENT", + [self.UNLATCH] = "UNLATCH", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.LOCK = 0x00 +new_mt.__index.UNLOCK = 0x01 +new_mt.__index.NON_ACCESS_USER_EVENT = 0x02 +new_mt.__index.FORCED_USER_EVENT = 0x03 +new_mt.__index.UNLATCH = 0x04 + +LockOperationTypeEnum.LOCK = 0x00 +LockOperationTypeEnum.UNLOCK = 0x01 +LockOperationTypeEnum.NON_ACCESS_USER_EVENT = 0x02 +LockOperationTypeEnum.FORCED_USER_EVENT = 0x03 +LockOperationTypeEnum.UNLATCH = 0x04 + +LockOperationTypeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LockOperationTypeEnum, new_mt) + +return LockOperationTypeEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/OperatingModeEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/OperatingModeEnum.lua new file mode 100644 index 0000000000..fc6c61c757 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/OperatingModeEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local OperatingModeEnum = {} +local new_mt = UintABC.new_mt({NAME = "OperatingModeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NORMAL] = "NORMAL", + [self.VACATION] = "VACATION", + [self.PRIVACY] = "PRIVACY", + [self.NO_REMOTE_LOCK_UNLOCK] = "NO_REMOTE_LOCK_UNLOCK", + [self.PASSAGE] = "PASSAGE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NORMAL = 0x00 +new_mt.__index.VACATION = 0x01 +new_mt.__index.PRIVACY = 0x02 +new_mt.__index.NO_REMOTE_LOCK_UNLOCK = 0x03 +new_mt.__index.PASSAGE = 0x04 + +OperatingModeEnum.NORMAL = 0x00 +OperatingModeEnum.VACATION = 0x01 +OperatingModeEnum.PRIVACY = 0x02 +OperatingModeEnum.NO_REMOTE_LOCK_UNLOCK = 0x03 +OperatingModeEnum.PASSAGE = 0x04 + +OperatingModeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(OperatingModeEnum, new_mt) + +return OperatingModeEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/OperationErrorEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/OperationErrorEnum.lua new file mode 100644 index 0000000000..79da0a7c58 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/OperationErrorEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local OperationErrorEnum = {} +local new_mt = UintABC.new_mt({NAME = "OperationErrorEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNSPECIFIED] = "UNSPECIFIED", + [self.INVALID_CREDENTIAL] = "INVALID_CREDENTIAL", + [self.DISABLED_USER_DENIED] = "DISABLED_USER_DENIED", + [self.RESTRICTED] = "RESTRICTED", + [self.INSUFFICIENT_BATTERY] = "INSUFFICIENT_BATTERY", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNSPECIFIED = 0x00 +new_mt.__index.INVALID_CREDENTIAL = 0x01 +new_mt.__index.DISABLED_USER_DENIED = 0x02 +new_mt.__index.RESTRICTED = 0x03 +new_mt.__index.INSUFFICIENT_BATTERY = 0x04 + +OperationErrorEnum.UNSPECIFIED = 0x00 +OperationErrorEnum.INVALID_CREDENTIAL = 0x01 +OperationErrorEnum.DISABLED_USER_DENIED = 0x02 +OperationErrorEnum.RESTRICTED = 0x03 +OperationErrorEnum.INSUFFICIENT_BATTERY = 0x04 + +OperationErrorEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(OperationErrorEnum, new_mt) + +return OperationErrorEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/OperationSourceEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/OperationSourceEnum.lua new file mode 100644 index 0000000000..5d5e9ef592 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/OperationSourceEnum.lua @@ -0,0 +1,54 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local OperationSourceEnum = {} +local new_mt = UintABC.new_mt({NAME = "OperationSourceEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNSPECIFIED] = "UNSPECIFIED", + [self.MANUAL] = "MANUAL", + [self.PROPRIETARY_REMOTE] = "PROPRIETARY_REMOTE", + [self.KEYPAD] = "KEYPAD", + [self.AUTO] = "AUTO", + [self.BUTTON] = "BUTTON", + [self.SCHEDULE] = "SCHEDULE", + [self.REMOTE] = "REMOTE", + [self.RFID] = "RFID", + [self.BIOMETRIC] = "BIOMETRIC", + [self.ALIRO] = "ALIRO", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNSPECIFIED = 0x00 +new_mt.__index.MANUAL = 0x01 +new_mt.__index.PROPRIETARY_REMOTE = 0x02 +new_mt.__index.KEYPAD = 0x03 +new_mt.__index.AUTO = 0x04 +new_mt.__index.BUTTON = 0x05 +new_mt.__index.SCHEDULE = 0x06 +new_mt.__index.REMOTE = 0x07 +new_mt.__index.RFID = 0x08 +new_mt.__index.BIOMETRIC = 0x09 +new_mt.__index.ALIRO = 0x0A + +OperationSourceEnum.UNSPECIFIED = 0x00 +OperationSourceEnum.MANUAL = 0x01 +OperationSourceEnum.PROPRIETARY_REMOTE = 0x02 +OperationSourceEnum.KEYPAD = 0x03 +OperationSourceEnum.AUTO = 0x04 +OperationSourceEnum.BUTTON = 0x05 +OperationSourceEnum.SCHEDULE = 0x06 +OperationSourceEnum.REMOTE = 0x07 +OperationSourceEnum.RFID = 0x08 +OperationSourceEnum.BIOMETRIC = 0x09 +OperationSourceEnum.ALIRO = 0x0A + +OperationSourceEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(OperationSourceEnum, new_mt) + +return OperationSourceEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/UserStatusEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/UserStatusEnum.lua new file mode 100644 index 0000000000..11e20c7a3a --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/UserStatusEnum.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local UserStatusEnum = {} +local new_mt = UintABC.new_mt({NAME = "UserStatusEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.AVAILABLE] = "AVAILABLE", + [self.OCCUPIED_ENABLED] = "OCCUPIED_ENABLED", + [self.OCCUPIED_DISABLED] = "OCCUPIED_DISABLED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.AVAILABLE = 0x00 +new_mt.__index.OCCUPIED_ENABLED = 0x01 +new_mt.__index.OCCUPIED_DISABLED = 0x03 + +UserStatusEnum.AVAILABLE = 0x00 +UserStatusEnum.OCCUPIED_ENABLED = 0x01 +UserStatusEnum.OCCUPIED_DISABLED = 0x03 + +UserStatusEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(UserStatusEnum, new_mt) + +return UserStatusEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/UserTypeEnum.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/UserTypeEnum.lua new file mode 100644 index 0000000000..9d00c2b940 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/UserTypeEnum.lua @@ -0,0 +1,51 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local UserTypeEnum = {} +local new_mt = UintABC.new_mt({NAME = "UserTypeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNRESTRICTED_USER] = "UNRESTRICTED_USER", + [self.YEAR_DAY_SCHEDULE_USER] = "YEAR_DAY_SCHEDULE_USER", + [self.WEEK_DAY_SCHEDULE_USER] = "WEEK_DAY_SCHEDULE_USER", + [self.PROGRAMMING_USER] = "PROGRAMMING_USER", + [self.NON_ACCESS_USER] = "NON_ACCESS_USER", + [self.FORCED_USER] = "FORCED_USER", + [self.DISPOSABLE_USER] = "DISPOSABLE_USER", + [self.EXPIRING_USER] = "EXPIRING_USER", + [self.SCHEDULE_RESTRICTED_USER] = "SCHEDULE_RESTRICTED_USER", + [self.REMOTE_ONLY_USER] = "REMOTE_ONLY_USER", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNRESTRICTED_USER = 0x00 +new_mt.__index.YEAR_DAY_SCHEDULE_USER = 0x01 +new_mt.__index.WEEK_DAY_SCHEDULE_USER = 0x02 +new_mt.__index.PROGRAMMING_USER = 0x03 +new_mt.__index.NON_ACCESS_USER = 0x04 +new_mt.__index.FORCED_USER = 0x05 +new_mt.__index.DISPOSABLE_USER = 0x06 +new_mt.__index.EXPIRING_USER = 0x07 +new_mt.__index.SCHEDULE_RESTRICTED_USER = 0x08 +new_mt.__index.REMOTE_ONLY_USER = 0x09 + +UserTypeEnum.UNRESTRICTED_USER = 0x00 +UserTypeEnum.YEAR_DAY_SCHEDULE_USER = 0x01 +UserTypeEnum.WEEK_DAY_SCHEDULE_USER = 0x02 +UserTypeEnum.PROGRAMMING_USER = 0x03 +UserTypeEnum.NON_ACCESS_USER = 0x04 +UserTypeEnum.FORCED_USER = 0x05 +UserTypeEnum.DISPOSABLE_USER = 0x06 +UserTypeEnum.EXPIRING_USER = 0x07 +UserTypeEnum.SCHEDULE_RESTRICTED_USER = 0x08 +UserTypeEnum.REMOTE_ONLY_USER = 0x09 + +UserTypeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(UserTypeEnum, new_mt) + +return UserTypeEnum \ No newline at end of file diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua new file mode 100644 index 0000000000..f4b2939d61 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("DoorLock.types." .. key) + end + return types_mt.__types_cache[key] +end + +local DoorLockTypes = {} + +setmetatable(DoorLockTypes, types_mt) + +return DoorLockTypes diff --git a/drivers/SmartThings/matter-lock/src/init.lua b/drivers/SmartThings/matter-lock/src/init.lua index 6c08eb1b61..f94702c496 100755 --- a/drivers/SmartThings/matter-lock/src/init.lua +++ b/drivers/SmartThings/matter-lock/src/init.lua @@ -1,45 +1,504 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local MatterDriver = require "st.matter.driver" +local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" +local utils = require "st.utils" +local lock_utils = require "lock_utils" + +local version = require "version" +if version.api < 10 then + clusters.DoorLock = require "DoorLock" +end local DoorLock = clusters.DoorLock local PowerSource = clusters.PowerSource -local capabilities = require "st.capabilities" -local im = require "st.matter.interaction_model" -local lock_utils = require "lock_utils" +local INITIAL_CREDENTIAL_INDEX = 1 +local ALL_INDEX = 0xFFFE +-- maximum as defined by the Matter specification +local MAX_USER_NAME_LENGTH = 10 + +local RESPONSE_STATUS_MAP = { + [DoorLock.types.DlStatus.SUCCESS] = "success", + [DoorLock.types.DlStatus.FAILURE] = "failure", + [DoorLock.types.DlStatus.DUPLICATE] = "duplicate", + [DoorLock.types.DlStatus.OCCUPIED] = "occupied", + [DoorLock.types.DlStatus.INVALID_FIELD] = "invalidCommand", + [DoorLock.types.DlStatus.RESOURCE_EXHAUSTED] = "resourceExhausted", + [DoorLock.types.DlStatus.NOT_FOUND] = "failure" +} + +local WEEK_DAY_MAP = { + ["Sunday"] = 1, + ["Monday"] = 2, + ["Tuesday"] = 4, + ["Wednesday"] = 8, + ["Thursday"] = 16, + ["Friday"] = 32, + ["Saturday"] = 64, +} + +local ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP = { + ["evictableEndpointKey"] = DoorLock.types.CredentialTypeEnum.ALIRO_EVICTABLE_ENDPOINT_KEY, + ["nonEvictableEndpointKey"] = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY +} + +local battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" +} + +local profiling_data = { + BATTERY_SUPPORT = "__BATTERY_SUPPORT", +} + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} +local subscribed_attributes = { + [capabilities.lock.ID] = { + DoorLock.attributes.LockState + }, + [capabilities.remoteControlStatus.ID] = { + DoorLock.attributes.OperatingMode + }, + [capabilities.lockUsers.ID] = { + DoorLock.attributes.NumberOfTotalUsersSupported + }, + [capabilities.lockCredentials.ID] = { + DoorLock.attributes.NumberOfPINUsersSupported, + DoorLock.attributes.MaxPINCodeLength, + DoorLock.attributes.MinPINCodeLength, + DoorLock.attributes.RequirePINforRemoteOperation + }, + [capabilities.lockSchedules.ID] = { + DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser, + DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser + }, + [capabilities.lockAliro.ID] = { + DoorLock.attributes.AliroReaderVerificationKey, + DoorLock.attributes.AliroReaderGroupIdentifier, + DoorLock.attributes.AliroReaderGroupSubIdentifier, + DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions, + DoorLock.attributes.AliroGroupResolvingKey, + DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions, + DoorLock.attributes.AliroBLEAdvertisingVersion, + DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported, + DoorLock.attributes.NumberOfAliroEndpointKeysSupported, + }, + [capabilities.battery.ID] = { + PowerSource.attributes.BatPercentRemaining + }, + [capabilities.batteryLevel.ID] = { + PowerSource.attributes.BatChargeLevel + } +} + +local subscribed_events = { + [capabilities.lock.ID] = { + DoorLock.events.LockOperation + }, + [capabilities.lockAlarm.ID] = { + DoorLock.events.DoorLockAlarm + }, + [capabilities.lockUsers.ID] = { + DoorLock.events.LockUserChange + } +} + +local function find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = device:get_endpoints(cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return res +end + +local function component_to_endpoint(device, component_name) + return find_default_endpoint(device, clusters.DoorLock.ID) +end + +local function device_init(driver, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) == 0 then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist = true}) + elseif device:get_field(profiling_data.BATTERY_SUPPORT) == nil then + device:add_subscribed_attribute(clusters.PowerSource.attributes.AttributeList) + end + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + device:add_subscribed_attribute(DoorLockFeatureMapAttr) + for cap_id, events in pairs(subscribed_events) do + if device:supports_capability_by_id(cap_id) then + for _, e in ipairs(events) do + device:add_subscribed_event(e) + end + end + end + device:subscribe() + end + +local function device_added(driver, device) + device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) +end + +local function set_reader_config(device) + local reader_config_updated = device:get_field(lock_utils.ALIRO_READER_CONFIG_UPDATED) or nil + if reader_config_updated == "TRUE" or reader_config_updated == "IN_PROGRESS" then return end + + local cmdName = "setReaderConfig" + local groupId = lock_utils.create_group_id_resolving_key() + local groupResolvingKey = nil + local aliro_ble_uwb_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.ALIROBLEUWB}) + if #aliro_ble_uwb_eps > 0 then + groupResolvingKey = lock_utils.create_group_id_resolving_key() + end + local privKey, pubKey = lock_utils.generate_keypair(device) + if not privKey or not pubKey then + local command_result_info = { + commandName = cmdName, + statusCode = "failure" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.VERIFICATION_KEY, pubKey, {persist = true}) + device:set_field(lock_utils.GROUP_ID, groupId, {persist = true}) + device:set_field(lock_utils.GROUP_RESOLVING_KEY, groupResolvingKey, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, clusters.DoorLock.ID) + device:send( + DoorLock.server.commands.SetAliroReaderConfig( + device, ep, + lock_utils.hex_string_to_octet_string(privKey), + lock_utils.hex_string_to_octet_string(pubKey), + lock_utils.hex_string_to_octet_string(groupId), + lock_utils.hex_string_to_octet_string(groupResolvingKey) + ) + ) + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "IN_PROGRESS", {persist = true}) +end + +local function match_profile_modular(driver, device) + local enabled_optional_component_capability_pairs = {} + local main_component_capabilities = {} + local modular_profile_name = "lock-modular" + for _, device_ep in pairs(device.endpoints) do + for _, ep_cluster in pairs(device_ep.clusters) do + if ep_cluster.cluster_id == DoorLock.ID then + local clus_has_feature = function(feature_bitmap) + return DoorLock.are_features_supported( + feature_bitmap, + lock_utils.get_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, device_ep.endpoint_id) or + ep_cluster.feature_map + ) + end + if clus_has_feature(DoorLock.types.Feature.USER) then + table.insert(main_component_capabilities, capabilities.lockUsers.ID) + end + if clus_has_feature(DoorLock.types.Feature.PIN_CREDENTIAL) then + table.insert(main_component_capabilities, capabilities.lockCredentials.ID) + end + if clus_has_feature(DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES) or + clus_has_feature(DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES) then + table.insert(main_component_capabilities, capabilities.lockSchedules.ID) + end + if clus_has_feature(DoorLock.types.Feature.UNBOLT) then + device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + modular_profile_name = "lock-modular-embedded-unlatch" -- use the embedded config specified in this profile for devices supporting "unlatch" + else + device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + end + if clus_has_feature(DoorLock.types.Feature.ALIRO_PROVISIONING) then + table.insert(main_component_capabilities, capabilities.lockAliro.ID) + end + break + end + end + end + + local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT) + if supported_battery_type == battery_support.BATTERY_LEVEL then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then + table.insert(main_component_capabilities, capabilities.battery.ID) + end + + table.insert(enabled_optional_component_capability_pairs, {"main", main_component_capabilities}) + if modular_profile_name == "lock-modular-embedded-unlatch" -- the embedded config that may be needed is not checked by an optional capability comparison + or device:supports_capability(capabilities.lockCodes) -- always re-profile a device using the legacy code storage methods + or lock_utils.optional_capabilities_list_changed(enabled_optional_component_capability_pairs, device.profile.components) then + device:try_update_metadata({profile = modular_profile_name, optional_component_capabilities = enabled_optional_component_capability_pairs}) + end +end + +local function match_profile_switch(driver, device) + local user_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.USER}) + local pin_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.PIN_CREDENTIAL}) + local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES}) + local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES}) + local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) + + local profile_name = "lock" + if #user_eps > 0 then + profile_name = profile_name .. "-user" + if #pin_eps > 0 then + profile_name = profile_name .. "-pin" + end + if #week_schedule_eps + #year_schedule_eps > 0 then + profile_name = profile_name .. "-schedule" + end + end + if #unbolt_eps > 0 then + profile_name = profile_name .. "-unlatch" + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + else + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + end + + local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT) + if supported_battery_type == battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + end + + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +local function deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return deep_equals(mt_a, mt_b, opts, seen) +end + +local function migrate_lock_codes(device) + local lockCodes = device:get_field(lock_utils.LOCK_CODES_FOR_MIGRATION) or {} + local user_table = {} + local credential_table = {} + for _, entry in ipairs(lockCodes) do + local index = tonumber(entry[1]) + table.insert(user_table, {userIndex = index, userType = "guest", userName = entry[2]}) + table.insert(credential_table, {userIndex = index, credentialIndex = index, credentialType = "pin"}) + end + if #user_table > 0 or #credential_table > 0 then + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) + device:emit_event(capabilities.lockCredentials.credentials(credential_table, {visibility = {displayed = false}})) + end +end + +local function info_changed(driver, device, event, args) + if deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then + return + end + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + for cap_id, events in pairs(subscribed_events) do + if device:supports_capability_by_id(cap_id) then + for _, e in ipairs(events) do + device:add_subscribed_event(e) + end + end + end + device:subscribe() + if device:supports_capability_by_id(capabilities.lockAliro.ID) then + set_reader_config(device) + end + if device:get_latest_state("main", capabilities.lockAlarm.ID, capabilities.lockAlarm.supportedAlarmValues.NAME) == nil then + device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) + device:emit_event(capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) -- lockJammed is mandatory + end + if device:get_field(lock_utils.LOCK_CODES_COPY_REQUIRED) == true then + migrate_lock_codes(device) + device:set_field(lock_utils.LOCK_CODES_COPY_REQUIRED, false, {persist = true}) + end +end + +local function profiling_data_still_required(device) + for _, field in pairs(profiling_data) do + if device:get_field(field) == nil then + return true -- data still required if a field is nil + end + end + return false +end + +local function match_profile(driver, device, ignore_static_profiling) + if profiling_data_still_required(device) then return end + if version.api >= 15 and version.rpc >= 9 then + match_profile_modular(driver, device) + elseif ignore_static_profiling ~= true then + match_profile_switch(driver, device) + end +end + +local function do_configure(driver, device) + match_profile(driver, device, false) + device.thread:call_with_delay(5, function() + device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) + device:emit_event(capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) -- lockJammed is mandatory + end) +end + +local function driver_switched(driver, device) + match_profile(driver, device, false) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +-- Matter Handler +local function lock_state_handler(driver, device, ib, response) + local LockState = DoorLock.attributes.LockState + local attr = capabilities.lock.lock + local LOCK_STATE = { + [LockState.NOT_FULLY_LOCKED] = attr.not_fully_locked(), + [LockState.LOCKED] = attr.locked(), + [LockState.UNLOCKED] = attr.unlocked(), + [LockState.UNLATCHED] = attr.unlatched() + } + + -- The lock state is usually updated in lock_state_handler and lock_op_event_handler, respectively. + -- In this case, two events occur. To prevent this, when both functions are called, + -- it send the event after 1 second so that no event occurs in the lock_state_handler. + device.thread:call_with_delay(1, function () + if ib.data.value ~= nil and LOCK_STATE[ib.data.value] ~= nil then + device:emit_event(LOCK_STATE[ib.data.value]) + else + device.log.warn(string.format("Received unknown Lock State: %s", ib.data.value)) + end + end) +end + +local function operating_modes_handler(driver, device, ib, response) + local status = capabilities.remoteControlStatus.remoteControlEnabled + local op_type = DoorLock.types.OperatingModeEnum + local opMode_map = { + [op_type.NORMAL] = true, + [op_type.VACATION] = true, + [op_type.PRIVACY] = false, + [op_type.NO_REMOTE_LOCK_UNLOCK] = false, + [op_type.PASSAGE] = false, + } + local result = opMode_map[ib.data.value] + local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) + if result == true then + device:emit_event(status("true", {visibility = {displayed = true}})) + if #unbolt_eps > 0 then + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + else + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + end + elseif result == false then + device:emit_event(status("false", {visibility = {displayed = true}})) + device:emit_event(capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + end +end -local INITIAL_COTA_INDEX = 1 +local function total_users_supported_handler(driver, device, ib, response) + device:emit_event(capabilities.lockUsers.totalUsersSupported(ib.data.value, {visibility = {displayed = false}})) +end + +local function pin_users_supported_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(ib.data.value, {visibility = {displayed = false}})) +end + +local function min_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(ib.data.value, {visibility = {displayed = false}})) +end + +local function max_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(ib.data.value, {visibility = {displayed = false}})) +end ---- If a device needs a cota credential this function attempts to set the credential ---- at the index provided. The set_credential_response_handler handles all failures ---- and retries with the appropriate index when necessary. local function set_cota_credential(device, credential_index) local eps = device:get_endpoints(DoorLock.ID) local cota_cred = device:get_field(lock_utils.COTA_CRED) if cota_cred == nil then -- Shouldn't happen but defensive to try to figure out if we need the cota cred and set it. device:send(DoorLock.attributes.RequirePINforRemoteOperation:read(device, #eps > 0 and eps[1] or 1)) - device.thread:call_with_delay(2, function(t) set_cota_credential(device, credential_index) end) - elseif not cota_cred then + return + elseif cota_cred == false then device.log.debug("Device does not require PIN for remote operation. Not setting COTA credential") return end - if device:get_field(lock_utils.SET_CREDENTIAL) ~= nil then + -- Check Busy State + if lock_utils.is_busy_state_set(device) then device.log.debug("delaying setting COTA credential since a credential is currently being set") device.thread:call_with_delay(2, function(t) set_cota_credential(device, credential_index) @@ -47,557 +506,2397 @@ local function set_cota_credential(device, credential_index) return end + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, "addCota") + device:set_field(lock_utils.CRED_INDEX, credential_index) device:set_field(lock_utils.COTA_CRED_INDEX, credential_index, {persist = true}) - local credential = {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = credential_index} - -- Set the credential to a code - device:set_field(lock_utils.SET_CREDENTIAL, credential_index) + device:set_field(lock_utils.USER_TYPE, "remote") + + -- Send command device.log.info(string.format("Attempting to set COTA credential at index %s", credential_index)) + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credential_index + } device:send(DoorLock.server.commands.SetCredential( device, #eps > 0 and eps[1] or 1, - DoorLock.types.DlDataOperationType.ADD, + DoorLock.types.DataOperationTypeEnum.ADD, credential, device:get_field(lock_utils.COTA_CRED), nil, -- nil user_index creates a new user - DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, - DoorLock.types.DlUserType.REMOTE_ONLY_USER + DoorLock.types.UserStatusEnum.OCCUPIED_ENABLED, + DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER )) end -local function generate_cota_cred_for_device(device) - local len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) or 4 - local cred_data = math.floor(math.random() * (10 ^ len)) - cred_data = string.format("%0" .. tostring(len) .. "d", cred_data) - device:set_field(lock_utils.COTA_CRED, cred_data, {persist = true}) -end +local function generate_cota_cred_for_device(device) + local len = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) or 6 + local cred_data = math.floor(math.random() * (10 ^ len)) + cred_data = string.format("%0" .. tostring(len) .. "d", cred_data) + device:set_field(lock_utils.COTA_CRED, cred_data, {persist = true}) +end + +local function apply_cota_credentials_if_absent(device) + if not device:get_field(lock_utils.COTA_CRED) then + -- Process after all other info blocks have been dispatched to ensure MaxPINCodeLength has been processed + device.thread:call_with_delay(0, function(t) + generate_cota_cred_for_device(device) + -- delay needed to allow test to override the random credential data + device.thread:call_with_delay(0, function(t) + -- Attempt to set cota credential at the lowest index + set_cota_credential(device, INITIAL_CREDENTIAL_INDEX) + end) + end) + end +end + +local function require_remote_pin_handler(driver, device, ib, response) + if ib.data.value then + apply_cota_credentials_if_absent(device) + else + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + end +end + +local function max_week_schedule_of_user_handler(driver, device, ib, response) + device:emit_event(capabilities.lockSchedules.weekDaySchedulesPerUser(ib.data.value, {visibility = {displayed = false}})) +end + +local function max_year_schedule_of_user_handler(driver, device, ib, response) + device:emit_event(capabilities.lockSchedules.yearDaySchedulesPerUser(ib.data.value, {visibility = {displayed = false}})) +end + +local function aliro_reader_verification_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.readerVerificationKey( + utils.bytes_to_hex_string(ib.data.value), {visibility = {displayed = false}} + )) + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "TRUE", {persist = true}) + end +end + +local function aliro_reader_group_id_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.readerGroupIdentifier( + utils.bytes_to_hex_string(ib.data.value), + {visibility = {displayed = false}} + )) + end +end + +local function aliro_group_resolving_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.groupResolvingKey( + utils.bytes_to_hex_string(ib.data.value), + {visibility = {displayed = false}} + )) + end +end + +local function aliro_protocol_versions_handler(driver, device, ib, response) + if ib.data.elements == nil then + return + end + local protocol_versions = {} + for i, element in ipairs(ib.data.elements) do + local ver = string.format("%s.%s", element.value:byte(1), element.value:byte(2)) + table.insert(protocol_versions, ver); + end + device:emit_event(capabilities.lockAliro.expeditedTransactionProtocolVersions(protocol_versions, {visibility = {displayed = false}})) +end + +local function aliro_supported_ble_uwb_protocol_versions_handler(driver, device, ib, response) + if ib.data.elements == nil then + return + end + local protocol_versions = {} + for i, element in ipairs(ib.data.elements) do + local ver = string.format("%s.%s", element.value:byte(1), element.value:byte(2)) + table.insert(protocol_versions, ver); + end + device:emit_event(capabilities.lockAliro.bleUWBProtocolVersions(protocol_versions, {visibility = {displayed = false}})) +end + +local function aliro_ble_advertising_version_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.bleAdvertisingVersion(string.format("%s", ib.data.value), {visibility = {displayed = false}})) + end +end + +local function max_aliro_credential_issuer_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.maxCredentialIssuerKeys(ib.data.value, {visibility = {displayed = false}})) + end +end + +local function max_aliro_endpoint_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.maxEndpointKeys(ib.data.value, {visibility = {displayed = false}})) + end +end + +local function door_lock_feature_map_handler(driver, device, ib, response) + if ib.data.value == nil then return end + local feature_map = lock_utils.get_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, ib.endpoint_id) or nil + if feature_map ~= ib.data.value then + lock_utils.set_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, ib.endpoint_id, ib.data.value, { persist = true }) + match_profile(driver, device, true) + end +end + +local function handle_power_source_attribute_list(driver, device, ib, response) + local latest_battery_support = device:get_field(profiling_data.BATTERY_SUPPORT) + for _, attr in ipairs(ib.data.elements or {}) do + if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_PERCENTAGE, {persist=true}) + break -- BATTERY_PERCENTAGE is highest priority. break early if found + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_LEVEL, {persist=true}) + end + end + -- in the case that 1) no battery has been set, and 2) the returned ib does not include battery attributes, ignore battery + if latest_battery_support == nil and not device:get_field(profiling_data.BATTERY_SUPPORT) then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist=true}) + end + if latest_battery_support == nil or latest_battery_support ~= device:get_field(profiling_data.BATTERY_SUPPORT) then + match_profile(driver, device, false) + end +end + +local function handle_battery_percent_remaining(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +local function handle_battery_charge_level(driver, device, ib, response) + if ib.data.value == PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +-- Capability Handler +local function handle_lock(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.LockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.LockDoor(device, ep)) + end +end + +local function handle_unlock(driver, device, command) + local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + local ep = device:component_to_endpoint(command.component) + + if #unbolt_eps > 0 then + if cota_cred then + device:send( + DoorLock.server.commands.UnboltDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnboltDoor(device, ep)) + end + else + if cota_cred then + device:send( + DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + end + end +end + +local function handle_unlatch(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + end +end + +local function add_user_to_table(device, userIdx, userName, userType) + -- Get latest user table + local user_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockUsers.ID, + capabilities.lockUsers.users.NAME, + {} + )) + + -- Add new entry to table + table.insert(user_table, {userIndex = userIdx, userName = userName, userType = userType}) + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) +end + +local function update_user_in_table(device, userIdx, userName, userType) + -- Get latest user table + local user_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockUsers.ID, + capabilities.lockUsers.users.NAME, + {} + )) + + -- Find user entry + local i = 0 + for index, entry in pairs(user_table) do + if entry.userIndex == userIdx then + i = index + break + end + end + + -- Update user entry + if i ~= 0 then + user_table[i].userType = userType + user_table[i].userName = userName + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) + end +end + +local function delete_user_from_table(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockUsers.users({}, {visibility = {displayed = false}})) + return + end + + -- Get latest user table + local user_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockUsers.ID, + capabilities.lockUsers.users.NAME, + {} + )) + + -- Remove element from user table + for index, entry in pairs(user_table) do + if entry.userIndex == userIdx then + table.remove(user_table, index) + break + end + end + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) +end + +local function has_credentials(device, userIdx) + -- Get latest credential table + local cred_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Find credential + for index, entry in pairs(cred_table) do + if entry.userIndex == userIdx then + return true + end + end + + -- Get latest Aliro credential table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Find Aliro credential + for index, entry in pairs(aliro_table) do + if entry.userIndex == userIdx then + return true + end + end + + return false +end + +local function add_credential_to_table(device, userIdx, credIdx, credType) + -- Get latest credential table + local cred_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Add new entry to table + table.insert(cred_table, {userIndex = userIdx, credentialIndex = credIdx, credentialType = credType}) + device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) +end + +local function delete_credential_from_table(device, credIdx) + -- If Credential Index is ALL_INDEX, remove all entries from the table + if credIdx == ALL_INDEX then + device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) + return ALL_INDEX + end + + -- Get latest credential table + local cred_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Delete an entry from credential table + local userIdx = nil + for index, entry in pairs(cred_table) do + if entry.credentialIndex == credIdx then + table.remove(cred_table, index) + userIdx = entry.userIndex + break + end + end + + device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) + return userIdx +end + +local function delete_credential_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) + return + end + + -- Get latest credential table + local cred_table = device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME + ) or {} + local new_cred_table = {} + + -- Re-create credential table + for index, entry in pairs(cred_table) do + if entry.userIndex ~= userIdx then + table.insert(new_cred_table, entry) + end + end + + device:emit_event(capabilities.lockCredentials.credentials(new_cred_table, {visibility = {displayed = false}})) +end + +local function add_week_schedule_to_table(device, userIdx, scheduleIdx, schedule) + -- Get latest week day schedule table + local week_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.weekDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(week_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + -- Re-create weekDays list + local weekDayList = {} + for _, weekday in ipairs(schedule.weekDays) do + table.insert(weekDayList, weekday) + end + + if i ~= 0 then -- Add schedule for existing user + -- Exclude same scheduleIdx + local new_schedule_table = {} + for index, entry in pairs(week_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + -- Add new entry to table + table.insert( + new_schedule_table, + { + scheduleIndex = scheduleIdx, + weekDays = weekDayList, + startHour = schedule.startHour, + startMinute = schedule.startMinute, + endHour = schedule.endHour, + endMinute = schedule.endMinute + } + ) + -- Update schedule for specific user + week_schedule_table[i].schedules = new_schedule_table + else -- Add schedule for new user + table.insert( + week_schedule_table, + { + userIndex = userIdx, + schedules = {{ + scheduleIndex = scheduleIdx, + weekDays = weekDayList, + startHour = schedule.startHour, + startMinute = schedule.startMinute, + endHour = schedule.endHour, + endMinute = schedule.endMinute + }} + } + ) + end + + device:emit_event(capabilities.lockSchedules.weekDaySchedules(week_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_week_schedule_from_table(device, userIdx, scheduleIdx) + -- Get latest week day schedule table + local week_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.weekDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(week_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + -- When there is no userIndex in the table + if i == 0 then + return + end + + -- Re-create schedule table for the user + local new_schedule_table = {} + for index, entry in pairs(week_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + + -- If user has no schedule, remove user from the table + if #new_schedule_table == 0 then + table.remove(week_schedule_table, i) + else + week_schedule_table[i].schedules = new_schedule_table + end + + device:emit_event(capabilities.lockSchedules.weekDaySchedules(week_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_week_schedule_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockSchedules.weekDaySchedules({}, {visibility = {displayed = false}})) + return + end + + -- Get latest week day schedule table + local week_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.weekDaySchedules.NAME, + {} + )) + + -- Re-create week day schedule table + local new_week_schedule_table = {} + for index, entry in pairs(week_schedule_table) do + if entry.userIndex ~= userIdx then + table.insert(new_week_schedule_table, entry) + end + end + + device:emit_event(capabilities.lockSchedules.weekDaySchedules(new_week_schedule_table, {visibility = {displayed = false}})) +end + +local function add_year_schedule_to_table(device, userIdx, scheduleIdx, sTime, eTime) + -- Get latest year day schedule table + local year_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.yearDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(year_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + if i ~= 0 then -- Add schedule for existing user + -- Exclude same scheduleIdx + local new_schedule_table = {} + for index, entry in pairs(year_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + -- Add new entry to table + table.insert( + new_schedule_table, + { + scheduleIndex = scheduleIdx, + localStartTime = sTime, + localEndTime = eTime + } + ) + -- Update schedule for specific user + year_schedule_table[i].schedules = new_schedule_table + else -- Add schedule for new user + table.insert( + year_schedule_table, + { + userIndex = userIdx, + schedules = {{ + scheduleIndex = scheduleIdx, + localStartTime = sTime, + localEndTime = eTime + }} + } + ) + end + + device:emit_event(capabilities.lockSchedules.yearDaySchedules(year_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_year_schedule_from_table(device, userIdx, scheduleIdx) + -- Get latest year day schedule table + local year_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.yearDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(year_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + -- When there is no userIndex in the table + if i == 0 then + return + end + + -- Re-create year day schedule table for the user + local new_schedule_table = {} + for index, entry in pairs(year_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + + -- If user has no schedule, remove user from the table + if #new_schedule_table == 0 then + table.remove(year_schedule_table, i) + else + year_schedule_table[i].schedules = new_schedule_table + end + + device:emit_event(capabilities.lockSchedules.yearDaySchedules(year_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_year_schedule_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockSchedules.yearDaySchedules({}, {visibility = {displayed = false}})) + return + end + + -- Get latest year day schedule table + local year_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.yearDaySchedules.NAME, + {} + )) + + -- Re-create year day schedule table + local new_year_schedule_table = {} + for index, entry in pairs(year_schedule_table) do + if entry.userIndex ~= userIdx then + table.insert(new_year_schedule_table, entry) + end + end + + device:emit_event(capabilities.lockSchedules.yearDaySchedules(new_year_schedule_table, {visibility = {displayed = false}})) +end + +local function add_aliro_to_table(device, userIdx, keyIdx, keyType, keyId) + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Add new entry to table + table.insert(aliro_table, {userIndex = userIdx, keyIndex = keyIdx, keyType = keyType, keyId = keyId}) + device:emit_event(capabilities.lockAliro.credentials(aliro_table, {visibility = {displayed = false}})) +end + +local function delete_aliro_from_table(device, userIdx, keyType, keyId) + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Delete an entry from aliro table + if keyType == "issuerKey" then + for i, entry in pairs(aliro_table) do + if entry.userIndex == userIdx then + table.remove(aliro_table, i) + break + end + end + else + for i, entry in pairs(aliro_table) do + if entry.userIndex == userIdx and entry.keyId == keyId then + table.remove(aliro_table, i) + break + end + end + end + device:emit_event(capabilities.lockAliro.credentials(aliro_table, {visibility = {displayed = false}})) +end + +local function delete_aliro_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockAliro.credentials({}, {visibility = {displayed = false}})) + return + end + + -- Get latest credential table + local aliro_table = device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME + ) or {} + local new_aliro_table = {} + + -- Re-create credential table + for index, entry in pairs(aliro_table) do + if entry.userIndex ~= userIdx then + table.insert(new_aliro_table, entry) + end + end + + device:emit_event(capabilities.lockAliro.credentials(new_aliro_table, {visibility = {displayed = false}})) +end + +local function handle_add_user(driver, device, command) + -- Get parameters + local cmdName = "addUser" + local userName = command.args.userName + local userType = command.args.userType + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) + device:set_field(lock_utils.USER_NAME, userName, {persist = true}) + device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) + + -- Get available user index + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.GetUser(device, ep, INITIAL_CREDENTIAL_INDEX)) +end + +local function handle_update_user(driver, device, command) + -- Get parameters + local cmdName = "updateUser" + local userIdx = command.args.userIndex + local userName = command.args.userName + local userNameMatter = string.sub(userName, 1, MAX_USER_NAME_LENGTH) + local userType = command.args.userType + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.USER_NAME, userName, {persist = true}) + device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetUser( + device, ep, + DoorLock.types.DataOperationTypeEnum.MODIFY, + userIdx, + userNameMatter, + nil, -- Unique ID + nil, -- User Status + userTypeMatter, + nil -- Credential Rule + ) + ) +end + +local function get_user_response_handler(driver, device, ib, response) + local elements = ib.info_block.data.elements + local userIdx = elements.user_index.value + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + if status ~= "success" then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = status + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + local ep = find_default_endpoint(device, DoorLock.ID) + local status = elements.user_status.value + local maxUser = device:get_latest_state( + "main", + capabilities.lockUsers.ID, + capabilities.lockUsers.totalUsersSupported.NAME + ) or 10 + -- Found available user index + if status == nil or status == DoorLock.types.UserStatusEnum.AVAILABLE then + local userName = device:get_field(lock_utils.USER_NAME) + local userNameMatter = string.sub(userName, 1, MAX_USER_NAME_LENGTH) + local userType = device:get_field(lock_utils.USER_TYPE) + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + end + + -- Save values to field + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + device:send( + DoorLock.server.commands.SetUser( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, + userIdx, + userNameMatter, + nil, -- Unique ID + nil, -- User Status + userTypeMatter, + nil -- Credential Rule + ) + ) + elseif userIdx >= maxUser then -- There's no available user index + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = "resourceExhausted" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + else -- Check next user index + device:send(DoorLock.server.commands.GetUser(device, ep, userIdx + 1)) + end +end + +local function set_user_response_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userName = device:get_field(lock_utils.USER_NAME) + local userType = device:get_field(lock_utils.USER_TYPE) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.OCCUPIED then + status = "occupied" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + + -- Update User in table + if status == "success" then + if cmdName == "addUser" then + add_user_to_table(device, userIdx, userName, userType) + elseif cmdName == "updateUser" then + update_user_in_table(device, userIdx, userName, userType) + end + else + device.log.warn(string.format("Failed to set user: %s", status)) + end + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = status + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) +end + +local function handle_delete_user(driver, device, command) + -- Get parameters + local cmdName = "deleteUser" + local userIdx = command.args.userIndex + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearUser(device, ep, userIdx)) +end + +local function handle_delete_all_users(driver, device, command) + -- Get parameters + local cmdName = "deleteAllUsers" + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, ALL_INDEX, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearUser(device, ep, ALL_INDEX)) +end + +local function clear_user_response_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + + -- Delete User and Credential from table + if status == "success" then + delete_user_from_table(device, userIdx) + delete_credential_from_table_as_user(device, userIdx) + delete_aliro_from_table_as_user(device, userIdx) + delete_week_schedule_from_table_as_user(device, userIdx) + delete_year_schedule_from_table_as_user(device, userIdx) + else + device.log.warn(string.format("Failed to clear user: %s", status)) + end + + -- In the "defaultSchedule" cmd failure path, when a guest user's credentials are set but the scheduling + -- fails during default setup, those credentials should be removed, so we wait to log lock credentials until here. + if cmdName == "defaultSchedule" then + -- note: if clear user succeeds, we'd log credential settings as a "failure" since it's effectively a no-op. + -- If clear user fails, log "success" since the credentials would still be present. + local lock_credential_status = status == "success" and "failure" or "success" + local command_result_info = { + commandName = "addCredential", + userIndex = userIdx, + statusCode = lock_credential_status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + else + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = status + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + end + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) +end + +local function handle_add_credential(driver, device, command) + -- Get parameters + local cmdName = "addCredential" + local userIdx = command.args.userIndex + local userType = command.args.userType + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + end + if userIdx == 0 then + userIdx = nil + else + userTypeMatter = nil + end + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = INITIAL_CREDENTIAL_INDEX + } + local credData = command.args.credentialData + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) + device:set_field(lock_utils.CRED_DATA, credData, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + credData, -- Credential Data + userIdx, -- User Index + nil, -- User Status + userTypeMatter -- User Type + ) + ) +end + +local function handle_update_credential(driver, device, command) + -- Get parameters + local cmdName = "updateCredential" + local userIdx = command.args.userIndex + local credIdx = command.args.credentialIndex + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credIdx + } + local credData = command.args.credentialData + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) + device:set_field(lock_utils.USER_TYPE, nil, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.MODIFY, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + credData, -- Credential Data + userIdx, -- User Index + nil, -- User Status + nil -- User Type + ) + ) +end + +local function set_pin_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.error("Failed to set credential for device") + return + end + + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local credData = device:get_field(lock_utils.CRED_DATA) + if cmdName == "addCota" then + credData = device:get_field(lock_utils.COTA_CRED) + end + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = device:get_field(lock_utils.USER_TYPE) + local credIdx = device:get_field(lock_utils.CRED_INDEX) + local elements = ib.info_block.data.elements + local status = RESPONSE_STATUS_MAP[elements.status.value] + + if status == "success" then + -- Don't save user and credential for COTA + if cmdName == "addCota" then + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- If user is added also, update User table + if userIdx == nil then + add_user_to_table(device, elements.user_index.value, nil, userType) + end + + -- Update Credential table + userIdx = elements.user_index.value + if cmdName == "addCredential" then + add_credential_to_table(device, userIdx, credIdx, "pin") + end + + -- If User Type is Guest and device support schedule, add default schedule + local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES}) + local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES}) + if userType == "guest" and (#week_schedule_eps > 0 or #year_schedule_eps > 0) and cmdName ~= "updateCredential" then + local cmdName = "defaultSchedule" + local scheduleIdx = 1 + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + + local ep = device:component_to_endpoint("main") + device:send( + DoorLock.server.commands.SetYearDaySchedule( + device, ep, + scheduleIdx, + userIdx, + lock_utils.MIN_EPOCH_S, + lock_utils.MAX_EPOCH_S + ) + ) + else + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + credentialIndex = credIdx, + statusCode = status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end + return + end + + -- In the case DlStatus returns Occupied, this means the current credential index is in use, + -- so we must try the next one. If there is not a next index (i.e. it is nil), + -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. + device.log.warn(string.format("Failed to set credential: %s", status)) + if status == "occupied" and elements.next_credential_index.value == nil then + local command_result_info = { + commandName = cmdName, + statusCode = "resourceExhausted" -- No more available credential index + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + elseif status == "occupied" then + -- Get parameters + local credIdx = elements.next_credential_index.value + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credIdx, + } + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = device:get_field(lock_utils.USER_TYPE) + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userIdx ~= nil then + userTypeMatter = nil + elseif userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + elseif userType == "remote" then + userTypeMatter = DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER + end + + device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, DoorLock.ID) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + credData, -- Credential Data + userIdx, -- User Index + nil, -- User Status + userTypeMatter -- User Type + ) + ) + elseif status == "duplicate" and cmdName == "addCota" then + generate_cota_cred_for_device(device) + device.thread:call_with_delay(0, function(t) set_cota_credential(device, credIdx) end) + else + local command_result_info = { + commandName = cmdName, + statusCode = status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end +end + +local function set_issuer_key_response_handler(driver, device, ib, response) + local cmdName = "setIssuerKey" + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local issuerKeyIndex = device:get_field(lock_utils.ISSUER_KEY_INDEX) + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + local elements = ib.info_block.data.elements + local status = RESPONSE_STATUS_MAP[elements.status.value] + + if status == "success" then + -- Delete field data + device:set_field(lock_utils.ISSUER_KEY, nil, {persist = true}) + + -- If user is added also, update User table + if userIdx == nil then + userIdx = elements.user_index.value + add_user_to_table(device, userIdx, nil, "adminMember") + end + + -- Update Aliro table + add_aliro_to_table(device, userIdx, issuerKeyIndex, "issuerKey", nil) + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- In the case DlStatus returns Occupied, this means the current credential index is in use, + -- so we must try the next one. If there is not a next index (i.e. it is nil), + -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. + device.log.warn(string.format("Failed to set credential: %s", status)) + if status == "occupied" and elements.next_credential_index.value == nil then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = "resourceExhausted" -- No more available credential index + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + elseif status == "occupied" then + -- Get parameters + if userIdx ~= nil then + userType = nil + end + local credIdx = elements.next_credential_index.value + local credType = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY + local credData = device:get_field(lock_utils.ISSUER_KEY) + local credential = { + credential_type = credType, + credential_index = credIdx + } + + -- Save values to field + device:set_field(lock_utils.ISSUER_KEY_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, DoorLock.ID) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, + credential, -- Credential + lock_utils.hex_string_to_octet_string(credData), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type + ) + ) + else + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end +end + +local function set_endpoint_key_response_handler(driver, device, ib, response) + local cmdName = "setEndpointKey" + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local keyId = device:get_field(lock_utils.DEVICE_KEY_ID) + local keyType = device:get_field(lock_utils.ENDPOINT_KEY_TYPE) + local endpointKeyIndex = device:get_field(lock_utils.ENDPOINT_KEY_INDEX) + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + local elements = ib.info_block.data.elements + local status = RESPONSE_STATUS_MAP[elements.status.value] + + if status == "success" then + -- Delete field data + device:set_field(lock_utils.ENDPOINT_KEY, nil, {persist = true}) + + -- If user is added also, update User table + if userIdx == nil then + userIdx = elements.user_index.value + add_user_to_table(device, userIdx, nil, "adminMember") + end + + -- Update Aliro table + add_aliro_to_table(device, userIdx, endpointKeyIndex, keyType, keyId) + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- In the case DlStatus returns Occupied, this means the current credential index is in use, + -- so we must try the next one. If there is not a next index (i.e. it is nil), + -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. + device.log.warn(string.format("Failed to set credential: %s", status)) + + if status == "occupied" and elements.next_credential_index.value == nil then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = "resourceExhausted" -- No more available credential index + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + elseif status == "occupied" then + -- Get parameters + if userIdx ~= nil then + userType = nil + end + local credIdx = elements.next_credential_index.value + local credType = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType] + local credData = device:get_field(lock_utils.ENDPOINT_KEY) + local credential = { + credential_type = credType, + credential_index = credIdx + } + + -- Save values to field + device:set_field(lock_utils.ENDPOINT_KEY_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, DoorLock.ID) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, + credential, -- Credential + lock_utils.hex_string_to_octet_string(credData), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type + ) + ) + else + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end +end + +local function set_credential_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.error("Failed to set credential for device") + return + end + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + if cmdName == "addCredential" or cmdName == "updateCredential" or cmdName == "addCota" then + set_pin_response_handler(driver, device, ib, response) + elseif cmdName == "setIssuerKey" then + set_issuer_key_response_handler(driver, device, ib, response) + elseif cmdName == "setEndpointKey" then + set_endpoint_key_response_handler(driver, device, ib, response) + end +end + +local function handle_delete_credential(driver, device, command) + -- Get parameters + local cmdName = "deleteCredential" + local credIdx = command.args.credentialIndex + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credIdx, + } + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) +end + +local function handle_delete_all_credentials(driver, device, command) + -- Get parameters + local cmdName = "deleteAllCredentials" + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = ALL_INDEX, + } + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, ALL_INDEX, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) +end + +local function clear_credential_response_handler(driver, device, ib, response) + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + if cmdName ~= "deleteCredential" and cmdName ~= "clearEndpointKey" and + cmdName ~= "clearIssuerKey" and cmdName ~= "deleteAllCredentials" then + return + end + local status = RESPONSE_STATUS_MAP[ib.status] or "success" + local command_result_info = { commandName = cmdName, statusCode = status } -- default command result + local userIdx = device:get_field(lock_utils.USER_INDEX) + local all_user_credentials_removed = false + + if (cmdName == "deleteCredential" or cmdName == "deleteAllCredentials") and status == "success" then + -- Get result from data saved in relevant, associated fields + local credIdx = device:get_field(lock_utils.CRED_INDEX) + + -- find userIdx associated with credIdx, don't use lock utils field in this case + userIdx = delete_credential_from_table(device, credIdx) + if userIdx ~= nil then + all_user_credentials_removed = not has_credentials(device, userIdx) + end + + -- set unique command result fields + command_result_info.userIndex = userIdx + command_result_info.credentialIndex = credIdx + elseif cmdName == "clearIssuerKey" and status == "success" then + -- Get result from data saved in relevant, associated fields + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + + delete_aliro_from_table(device, userIdx, "issuerKey", nil) + all_user_credentials_removed = not has_credentials(device, userIdx) + + -- set unique command result fields + command_result_info.userIndex = userIdx + command_result_info.requestId = reqId + elseif cmdName == "clearEndpointKey" and status == "success" then + -- Get result from data saved in relevant, associated fields + local deviceKeyId = device:get_field(lock_utils.DEVICE_KEY_ID) + local keyType = device:get_field(lock_utils.ENDPOINT_KEY_TYPE) + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + + delete_aliro_from_table(device, userIdx, keyType, deviceKeyId) + all_user_credentials_removed = not has_credentials(device, userIdx) -local function lock_state_handler(driver, device, ib, response) - local LockState = DoorLock.attributes.LockState - local attr = capabilities.lock.lock - local LOCK_STATE = { - [LockState.NOT_FULLY_LOCKED] = attr.unknown(), - [LockState.LOCKED] = attr.locked(), - [LockState.UNLOCKED] = attr.unlocked(), - } + -- set unique command result fields + command_result_info.userIndex = userIdx + command_result_info.keyId = deviceKeyId + command_result_info.requestId = reqId + end - if ib.data.value ~= nil then - device:emit_event(LOCK_STATE[ib.data.value]) + -- user data if credentials were removed + if all_user_credentials_removed then + delete_user_from_table(device, userIdx) + delete_week_schedule_from_table_as_user(device, userIdx) + delete_year_schedule_from_table_as_user(device, userIdx) + end + + -- Update commandResult + if cmdName == "deleteCredential" or cmdName == "deleteAllCredentials" then + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) else - device:emit_event(LOCK_STATE[LockState.NOT_FULLY_LOCKED]) + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) end + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end -local function handle_battery_percent_remaining(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) +local function handle_set_week_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "setWeekDaySchedule" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + local schedule = command.args.schedule + local wDays = {} + local scheduleBit = 0 + for _, weekDay in ipairs(schedule.weekDays) do + scheduleBit = scheduleBit + WEEK_DAY_MAP[weekDay] + table.insert(wDays, weekDay) end -end + local startHour = schedule.startHour + local startMinute = schedule.startMinute + local endHour = schedule.endHour + local endMinute = schedule.endMinute -local function max_pin_code_len_handler(driver, device, ib, response) - device:emit_event(capabilities.lockCodes.maxCodeLength(ib.data.value, {visibility = {displayed = false}})) -end + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end -local function min_pin_code_len_handler(driver, device, ib, response) - device:emit_event(capabilities.lockCodes.minCodeLength(ib.data.value, {visibility = {displayed = false}})) -end + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_WEEK_DAYS, wDays, {persist = true}) + device:set_field(lock_utils.SCHEDULE_START_HOUR, startHour, {persist = true}) + device:set_field(lock_utils.SCHEDULE_START_MINUTE, startMinute, {persist = true}) + device:set_field(lock_utils.SCHEDULE_END_HOUR, endHour, {persist = true}) + device:set_field(lock_utils.SCHEDULE_END_MINUTE, endMinute, {persist = true}) -local function num_pin_users_handler(driver, device, ib, response) - device:set_field(lock_utils.TOTAL_PIN_USERS, ib.data.value) - device:emit_event(capabilities.lockCodes.maxCodes(ib.data.value, {visibility = {displayed = false}})) + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetWeekDaySchedule( + device, ep, + scheduleIdx, -- Week Day Schedule Index + userIdx, -- User Index + scheduleBit, -- Days Mask + startHour, -- Start Hour + startMinute, -- Start Minute + endHour, -- End Hour + endMinute -- End Minute + ) + ) end -local function require_remote_pin_handler(driver, device, ib, response) - if ib.data.value then - --Process after all other info blocks have been dispatched to ensure MaxPINCodeLength has been processed - device.thread:call_with_delay(0, function(t) - generate_cota_cred_for_device(device) - -- delay needed to allow test to override the random credential data - device.thread:call_with_delay(0, function(t) - -- Attempt to set cota credential at the lowest index - set_cota_credential(device, INITIAL_COTA_INDEX) - end) - end) +local function set_week_day_schedule_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) + local days = device:get_field(lock_utils.SCHEDULE_WEEK_DAYS) + local sHour = device:get_field(lock_utils.SCHEDULE_START_HOUR) + local sMinute = device:get_field(lock_utils.SCHEDULE_START_MINUTE) + local eHour = device:get_field(lock_utils.SCHEDULE_END_HOUR) + local eMinute = device:get_field(lock_utils.SCHEDULE_END_MINUTE) + local schedule = { + weekDays = days, + startHour = sHour, + startMinute = sMinute, + endHour = eHour, + endMinute = eMinute + } + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + + -- Add Week Day Schedule to table + if status == "success" then + add_week_schedule_to_table(device, userIdx, scheduleIdx, schedule) else - device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + device.log.warn(string.format("Failed to set week day schedule: %s", status)) end + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + scheduleIndex = scheduleIdx, + statusCode = status + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end -local function clear_credential_response_handler(driver, device, ib, response) - local deleted_code_slot = device:get_field(lock_utils.DELETING_CODE) - if deleted_code_slot == nil and ib.status == im.InteractionResponse.Status.SUCCESS then - device.log.debug("Cleared space in lock credential db for COTA credential") +local function handle_clear_week_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "clearWeekDaySchedules" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) return end - if ib.status == im.InteractionResponse.Status.SUCCESS then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(deleted_code_slot))) - --make sure cota credential exists if the user deletes it or if space was created for the COTA cred - if deleted_code_slot == device:get_field(lock_utils.COTA_CRED_INDEX) or - device:get_field(lock_utils.NONFUNCTIONAL) then - set_cota_credential(device, device:get_field(lock_utils.COTA_CRED_INDEX) or INITIAL_COTA_INDEX) - end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearWeekDaySchedule(device, ep, scheduleIdx, userIdx)) +end + +local function clear_week_day_schedule_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + + -- Delete Week Day Schedule to table + if status == "success" then + delete_week_schedule_from_table(device, userIdx, scheduleIdx) else - device.log.error(string.format("Failed to delete code slot %s", deleted_code_slot)) + device.log.warn(string.format("Failed to clear week day schedule: %s", status)) end - device:set_field(lock_utils.DELETING_CODE, nil) + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + scheduleIndex = scheduleIdx, + statusCode = status + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end -local function set_credential_response_handler(driver, device, ib, response) - if ib.status ~= im.InteractionResponse.Status.SUCCESS then - device.log.error("Failed to set code for device") +local function handle_set_year_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "setYearDaySchedule" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + local localStartTime = command.args.schedule.localStartTime + local localEndTime = command.args.schedule.localEndTime + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) return end - local elements = ib.info_block.data.elements - local credential_index = device:get_field(lock_utils.SET_CREDENTIAL) - device:set_field(lock_utils.SET_CREDENTIAL, nil) - if credential_index == nil then - device.log.error("Received unexpected SetCredentialResponse") - return - end - local code_slot = tostring(credential_index) - local status = elements.status.value - if status == DoorLock.types.DlStatus.SUCCESS then - local event = capabilities.lockCodes.codeChanged("", {state_change = true}) - local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) - local code_name = (credential_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or - lock_utils.get_code_name(device, code_slot) - event.data = {codeName = code_name} - event.value = lock_utils.get_change_type(device, tostring(code_slot)) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - if credential_index == cota_cred_index then - device:emit_event( - capabilities.lockCodes.codeChanged( - code_slot .. " renamed", {state_change = true} - ) - ) - end - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - if device:get_field(lock_utils.NONFUNCTIONAL) and cota_cred_index == credential_index then - device.log.info("Successfully set COTA credential after being non-functional") - device:set_field(lock_utils.NONFUNCTIONAL, false, {persist = true}) - device:try_update_metadata({profile = "base-lock", provisioning_state = "PROVISIONED"}) - end - elseif device:get_field(lock_utils.COTA_CRED) and credential_index == device:get_field(lock_utils.COTA_CRED_INDEX) then - -- Handle failure to set a COTA credential - if status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value ~= nil then - --This credential index is unavailable, but there is another available - set_cota_credential(device, elements.next_credential_index.value) - elseif status == DoorLock.types.DlStatus.OCCUPIED and - elements.next_credential_index.value == nil and - credential_index == INITIAL_COTA_INDEX then - --There are no credential indices available on the device - device.log.error("Device requires COTA credential, but has no credential indexes available!") - device.log.error("Lock and Unlock commands will no longer work!!") - device:try_update_metadata({profile = "nonfunctional-lock", provisioning_state = "NONFUNCTIONAL"}) - device:set_field(lock_utils.NONFUNCTIONAL, true, {persist = true}) - elseif status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value == nil then - --There are no credential indices available, but we must ensure we search all indices. - set_cota_credential(device, INITIAL_COTA_INDEX) - elseif status == DoorLock.types.DlStatus.DUPLICATE then - --The credential we randomly generated already exists - generate_cota_cred_for_device(device) - --delay 0 needed for unit test verification of random value - device.thread:call_with_delay(0, function(t) set_cota_credential(device, credential_index) end) - elseif status == DoorLock.types.DlStatus.INVALID_FIELD then - device.log.error("Invalid SetCredential command sent to set a COTA credential. This is a bug.") - elseif elements.next_credential_index.value ~= nil then - device.log.warn(string.format( - "Received non-success SetCredentialResponse status (%s), but there is a next credential index available", elements.status - )) - set_cota_credential(device, elements.next_credential_index.value) - end - else - device.log.error( - string.format( - "Failed to set user code for device, SetCredential status received: %s", elements.status - ) + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_LOCAL_START_TIME, localStartTime, {persist = true}) + device:set_field(lock_utils.SCHEDULE_LOCAL_END_TIME, localEndTime, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetYearDaySchedule( + device, ep, + scheduleIdx, + userIdx, + lock_utils.iso8601_to_epoch(localStartTime), + lock_utils.iso8601_to_epoch(localEndTime) ) - end + ) end -local function get_credential_status_response_handler(driver, device, ib, response) - if ib.status ~= im.InteractionResponse.Status.SUCCESS then - device.log.warn("Not taking action on GetCredentialStatusResponse because failed status") +local function set_year_day_schedule_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) + local localStartTime = device:get_field(lock_utils.SCHEDULE_LOCAL_START_TIME) + local localEndTime = device:get_field(lock_utils.SCHEDULE_LOCAL_END_TIME) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" end - local cred_index = device:get_field(lock_utils.CHECKING_CREDENTIAL) - if cred_index == nil then - device.log.warn("Received unexpected CredentialStatusResponse") + + if cmdName == "defaultSchedule" then + local cmdName = "addCredential" + local credIdx = device:get_field(lock_utils.CRED_INDEX) + + if status == "success" then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + credentialIndex = credIdx, + statusCode = status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + else + local ep = find_default_endpoint(device, clusters.DoorLock.ID) + device:send(DoorLock.server.commands.ClearUser(device, ep, userIdx)) + end return end - local elements = ib.info_block.data.elements - local credential_exists = elements.credential_exists.value - local next_credential_index = elements.next_credential_index and elements.next_credential_index.value or nil - - local event = capabilities.lockCodes.codeChanged("", {state_change = true}) - local code_slot = tostring(cred_index) - local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) - local code_name = (cred_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) - event.data = {codeName = code_name} - if credential_exists then - -- Code slot is occupied - event.value = lock_utils.get_change_type(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - else - -- Code slot is unoccupied - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - -- Code has been deleted - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - if cred_index == cota_cred_index then --make sure cota credential exists if it was deleted - set_cota_credential(device, INITIAL_COTA_INDEX) - end - else - -- Code is unset - event.value = code_slot .. " unset" - device:emit_event(event) + + if status == "success" then + if cmdName == "setYearDaySchedule" then + add_year_schedule_to_table(device, userIdx, scheduleIdx, localStartTime, localEndTime) + elseif cmdName == "clearYearDaySchedules" then + delete_year_schedule_from_table(device, userIdx, scheduleIdx) end + else + device.log.warn(string.format("Failed to set/clear year day schedule: %s", status)) end - device:set_field(lock_utils.CHECKING_CREDENTIAL, nil) - local is_scanning = device:get_latest_state( - "main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME - ) == "Scanning" - if not is_scanning then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + scheduleIndex = scheduleIdx, + statusCode = status + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) +end + +local function handle_clear_year_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "clearYearDaySchedules" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) return end - if (next_credential_index == nil) then - device:emit_event( - capabilities.lockCodes.scanCodes( - "Complete", {visibility = {displayed = false}} - ) - ) - local lock_codes = lock_utils.get_lock_codes(device) - lock_utils.lock_codes_event(device, lock_codes) - elseif next_credential_index ~= nil then - device:set_field(lock_utils.CHECKING_CREDENTIAL, next_credential_index) - device:send( - DoorLock.server.commands.GetCredentialStatus( - device, - ib.info_block.endpoint_id, - {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} - ) + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + -- In SmartThings, Schedule Restrict User is basically allowed to access always. + -- So, if user delete the year day schedule, enter an infinitely long schedule. + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetYearDaySchedule( + device, ep, + scheduleIdx, + userIdx, + lock_utils.MIN_EPOCH_S, + lock_utils.MAX_EPOCH_S ) - end + ) end local function alarm_event_handler(driver, device, ib, response) local DlAlarmCode = DoorLock.types.DlAlarmCode local alarm_code = ib.data.elements.alarm_code - if alarm_code.value == DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED or alarm_code.value - == DlAlarmCode.WRONG_CODE_ENTRY_LIMIT or alarm_code.value == DlAlarmCode.FORCED_USER - or alarm_code.value == DlAlarmCode.DOOR_FORCED_OPEN then - device:emit_event(capabilities.tamperAlert.tamper.detected()) + if alarm_code.value == DlAlarmCode.LOCK_JAMMED then + device:emit_event(capabilities.lockAlarm.alarm.unableToLockTheDoor({state_change = true})) + elseif alarm_code.value == DlAlarmCode.LOCK_FACTORY_RESET then + device:emit_event(capabilities.lockAlarm.alarm.lockFactoryReset({state_change = true})) + elseif alarm_code.value == DlAlarmCode.WRONG_CODE_ENTRY_LIMIT then + device:emit_event(capabilities.lockAlarm.alarm.attemptsExceeded({state_change = true})) + elseif alarm_code.value == DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED then + device:emit_event(capabilities.lockAlarm.alarm.damaged({state_change = true})) + elseif alarm_code.value == DlAlarmCode.DOOR_FORCED_OPEN then + device:emit_event(capabilities.lockAlarm.alarm.forcedOpeningAttempt({state_change = true})) end end local function lock_op_event_handler(driver, device, ib, response) - local tamper_detected = device:get_latest_state( - device:endpoint_to_component(ib.endopint_id), - capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME - ) - if nil == tamper_detected or tamper_detected == capabilities.tamperAlert.tamper.detected.NAME then - device:emit_event(capabilities.tamperAlert.tamper.clear()) - end -end - -local function lock_user_change_event_handler(driver, device, ib, response) - local event = capabilities.lockCodes.codeChanged("", {state_change = true}) - local elements = ib.data.elements - local data_type_changed = elements.lock_data_type.value - local operation_type = elements.data_operation_type.value - local user_index = elements.user_index.value - local data_index = elements.data_index and elements.data_index.value - local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) - - if data_type_changed == DoorLock.types.DlLockDataType.PIN then -- pin added or removed - local code_slot = data_index and tostring(data_index) or nil - if (operation_type == DoorLock.types.DlDataOperationType.ADD or operation_type - == DoorLock.types.DlDataOperationType.MODIFY) and code_slot ~= nil then - local change_type = lock_utils.get_change_type(device, code_slot) - event.value = change_type - local code_name = (data_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) - event.data = {codeName = code_name} - device:emit_event(event) - if string.match(change_type, "%d+ set") ~= nil then - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = code_name - lock_utils.lock_codes_event(device, lock_codes) - end - elseif operation_type == DoorLock.types.DlDataOperationType.CLEAR and code_slot ~= nil then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(code_slot))) - --make sure cota credential is created if the user deletes it or a space is made for it - if data_index == cota_cred_index or device:get_field(lock_utils.NONFUNCTIONAL) then - set_cota_credential(device, cota_cred_index or INITIAL_COTA_INDEX) - end - else -- invalid event because no credential index - device.log.error( - "Received unhandled LockUserChangeEvent because it didn't affect a PIN credential" - ) - end - elseif data_type_changed == DoorLock.types.DlLockDataType.USER_INDEX and operation_type - == DoorLock.types.DlDataOperationType.CLEAR then - if user_index == 0xFFFE then - device.log.warn("All users were cleared by another fabric") -- we never do this - for cs, _ in pairs(lock_utils.get_lock_codes(device)) do - lock_utils.code_deleted(device, cs) - end - lock_utils.lock_codes_event(device, {}) - if device:get_field(lock_utils.COTA_CRED) ~= nil then set_cota_credential(device, INITIAL_COTA_INDEX) end - else - device.log.info("Not handling LockUserChange event") - end - -- Note when a Lock User is deleted, the credentials associated with that user are also deleted. - -- Change events are created for each credential as well as the user. + local opType = ib.data.elements.lock_operation_type + local opSource = ib.data.elements.operation_source + local userIdx = ib.data.elements.user_index + -- TODO: This handler can check fabric index and exclude other fabric events + + if opType == nil or opSource == nil then + return + end + + local Type = DoorLock.types.LockOperationTypeEnum + local Lock = capabilities.lock.lock + if opType.value == Type.LOCK then + opType = Lock.locked + elseif opType.value == Type.UNLOCK then + opType = Lock.unlocked + elseif opType.value == Type.UNLATCH then + opType = Lock.unlatched else - device.log.info( - string.format( - "Not handling LockUserChange event because the data type (%s) doesn't affect lock codes", - elements.lock_data_type - ) - ) + return end -end -local function handle_refresh(driver, device, command) - -- Note: no endpoint specified indicates a wildcard endpoint - local req = DoorLock.attributes.LockState:read(device) - req:merge(PowerSource.attributes.BatPercentRemaining:read(device)) - device:send(req) + local Source = DoorLock.types.OperationSourceEnum + if opSource.value == Source.UNSPECIFIED then + opSource = nil + elseif opSource.value == Source.MANUAL then + opSource = "manual" + elseif opSource.value == Source.PROPRIETARY_REMOTE then + opSource = "proprietaryRemote" + elseif opSource.value == Source.KEYPAD then + opSource = "keypad" + elseif opSource.value == Source.AUTO then + opSource = "auto" + elseif opSource.value == Source.BUTTON then + opSource = "button" + elseif opSource.value == Source.SCHEDULE then + opSource = nil + elseif opSource.value == Source.REMOTE then + opSource = "command" + elseif opSource.value == Source.RFID then + opSource = "rfid" + elseif opSource.value == Source.BIOMETRIC then + opSource = nil -- It will be updated R2 + elseif opSource.value == Source.ALIRO then + opSource = "digitalKey" + else + opSource = nil + end + + if userIdx ~= nil then + userIdx = userIdx.value + end + + local data_obj = {method = opSource, userIndex = userIdx} + device:emit_event(opType({data = data_obj, state_change = true})) end -local function handle_lock(driver, device, command) - local ep = device:component_to_endpoint(command.component) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - if cota_cred then - device:send( - DoorLock.server.commands.LockDoor(device, ep, cota_cred) - ) +local function handle_refresh(driver, device, command) + if device:get_field(lock_utils.LOCK_CODES_COPY_REQUIRED) == true then + -- ensure battery state remains as it was before the profile migration + if device:supports_capability(capabilities.battery) then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_PERCENTAGE, {persist = true}) + elseif device:supports_capability(capabilities.batteryLevel) then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_LEVEL, {persist = true}) + else + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist = true}) + end + match_profile(driver, device, true) else - device:send(DoorLock.server.commands.LockDoor(device, ep)) + device:send(DoorLock.attributes.LockState:read(device)) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end end -local function handle_unlock(driver, device, command) +local function handle_set_reader_config(driver, device, command) + local cmdName = "setReaderConfig" + local signingKey = command.args.signingKey + local verificationKey = command.args.verificationKey + local groupId = command.args.groupId + local groupResolvingKey = nil + local aliro_ble_uwb_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.ALIROBLEUWB}) + if #aliro_ble_uwb_eps > 0 then + groupResolvingKey = command.args.groupResolvingKey + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + local reader_config_updated = device:get_field(lock_utils.ALIRO_READER_CONFIG_UPDATED) or nil + if reader_config_updated == "IN_PROGRESS" then + return + elseif reader_config_updated == "TRUE" then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + statusCode = "success" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.VERIFICATION_KEY, verificationKey, {persist = true}) + device:set_field(lock_utils.GROUP_ID, groupId, {persist = true}) + device:set_field(lock_utils.GROUP_RESOLVING_KEY, groupResolvingKey, {persist = true}) + + -- Send command local ep = device:component_to_endpoint(command.component) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - if cota_cred then - device:send( - DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + device:send( + DoorLock.server.commands.SetAliroReaderConfig( + device, ep, + lock_utils.hex_string_to_octet_string(signingKey), + lock_utils.hex_string_to_octet_string(verificationKey), + lock_utils.hex_string_to_octet_string(groupId), + lock_utils.hex_string_to_octet_string(groupResolvingKey) ) + ) + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "IN_PROGRESS", {persist = true}) +end + +local function set_aliro_reader_config_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local status = "failure" + if ib.status == DoorLock.types.DlStatus.SUCCESS then + status = "success" + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "TRUE", {persist = true}) else - device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + if ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, nil, {persist = true}) end -end -local function handle_delete_code(driver, device, command) - local endpoint = device:component_to_endpoint(command.component) - device:set_field(lock_utils.DELETING_CODE, command.args.codeSlot) - device:send(DoorLock.server.commands.ClearCredential( - device, - endpoint, - {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = command.args.codeSlot} + -- Update commandResult + local command_result_info = { + commandName = cmdName, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) +end + +local function handle_set_card_id(driver, device, command) + if command.args.cardId ~= nil then + device:emit_event(capabilities.lockAliro.cardId(command.args.cardId, {visibility = {displayed = false}})) + end end -local function handle_reload_all_codes(driver, device, command) - if (device:get_field(lock_utils.CHECKING_CREDENTIAL) == nil) then - device:set_field(lock_utils.CHECKING_CREDENTIAL, 1) +local function handle_set_issuer_key(driver, device, command) + -- Get parameters + local cmdName = "setIssuerKey" + local userIdx = command.args.userIndex + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local issuerKey = command.args.issuerKey + local reqId = command.args.requestId + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = INITIAL_CREDENTIAL_INDEX + } + + -- Adjustment + if userIdx == 0 then + userIdx = nil else - device.log.info(string.format("Delaying scanning since currently checking credential %d", device:get_field(lock_utils.CHECKING_CREDENTIAL))) - device.thread:call_with_delay(2, function(t) handle_reload_all_codes(driver, device, command) end) + userType = nil + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) return end - device:emit_event(capabilities.lockCodes.scanCodes("Scanning")) + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.ISSUER_KEY, issuerKey, {persist = true}) + device:set_field(lock_utils.ISSUER_KEY_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) device:send( - clusters.DoorLock.server.commands.GetCredentialStatus( - device, device:component_to_endpoint(command.component), - {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + lock_utils.hex_string_to_octet_string(issuerKey), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type ) ) end -local function handle_request_code(driver, device, command) - local endpoint_id = device:component_to_endpoint(command.component) - device:set_field(lock_utils.CHECKING_CREDENTIAL, command.args.codeSlot) - local credential = { - credential_type = DoorLock.types.DlCredentialType.PIN, - credential_index = command.args.codeSlot, - } - device:send(clusters.DoorLock.server.commands.GetCredentialStatus(device, endpoint_id, credential)) -end +local function handle_clear_issuer_key(driver, device, command) + -- Get parameters + local cmdName = "clearIssuerKey" + local userIdx = command.args.userIndex + local reqId = command.args.requestId -local function handle_set_code(driver, device, command) - local endpoint = device:component_to_endpoint(command.component) - if (command.args.codePIN == "") then - driver:inject_capability_command( - device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {command.args.codeSlot, command.args.codeName}, - } - ) - else - local credential = { - credential_type = DoorLock.types.DlCredentialType.PIN, - credential_index = command.args.codeSlot, + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = "busy" } - device:set_field(lock_utils.SET_CREDENTIAL, command.args.codeSlot) - device:send( - DoorLock.server.commands.SetCredential( - device, endpoint, DoorLock.types.DlDataOperationType.ADD, -- operation_type - credential, command.args.codePIN, -- credential_data - nil, -- nil user_index creates a new user - DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, DoorLock.types.DlUserType.UNRESTRICTED_USER - ) - ) - if (command.args.codeName ~= nil) then - -- wait for confirmation from the lock to commit this to memory - -- Groovy driver has a lot more info passed here as a description string, may need to be investigated - local codeState = device:get_field(lock_utils.CODE_STATE) or {} - codeState["setName" .. command.args.codeSlot] = command.args.codeName - device:set_field(lock_utils.CODE_STATE, codeState, {persist = true}) + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Find issuer key index + for index, entry in pairs(aliro_table) do + if entry.userIndex == userIdx and entry.keyType == "issuerKey" then + -- Set parameters + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = entry.keyIndex, + } + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) + break end end end -local function handle_name_slot(driver, device, command) - local code_slot = tostring(command.args.codeSlot) - local lock_codes = lock_utils.get_lock_codes(device) - if (lock_codes[code_slot] ~= nil) then - lock_codes[code_slot] = command.args.codeName - device:emit_event( - capabilities.lockCodes.codeChanged( - code_slot .. " renamed", {state_change = true} - ) - ) - lock_utils.lock_codes_event(device, lock_codes) +local function handle_set_endpoint_key(driver, device, command) + -- Get parameters + local cmdName = "setEndpointKey" + local userIdx = command.args.userIndex + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local keyId = command.args.keyId + local keyType = command.args.keyType + local endpointKey = command.args.endpointKey + local reqId = command.args.requestId + local dataOpType = DoorLock.types.DataOperationTypeEnum.ADD -- Data Operation Type: Add(0), Modify(2) + local endpointKeyIndex = INITIAL_CREDENTIAL_INDEX + + -- Min user index of commandResult is 1 + -- 0 should convert to nil before busy check + if userIdx == 0 then + userIdx = nil end -end -local function find_default_endpoint(device, cluster) - local res = device.MATTER_DEFAULT_ENDPOINT - local eps = device:get_endpoints(cluster) - table.sort(eps) - for _, v in ipairs(eps) do - if v ~= 0 then --0 is the matter RootNode endpoint - return v + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Adjustment + if userIdx ~= nil then + userType = nil + + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Find existing endpoint key + for index, entry in pairs(aliro_table) do + if (entry.keyType == "evictableEndpointKey" or entry.keyType == "nonEvictableEndpointKey") and entry.keyId == keyId then + dataOpType = DoorLock.types.DataOperationTypeEnum.MODIFY + endpointKeyIndex = entry.keyIndex + delete_aliro_from_table(device, userIdx, keyType, keyId) + break + end end end - device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return res -end -local function component_to_endpoint(device, component_name) - return find_default_endpoint(device, clusters.DoorLock.ID) + local credential = { + credential_type = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType], + credential_index = endpointKeyIndex + } + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.DEVICE_KEY_ID, keyId, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY_TYPE, keyType, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY, endpointKey, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY_INDEX, endpointKeyIndex, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + dataOpType, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + lock_utils.hex_string_to_octet_string(endpointKey), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type + ) + ) end -local function device_init(driver, device) - device:set_component_to_endpoint_fn(component_to_endpoint) - device:subscribe() - end +local function handle_clear_endpoint_key(driver, device, command) + -- Get parameters + local cmdName = "clearEndpointKey" + local userIdx = command.args.userIndex + local keyId = command.args.keyId + local keyType = command.args.keyType + local reqId = command.args.requestId -local function device_added(driver, device) - --Note: May want to write OperatingMode to NORMAL, to attempt to ensure remote operation works - --Note: May want to write RequirePINForRemoteOperation, to avoid cota cases if possible. - device:emit_event(capabilities.tamperAlert.tamper.clear()) - local eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) - if #eps == 0 then - if device:supports_capability_by_id(capabilities.tamperAlert.ID) then - device.log.debug("Device does not support lockCodes. Switching profile.") - device:try_update_metadata({profile = "lock-without-codes"}) - else - device.log.debug("Device supports neither lock codes nor tamper. Unable to switch profile.") - end + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.DEVICE_KEY_ID, keyId, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY_TYPE, keyType, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + local ep = device:component_to_endpoint(command.component) + if keyId == nil then + return else - local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) - req:merge(DoorLock.attributes.MaxPINCodeLength:read(device, eps[1])) - req:merge(DoorLock.attributes.MinPINCodeLength:read(device, eps[1])) - req:merge(DoorLock.attributes.NumberOfPINUsersSupported:read(device, eps[1])) - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - - --Device may require pin for remote operation if it supports COTA and PIN features. - eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.CREDENTIALSOTA | DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) - if #eps == 0 then - device.log.debug("Device will not require PIN for remote operation") - device:set_field(lock_utils.COTA_CRED, false, {persist = true}) - else - req:merge(DoorLock.attributes.RequirePINforRemoteOperation:read(device, eps[1])) + -- Find aliro credential + for index, entry in pairs(aliro_table) do + if entry.userIndex == userIdx and entry.keyId == keyId and entry.keyType == keyType then + -- Set parameters + local credential = { + credential_type = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType], + credential_index = entry.keyIndex, + } + -- Send command + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) + break + end end - device:send(req) end end local matter_lock_driver = { + NAME = "Matter Lock Driver", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + infoChanged = info_changed, + driverSwitched = driver_switched + }, + shared_device_thread_enabled = true, matter_handlers = { attr = { [DoorLock.ID] = { [DoorLock.attributes.LockState.ID] = lock_state_handler, - [DoorLock.attributes.MaxPINCodeLength.ID] = max_pin_code_len_handler, + [DoorLock.attributes.OperatingMode.ID] = operating_modes_handler, + [DoorLock.attributes.NumberOfTotalUsersSupported.ID] = total_users_supported_handler, + [DoorLock.attributes.NumberOfPINUsersSupported.ID] = pin_users_supported_handler, [DoorLock.attributes.MinPINCodeLength.ID] = min_pin_code_len_handler, - [DoorLock.attributes.NumberOfPINUsersSupported.ID] = num_pin_users_handler, + [DoorLock.attributes.MaxPINCodeLength.ID] = max_pin_code_len_handler, [DoorLock.attributes.RequirePINforRemoteOperation.ID] = require_remote_pin_handler, + [DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser.ID] = max_week_schedule_of_user_handler, + [DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser.ID] = max_year_schedule_of_user_handler, + [DoorLock.attributes.AliroReaderVerificationKey.ID] = aliro_reader_verification_key_handler, + [DoorLock.attributes.AliroReaderGroupIdentifier.ID] = aliro_reader_group_id_handler, + [DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions.ID] = aliro_protocol_versions_handler, + [DoorLock.attributes.AliroGroupResolvingKey.ID] = aliro_group_resolving_key_handler, + [DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions.ID] = aliro_supported_ble_uwb_protocol_versions_handler, + [DoorLock.attributes.AliroBLEAdvertisingVersion.ID] = aliro_ble_advertising_version_handler, + [DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported.ID] = max_aliro_credential_issuer_key_handler, + [DoorLock.attributes.NumberOfAliroEndpointKeysSupported.ID] = max_aliro_endpoint_key_handler, + [DoorLockFeatureMapAttr.ID] = door_lock_feature_map_handler, }, [PowerSource.ID] = { + [PowerSource.attributes.AttributeList.ID] = handle_power_source_attribute_list, [PowerSource.attributes.BatPercentRemaining.ID] = handle_battery_percent_remaining, - }, + [PowerSource.attributes.BatChargeLevel.ID] = handle_battery_charge_level, + } }, event = { [DoorLock.ID] = { [DoorLock.events.DoorLockAlarm.ID] = alarm_event_handler, [DoorLock.events.LockOperation.ID] = lock_op_event_handler, - [DoorLock.events.LockUserChange.ID] = lock_user_change_event_handler, }, }, cmd_response = { [DoorLock.ID] = { + [DoorLock.server.commands.SetUser.ID] = set_user_response_handler, + [DoorLock.client.commands.GetUserResponse.ID] = get_user_response_handler, + [DoorLock.server.commands.ClearUser.ID] = clear_user_response_handler, [DoorLock.client.commands.SetCredentialResponse.ID] = set_credential_response_handler, - [DoorLock.client.commands.GetCredentialStatusResponse.ID] = get_credential_status_response_handler, [DoorLock.server.commands.ClearCredential.ID] = clear_credential_response_handler, + [DoorLock.server.commands.SetWeekDaySchedule.ID] = set_week_day_schedule_handler, + [DoorLock.server.commands.ClearWeekDaySchedule.ID] = clear_week_day_schedule_handler, + [DoorLock.server.commands.SetYearDaySchedule.ID] = set_year_day_schedule_handler, + [DoorLock.server.commands.SetAliroReaderConfig.ID] = set_aliro_reader_config_handler, }, }, }, - subscribed_attributes = { - [capabilities.lock.ID] = {DoorLock.attributes.LockState}, - [capabilities.battery.ID] = {PowerSource.attributes.BatPercentRemaining}, - }, - subscribed_events = { - [capabilities.tamperAlert.ID] = {DoorLock.events.DoorLockAlarm, DoorLock.events.LockOperation}, - [capabilities.lockCodes.ID] = {DoorLock.events.LockUserChange}, - }, + subscribed_attributes = subscribed_attributes, + subscribed_events = subscribed_events, capability_handlers = { - [capabilities.refresh.ID] = {[capabilities.refresh.commands.refresh.NAME] = handle_refresh}, [capabilities.lock.ID] = { [capabilities.lock.commands.lock.NAME] = handle_lock, [capabilities.lock.commands.unlock.NAME] = handle_unlock, + [capabilities.lock.commands.unlatch.NAME] = handle_unlatch + }, + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = handle_add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = handle_update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = handle_delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = handle_delete_all_users, + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = handle_add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = handle_update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = handle_delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = handle_delete_all_credentials, + }, + [capabilities.lockSchedules.ID] = { + [capabilities.lockSchedules.commands.setWeekDaySchedule.NAME] = handle_set_week_day_schedule, + [capabilities.lockSchedules.commands.clearWeekDaySchedules.NAME] = handle_clear_week_day_schedule, + [capabilities.lockSchedules.commands.setYearDaySchedule.NAME] = handle_set_year_day_schedule, + [capabilities.lockSchedules.commands.clearYearDaySchedules.NAME] = handle_clear_year_day_schedule, }, - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.deleteCode.NAME] = handle_delete_code, - [capabilities.lockCodes.commands.reloadAllCodes.NAME] = handle_reload_all_codes, - [capabilities.lockCodes.commands.requestCode.NAME] = handle_request_code, - [capabilities.lockCodes.commands.setCode.NAME] = handle_set_code, - [capabilities.lockCodes.commands.nameSlot.NAME] = handle_name_slot, + [capabilities.lockAliro.ID] = { + [capabilities.lockAliro.commands.setReaderConfig.NAME] = handle_set_reader_config, + [capabilities.lockAliro.commands.setCardId.NAME] = handle_set_card_id, + [capabilities.lockAliro.commands.setIssuerKey.NAME] = handle_set_issuer_key, + [capabilities.lockAliro.commands.clearIssuerKey.NAME] = handle_clear_issuer_key, + [capabilities.lockAliro.commands.setEndpointKey.NAME] = handle_set_endpoint_key, + [capabilities.lockAliro.commands.clearEndpointKey.NAME] = handle_clear_endpoint_key, }, + [capabilities.refresh.ID] = {[capabilities.refresh.commands.refresh.NAME] = handle_refresh} }, supported_capabilities = { capabilities.lock, - capabilities.lockCodes, - capabilities.tamperAlert, + capabilities.lockUsers, + capabilities.lockCredentials, + capabilities.lockSchedules, capabilities.battery, + capabilities.batteryLevel }, - lifecycle_handlers = {init = device_init, added = device_added}, + sub_drivers = require("sub_drivers"), } ------------------------------------------------------------------------------------------------------------------------------ --- Driver Initialization ------------------------------------------------------------------------------------------------------------------------------ local matter_driver = MatterDriver("matter-lock", matter_lock_driver) matter_driver:run() diff --git a/drivers/SmartThings/matter-lock/src/lazy_load_subdriver.lua b/drivers/SmartThings/matter-lock/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..a04740d267 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/lazy_load_subdriver.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + local MatterDriver = require "st.matter.driver" + local version = require "version" + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/matter-lock/src/lock_utils.lua b/drivers/SmartThings/matter-lock/src/lock_utils.lua index f154846014..3836669115 100644 --- a/drivers/SmartThings/matter-lock/src/lock_utils.lua +++ b/drivers/SmartThings/matter-lock/src/lock_utils.lua @@ -1,16 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local security = require "st.security" +local PUB_KEY_PREFIX = "04" local lock_utils = { -- Lock device field names @@ -24,12 +16,47 @@ local lock_utils = { COTA_CRED = "cotaCred", COTA_CODE_NAME = "ST Remote Operation Code", COTA_CRED_INDEX = "cotaCredIndex", - NONFUNCTIONAL = "nonFunctional" + NONFUNCTIONAL = "nonFunctional", + COTA_READ_INITIALIZED = "cotaReadInitialized", + BUSY_STATE = "busyState", + COMMAND_NAME = "commandName", + USER_NAME = "userName", + USER_INDEX = "userIndex", + USER_TYPE = "userType", + CRED_INDEX = "credentialIndex", + CRED_DATA = "credentialData", + SCHEDULE_INDEX = "scheduleIndex", + SCHEDULE_WEEK_DAYS = "scheduleWeekDays", + SCHEDULE_START_HOUR = "scheduleStartHour", + SCHEDULE_START_MINUTE = "scheduleStartMinute", + SCHEDULE_END_HOUR = "scheduleEndHour", + SCHEDULE_END_MINUTE = "scheduleEndMinute", + SCHEDULE_LOCAL_START_TIME = "scheduleLocalStartTime", + SCHEDULE_LOCAL_END_TIME = "scheduleLocalEndTime", + VERIFICATION_KEY = "verificationKey", + GROUP_ID = "groupId", + GROUP_RESOLVING_KEY = "groupResolvingKey", + ISSUER_KEY = "issuerKey", + ISSUER_KEY_INDEX = "issuerKeyIndex", + ENDPOINT_KEY = "endpointKey", + ENDPOINT_KEY_INDEX = "endpointKeyIndex", + ENDPOINT_KEY_TYPE = "endpointKeyType", + DEVICE_KEY_ID = "deviceKeyId", + COMMAND_REQUEST_ID = "commandRequestId", + MODULAR_PROFILE_UPDATED = "__MODULAR_PROFILE_UPDATED", + ALIRO_READER_CONFIG_UPDATED = "aliroReaderConfigUpdated", + LATEST_DOOR_LOCK_FEATURE_MAP = "latestDoorLockFeatureMap", + LOCK_CODES_COPY_REQUIRED = "lockCodesCopyRequired", + LOCK_CODES_FOR_MIGRATION = "lockCodesForMigration", } local capabilities = require "st.capabilities" local json = require "st.json" local utils = require "st.utils" +lock_utils.MIN_EPOCH_S = 0 +lock_utils.MAX_EPOCH_S = 0xffffffff +lock_utils.THIRTY_YEARS_S = 946684800 -- 1970-01-01T00:00:00 ~ 2000-01-01T00:00:00 + lock_utils.get_lock_codes = function(device) local lc = device:get_field(lock_utils.LOCK_CODES) return lc ~= nil and lc or {} @@ -87,4 +114,199 @@ end -- keys are the code slots that ST uses -- user_index and credential_index are used in the matter commands -- +function lock_utils.get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +function lock_utils.set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +function lock_utils.optional_capabilities_list_changed(new_component_capability_list, previous_component_capability_list) + local previous_capability_map = {} + local component_sizes = {} + local previous_component_count = 0 + for component_name, component in pairs(previous_component_capability_list or {}) do + previous_capability_map[component_name] = {} + component_sizes[component_name] = 0 + for _, capability in pairs(component.capabilities or {}) do + if capability.id ~= "lock" and capability.id ~= "lockAlarm" and capability.id ~= "remoteControlStatus" and + capability.id ~= "firmwareUpdate" and capability.id ~= "refresh" then + previous_capability_map[component_name][capability.id] = true + component_sizes[component_name] = component_sizes[component_name] + 1 + end + end + previous_component_count = previous_component_count + 1 + end + + local number_of_components_counted = 0 + for _, new_component_capabilities in pairs(new_component_capability_list or {}) do + local component_name = new_component_capabilities[1] + local capability_list = new_component_capabilities[2] + number_of_components_counted = number_of_components_counted + 1 + if previous_capability_map[component_name] == nil then + return true + end + for _, capability in ipairs(capability_list) do + if previous_capability_map[component_name][capability] == nil then + return true + end + end + if #capability_list ~= component_sizes[component_name] then + return true + end + end + + if number_of_components_counted ~= previous_component_count then + return true + end + + return false +end + +-- This function check busy_state and if busy_state is false, set it to true(current time) +function lock_utils.is_busy_state_set(device) + local c_time = os.time() + local busy_state = device:get_field(lock_utils.BUSY_STATE) or false + if busy_state == false or c_time - busy_state > 10 then + device:set_field(lock_utils.BUSY_STATE, c_time, {persist = true}) + return false + else + return true + end +end + +-- This type represents an offset, in seconds, from 0 hours, 0 minutes, 0 seconds, on the 1st of January, 2000 UTC +function lock_utils.iso8601_to_epoch(iso_str) + local pattern = "^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)" + local year, month, day, hour, min, sec = iso_str:match(pattern) + if not year then + return nil + end + local epoch_s = os.time({ + year = tonumber(year), + month = tonumber(month), + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(min), + sec = tonumber(sec), + }) + + -- The os.time() is based on 1970. Thirty years must be subtracted for calculations from 2000. + epoch_s = epoch_s - lock_utils.THIRTY_YEARS_S + + if epoch_s < lock_utils.MIN_EPOCH_S then + return lock_utils.MIN_EPOCH_S + elseif epoch_s > lock_utils.MAX_EPOCH_S then + return lock_utils.MAX_EPOCH_S + else + return epoch_s + end +end + +function lock_utils.hex_string_to_octet_string(hex_string) + if hex_string == nil then + return nil + end + local octet_string = "" + for i = 1, #hex_string, 2 do + local hex = hex_string:sub(i, i + 1) + octet_string = octet_string .. string.char(tonumber(hex, 16)) + end + return octet_string +end + +function lock_utils.create_group_id_resolving_key() + math.randomseed(os.time()) + local result = string.format("%02x", math.random(0, 255)) + for i = 1, 15 do + result = result .. string.format("%02x", math.random(0, 255)) + end + return result +end + +function lock_utils.generate_keypair(device) + local request_opts = { + key_algorithm = { + type = "ec", + curve = "prime256v1" + }, + signature_algorithm = "sha256", + return_formats = { + pem = true, + der = true + }, + subject = { + common_name = "reader config" + }, + validity_days = 36500, + x509_extensions = { + key_usage = { + critical = true, + digital_signature = true + }, + certificate_policies = { + critical = true, + policy_2030_5_self_signed_client = true + } + } + } + local status = security.generate_self_signed_cert(request_opts) + if not status or not status.key_der then + device.log.error("generate_self_signed_cert returned no data") + return nil, nil + end + + local der = status.key_der + local privKey, pubKey = nil, nil + -- Helper: Parse ASN.1 length (handles 1-byte and multi-byte lengths) + local function get_length(data, start_pos) + local b = string.byte(data, start_pos) + if not b then return nil, start_pos end + + if b < 0x80 then + return b, start_pos + 1 + else + local num_bytes = b - 0x80 + local len = 0 + for i = 1, num_bytes do + len = (len * 256) + string.byte(data, start_pos + i) + end + return len, start_pos + 1 + num_bytes + end + end + -- Start parsing after the initial SEQUENCE tag (0x30) + -- Most keys start: [0x30][Length]. We find the first length to find the start of content. + local _, pos = get_length(der, 2) + + while pos < #der do + local tag = string.byte(der, pos) + local len, content_start = get_length(der, pos + 1) + if not len then break end + if tag == 0x04 then + -- PRIVATE KEY: Octet String + privKey = utils.bytes_to_hex_string(string.sub(der, content_start, content_start + len - 1)) + elseif tag == 0xA1 then + -- PUBLIC KEY Wrapper: Explicit Tag [1] + -- Inside 0xA1 is a BIT STRING (0x03) + local inner_tag = string.byte(der, content_start) + if inner_tag == 0x03 then + local bit_len, bit_start = get_length(der, content_start + 1) + -- BIT STRINGS have a "leading null byte" (unused bits indicator) + -- We skip that byte (bit_start) and the 0x04 EC prefix to get the raw X/Y coordinates + local actual_key_start = bit_start + 2 + local actual_key_len = bit_len - 2 + pubKey = PUB_KEY_PREFIX .. utils.bytes_to_hex_string(string.sub(der, actual_key_start, actual_key_start + actual_key_len - 1)) + end + end + -- Move pointer to the next tag + pos = content_start + len + end + + if not privKey or not pubKey then + device.log.error("Failed to extract keys from DER") + end + return privKey, pubKey +end + return lock_utils diff --git a/drivers/SmartThings/matter-lock/src/sub_drivers.lua b/drivers/SmartThings/matter-lock/src/sub_drivers.lua new file mode 100644 index 0000000000..563c428945 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("unmigrated-matter-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua new file mode 100644 index 0000000000..57d7e2b71e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua @@ -0,0 +1,334 @@ +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(0) +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-user-pin.yml"), + manufacturer_info = { + vendor_id = 0x115f, + product_id = 0x2802, + }, + 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.DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.DoorLock.types.Feature.PIN_CREDENTIAL | + clusters.DoorLock.types.Feature.USER + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = clusters.DoorLock.ID} +local function test_init() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(clusters.DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.attributes.MinPINCodeLength:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.events.LockUserChange:subscribe(mock_device)) + -- add test device + test.mock_device.add_test_device(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-user-pin" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Handle Lock command received from SmartThings.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "lock", component = "main", command = "lock", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = {mock_device.id, clusters.DoorLock.server.commands.LockDoor(mock_device, 1)}, + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Unlock command received from SmartThings.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "lock", component = "main", command = "unlock", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.DoorLock.server.commands.UnlockDoor(mock_device, 1), + }, + }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.LOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, clusters.DoorLock.attributes.LockState.LOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.UNLOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, clusters.DoorLock.attributes.LockState.UNLOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.NOT_FULLY_LOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, clusters.DoorLock.attributes.LockState.NOT_FULLY_LOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.not_fully_locked()) + ) + end, + { + min_api_version = 17 + } +) + +local function refresh_commands(dev) + local req = clusters.DoorLock.attributes.LockState:read(dev) + return req +end + +test.register_message_test( + "Handle received refresh.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "refresh", component = "main", command = "refresh", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = {mock_device.id, refresh_commands(mock_device)}, + }, + }, + { + min_api_version = 17 + } +) + +local DlAlarmCode = clusters.DoorLock.types.DlAlarmCode +test.register_message_test( + "Handle DoorLockAlarm event from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = DlAlarmCode.LOCK_JAMMED} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.unableToLockTheDoor({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = DlAlarmCode.LOCK_FACTORY_RESET} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.lockFactoryReset({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = DlAlarmCode.WRONG_CODE_ENTRY_LIMIT} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.attemptsExceeded({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.damaged({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = DlAlarmCode.DOOR_FORCED_OPEN} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.forcedOpeningAttempt({state_change = true}) + ), + }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Added lifecycle event lock nocodes nobattery", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.clear({state_change = true}) + ) + ) +end, +{ + min_api_version = 17 +} +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua new file mode 100644 index 0000000000..2f3566eb6c --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua @@ -0,0 +1,109 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local mock_device_record = { + profile = t_utils.get_profile_definition("base-lock.yml"), + manufacturer_info = {vendor_id = 0, product_id = 0}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + {cluster_id = clusters.DoorLock.ID, cluster_type = "SERVER", feature_map = 0x0101}, + }, + }, + }, +} +local mock_device = test.mock_device.build_test_matter_device(mock_device_record) + +local mock_device_record_level = { + profile = t_utils.get_profile_definition("lock-nocodes-notamper-batteryLevel.yml"), + manufacturer_info = {vendor_id = 0x129F, product_id = 0x0001}, -- Level Lock Plus + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + {cluster_id = clusters.DoorLock.ID, cluster_type = "SERVER", feature_map = 0x0000}, + }, + }, + }, +} + +local mock_device_level = test.mock_device.build_test_matter_device(mock_device_record_level) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes("[]", {visibility = {displayed = false}})) + ) + local req = clusters.DoorLock.attributes.MaxPINCodeLength:read(mock_device, 10) + req:merge(clusters.DoorLock.attributes.MinPINCodeLength:read(mock_device, 10)) + req:merge(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device, 10)) + test.socket.matter:__expect_send({mock_device.id, req}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + + test.mock_device.add_test_device(mock_device_level) + test.socket.device_lifecycle:__queue_receive({ mock_device_level.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_level.id, "init" }) + local subscribe_request_level = clusters.DoorLock.attributes.LockState:subscribe(mock_device_level) + subscribe_request_level:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device_level)) + test.socket["matter"]:__expect_send({mock_device_level.id, subscribe_request_level}) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "doConfigure lifecycle event for base-lock-nobattery", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "base-lock-nobattery" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doConfigure lifecycle event for Level Lock Plus profile", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_level.id, "doConfigure" }) + mock_device_level:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua index 80c5d6dac7..58185f2ec8 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -34,7 +24,7 @@ local mock_device_record = { endpoint_id = 10, clusters = { {cluster_id = clusters.DoorLock.ID, cluster_type = "SERVER", feature_map = 0x0000}, - {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 10}, }, }, }, @@ -42,12 +32,20 @@ local mock_device_record = { local mock_device = test.mock_device.build_test_matter_device(mock_device_record) local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + ) + mock_device:expect_metadata_update({ profile = "lock-without-codes" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.set_test_init_function(test_init) @@ -70,6 +68,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, clusters.DoorLock.server.commands.LockDoor(mock_device, 10)}, }, + }, + { + min_api_version = 17 } ) @@ -91,11 +92,14 @@ test.register_message_test( clusters.DoorLock.server.commands.UnlockDoor(mock_device, 10), }, }, + }, + { + min_api_version = 17 } ) test.register_message_test( - "Handle received Lock State from Matter device.", { + "Handle received LockState.LOCKED from Matter device.", { { channel = "matter", direction = "receive", @@ -111,6 +115,101 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()), }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle received LockState.UNLOCKED from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 10, clusters.DoorLock.attributes.LockState.UNLOCKED + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()), + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle received LockState.NOT_FULLY_LOCKED from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 10, clusters.DoorLock.attributes.LockState.NOT_FULLY_LOCKED + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.not_fully_locked()), + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle received LockState.UNLATCHED from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 10, clusters.DoorLock.attributes.LockState.UNLATCHED + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()), + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle unknown LockState value from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 10, 0xFF + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()), + }, + }, + { + min_api_version = 17 } ) @@ -133,6 +232,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 17 } ) @@ -157,6 +259,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, refresh_commands(mock_device)}, }, + }, + { + min_api_version = 17 } ) @@ -223,6 +328,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()), }, + }, + { + min_api_version = 17 } ) @@ -260,6 +368,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()), }, + }, + { + min_api_version = 17 } ) @@ -272,7 +383,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) -end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua new file mode 100644 index 0000000000..e43e075baf --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua @@ -0,0 +1,161 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local uint32 = require "st.matter.data_types.Uint32" + +local mock_device_record = { + profile = t_utils.get_profile_definition("base-lock.yml"), + manufacturer_info = {vendor_id = 0xcccc, product_id = 0x1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + {cluster_id = clusters.DoorLock.ID, cluster_type = "SERVER", feature_map = 0x0000}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 10}, + }, + }, + }, +} +local mock_device = test.mock_device.build_test_matter_device(mock_device_record) + +local mock_device_no_battery_record = { + profile = t_utils.get_profile_definition("base-lock.yml"), + manufacturer_info = {vendor_id = 0xcccc, product_id = 0x1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + {cluster_id = clusters.DoorLock.ID, cluster_type = "SERVER", feature_map = 0x0000}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0}, + }, + }, + }, +} +local mock_device_no_battery = test.mock_device.build_test_matter_device(mock_device_no_battery_record) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + ) + mock_device:expect_metadata_update({ profile = "lock-without-codes" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) +end +test.set_test_init_function(test_init) + +local function test_init_no_battery() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_no_battery) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_battery.id, "added" }) + test.socket.capability:__expect_send( + mock_device_no_battery:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + ) + mock_device_no_battery:expect_metadata_update({ profile = "lock-without-codes-nobattery" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_battery.id, "init" }) + local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device_no_battery) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device_no_battery)) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device_no_battery)) + test.socket["matter"]:__expect_send({mock_device_no_battery.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_battery.id, "doConfigure" }) + mock_device_no_battery:expect_metadata_update({ profile = "base-lock-nobattery" }) + mock_device_no_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.register_coroutine_test( + "Test profile changes to base-lock when battery percent remaining attribute (attribute ID 12) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 2, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + mock_device:expect_metadata_update({ profile = "base-lock" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test that profile changes to base-lock-batteryLevel when battery level attribute (attribute ID 14) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 2, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + mock_device:expect_metadata_update({ profile = "base-lock-batteryLevel" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test that profile changes to base-lock-no-battery when battery feature is not available", + function() + end, + { + test_init = test_init_no_battery, + min_api_version = 17 + } +) +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua new file mode 100644 index 0000000000..f1b3c2f182 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua @@ -0,0 +1,102 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local mock_device_record = { + profile = t_utils.get_profile_definition("lock-nocodes-notamper-batteryLevel.yml"), + manufacturer_info = {vendor_id = 0x101D, product_id = 0x1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + {cluster_id = clusters.DoorLock.ID, cluster_type = "SERVER", feature_map = 0x0000}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002}, + }, + }, + }, +} +local mock_device = test.mock_device.build_test_matter_device(mock_device_record) + + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(clusters.PowerSource.attributes.BatChargeLevel:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Handle BatChargeLevel capability handling with batteryLevel.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device, 10, clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.critical()), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device, 10, clusters.PowerSource.types.BatChargeLevelEnum.WARNING + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.warning()), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device, 10, clusters.PowerSource.types.BatChargeLevelEnum.OK + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.normal()), + }, + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua index d479f350cb..c2e86aae29 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua @@ -1,31 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. --- Mock out globals +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -34,6 +10,7 @@ local clusters = require "st.matter.clusters" local DoorLock = clusters.DoorLock local im = require "st.matter.interaction_model" local types = DoorLock.types + local mock_device_record = { profile = t_utils.get_profile_definition("base-lock.yml"), manufacturer_info = {vendor_id = 0xcccc, product_id = 0x1}, @@ -55,7 +32,7 @@ local mock_device_record = { cluster_type = "SERVER", feature_map = 0x0101, -- PIN & USR }, - {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0}, }, }, }, @@ -63,18 +40,30 @@ local mock_device_record = { local mock_device = test.mock_device.build_test_matter_device(mock_device_record) local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "base-lock-nobattery" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.set_test_init_function(test_init) local expect_reload_all_codes_messages = function(dev) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({}), {visibility = {displayed = false}} + ) + ) + ) + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.mock_time.advance_time(5) local credential = types.DlCredential({credential_type = types.DlCredentialType.PIN, credential_index = 1}) test.socket.capability:__expect_send( dev:generate_test_message( @@ -179,11 +168,11 @@ local function init_code_slot(slot_number, name, device) ) local credential = DoorLock.types.DlCredential( - { + { credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = slot_number, } - ) + ) test.socket.matter:__expect_send( { device.id, @@ -232,7 +221,10 @@ test.register_coroutine_test( req:merge(DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device, 10)) test.socket.matter:__expect_send({mock_device.id, req}) expect_reload_all_codes_messages(mock_device) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -245,7 +237,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -266,6 +261,9 @@ test.register_message_test( capabilities.lockCodes.minCodeLength(4, {visibility = {displayed = false}}) ), }, + }, + { + min_api_version = 17 } ) @@ -287,6 +285,9 @@ test.register_message_test( capabilities.lockCodes.maxCodeLength(4, {visibility = {displayed = false}}) ), }, + }, + { + min_api_version = 17 } ) @@ -308,6 +309,9 @@ test.register_message_test( capabilities.lockCodes.maxCodes(16, {visibility = {displayed = false}}) ), }, + }, + { + min_api_version = 17 } ) @@ -327,6 +331,9 @@ test.register_message_test( ), }, } + }, + { + min_api_version = 17 } ) @@ -344,10 +351,14 @@ test.register_message_test( nil, -- creator_fabric_index nil, -- last_modified_fabric_index 20, -- next_credential_index + nil, -- credential_data im.InteractionResponse.Status.FAILURE ), }, } + }, + { + min_api_version = 17 } ) @@ -360,7 +371,10 @@ test.register_coroutine_test( } ) expect_reload_all_codes_messages(mock_device) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -402,7 +416,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -437,7 +454,10 @@ test.register_coroutine_test( .codeChanged("1 unset", {data = {codeName = "Code 1"}, state_change = true}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -458,6 +478,15 @@ test.register_coroutine_test( {capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {}}, } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({}), {visibility = {displayed = false}} + ) + ) + ) + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.mock_time.advance_time(5) local credential = types.DlCredential({credential_type = types.DlCredentialType.PIN, credential_index = 1}) test.socket.capability:__expect_send( mock_device:generate_test_message( @@ -468,7 +497,6 @@ test.register_coroutine_test( {mock_device.id, DoorLock.server.commands.GetCredentialStatus(mock_device, 10, credential)} ) test.wait_for_events() - test.socket.matter:__queue_receive( { mock_device.id, @@ -478,14 +506,38 @@ test.register_coroutine_test( 1, --user_index nil, --creator fabric index nil, --last modified fabric index - nil + 2 --next credential index ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.lockCodes - .codeChanged("1 deleted", {data = {codeName = "Code 1"}, state_change = true}) + .codeChanged("1 unset", {data = {codeName = "Code 1"}, state_change = true}) + ) + ) + credential = types.DlCredential({credential_type = types.DlCredentialType.PIN, credential_index = 2}) + test.socket.matter:__expect_send( + {mock_device.id, DoorLock.server.commands.GetCredentialStatus(mock_device, 10, credential)} + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetCredentialStatusResponse:build_test_command_response( + mock_device, 10, -- endpoint + true, --credential exists + 2, --user_index + nil, --creator fabric index + nil, --last modified fabric index + nil --next credential index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes + .codeChanged("2 set", {data = {codeName = "Code 2"}, state_change = true}) ) ) test.socket.capability:__expect_send( @@ -508,8 +560,12 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) + test.register_coroutine_test( "Deleting a user code should be handled", function() init_code_slot(1, "initialName", mock_device) @@ -553,7 +609,10 @@ test.register_coroutine_test( capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -610,7 +669,10 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "test"}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -642,7 +704,10 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "foo"}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Setting a user code name via setCode should be handled", function() @@ -673,7 +738,10 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "foo"}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -712,6 +780,9 @@ test.register_message_test( ) ), }, + }, + { + min_api_version = 17 } ) @@ -752,7 +823,10 @@ test.register_coroutine_test( capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -828,7 +902,76 @@ test.register_coroutine_test( ) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) +local enabled_optional_component_capability_pairs = {{ + "main", + { + capabilities.lockUsers.ID, + capabilities.lockCredentials.ID, + capabilities.battery.ID, + } +}} + +test.register_coroutine_test( + "Lock codes stored during migration", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({["1"] = "Code 1"}), {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + init_code_slot(2, "Code 2", mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({["1"] = "Code 1", ["2"] = "Code 2"}), {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + init_code_slot(3, "Code 3", mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({["1"] = "Code 1", ["2"] = "Code 2", ["3"] = "Code 3"}), + {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCodes.ID, + command = "migrate", + args = {} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = enabled_optional_component_capability_pairs }) + + end, + { + min_api_version = 17 + } +) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua index e7e277b41c..39066776f9 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua @@ -1,31 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. --- Mock out globals +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -55,7 +31,7 @@ local mock_device_record = { cluster_type = "SERVER", feature_map = 0x0181, -- PIN & USR & COTA }, - {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 10}, }, }, }, @@ -63,18 +39,31 @@ local mock_device_record = { local mock_device = test.mock_device.build_test_matter_device(mock_device_record) local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) + test.socket.matter:__expect_send({mock_device.id, DoorLock.attributes.RequirePINforRemoteOperation:read(mock_device, 10)}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) end test.set_test_init_function(test_init) local expect_reload_all_codes_messages = function(dev) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({}), {visibility = {displayed = false}} + ) + ) + ) + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.mock_time.advance_time(5) local credential = types.DlCredential({credential_type = types.DlCredentialType.PIN, credential_index = 1}) test.socket.capability:__expect_send( dev:generate_test_message( @@ -152,7 +141,10 @@ test.register_coroutine_test( "Added should kick off cota cred process", function() test.socket.matter:__set_channel_ordering("relaxed") expect_kick_off_cota_process(mock_device) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +158,10 @@ test.register_coroutine_test( mock_device.id, clusters.DoorLock.server.commands.UnlockDoor(mock_device, 10, "1111"), }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -180,7 +175,10 @@ test.register_coroutine_test( mock_device.id, clusters.DoorLock.server.commands.LockDoor(mock_device, 10, "1111"), }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -212,7 +210,10 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -233,7 +234,10 @@ test.register_coroutine_test( profile = "nonfunctional-lock", provisioning_state = "NONFUNCTIONAL" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -330,11 +334,12 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "ST Remote Operation Code"}), {visibility = {displayed = false}}) ) ) - mock_device:expect_metadata_update({ - profile = "base-lock", - provisioning_state = "PROVISIONED" - }) - end + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -427,11 +432,12 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "ST Remote Operation Code"}), {visibility = {displayed = false}}) ) ) - mock_device:expect_metadata_update({ - profile = "base-lock", - provisioning_state = "PROVISIONED" - }) - end + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -517,11 +523,12 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "ST Remote Operation Code"}), {visibility = {displayed = false}}) ) ) - mock_device:expect_metadata_update({ - profile = "base-lock", - provisioning_state = "PROVISIONED" - }) - end + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -578,7 +585,10 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -615,7 +625,10 @@ test.register_coroutine_test( ) }) test.mock_time.advance_time(2) - end + end, + { + min_api_version = 17 + } ) @@ -693,7 +706,10 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -725,12 +741,18 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Delay setting COTA cred if another cred is already being set.", function() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_migration.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_migration.lua new file mode 100644 index 0000000000..b94b31e105 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_migration.lua @@ -0,0 +1,143 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local DoorLock = clusters.DoorLock +local lock_utils = require "lock_utils" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + manufacturer_info = { + vendor_id = 0x135D, + product_id = 0x00C1, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0101, -- PIN & USR + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + -- mock_device:set_field(lock_utils.LOCK_CODES_FOR_MIGRATION, { {1, "ST Remote Operation Code"}, {2, "Guest1"}, {3, "Guest2"} }, {persist=true}) +end + +test.set_test_init_function(test_init) + +local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) +subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) + +local enabled_optional_component_capability_pairs = {{ + "main", + { + capabilities.lockUsers.ID, + capabilities.lockCredentials.ID, + capabilities.battery.ID, + } +}} + +test.register_coroutine_test( + "Migration completed and User and Credential values restored", + function() + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = capabilities.lockCodes.ID, command = "migrate", args = {}} + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true)) + ) + -- at this point, refresh is injected in migration. + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = enabled_optional_component_capability_pairs }) + + + test.wait_for_events() + -- assume this was set prior + mock_device:set_field(lock_utils.LOCK_CODES_FOR_MIGRATION, { {1, "ST Remote Operation Code"}, {2, "Guest1"}, {3, "Guest2"} }, {persist=true}) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed( + {profile = {id = "00000000-1111-2222-3333-000000000010", components = { main = {capabilities={ + ["lock"]= {id="lock", version=1}, ["lockAlarm"] = {id="lockAlarm", version=1}, ["remoteControlStatus"] = {id="remoteControlStatus", version=1}, + ["lockUsers"] = {id="lockUsers", version=1}, ["lockCredentials"] = {id="lockCredentials", version=1}, ["firmwareUpdate"] = {id="firmwareUpdate", version=1}, + ["refresh"] = {id="refresh", version=1}}}}}} + )) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + {userIndex=1, userName="ST Remote Operation Code", userType="guest"}, + {userIndex=2, userName="Guest1", userType="guest"}, + {userIndex=3, userName="Guest2", userType="guest"} + }, + {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { + {credentialIndex=1, credentialType="pin", userIndex=1}, + {credentialIndex=2, credentialType="pin", userIndex=2}, + {credentialIndex=3, credentialType="pin", userIndex=3} + }, + {visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua new file mode 100644 index 0000000000..745e769f9e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua @@ -0,0 +1,699 @@ +-- Copyright 2025 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 uint32 = require "st.matter.data_types.Uint32" +local cluster_base = require "st.matter.cluster_base" +local DoorLock = clusters.DoorLock + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_unlatch = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1000, -- UNLATCH + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_user_pin = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-user-pin.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0181, -- PIN & USR & COTA + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_user_pin_schedule_unlatch = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-user-pin-schedule-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1591, -- PIN & USR & COTA & WDSCH & YDSCH & UNLATCH + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_modular = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-modular-embedded-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1591, -- PIN & USR & COTA & WDSCH & YDSCH & UNLATCH + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} +local function test_init() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device)) + + -- add test device + test.mock_device.add_test_device(mock_device) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_unlatch() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_unlatch) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_unlatch)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_unlatch)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_unlatch) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "added" }) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "init" }) + test.socket.matter:__expect_send({mock_device_unlatch.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "doConfigure" }) + mock_device_unlatch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_user_pin() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_user_pin) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin)) + + -- add test device + test.mock_device.add_test_device(mock_device_user_pin) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "added" }) + test.socket.capability:__expect_send( + mock_device_user_pin:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "init" }) + test.socket.matter:__expect_send({mock_device_user_pin.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "doConfigure" }) + mock_device_user_pin:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_user_pin_schedule_unlatch() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_user_pin_schedule_unlatch) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin_schedule_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin_schedule_unlatch)) + + -- add test device + test.mock_device.add_test_device(mock_device_user_pin_schedule_unlatch) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "added" }) + test.socket.capability:__expect_send( + mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "init" }) + test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "doConfigure" }) + mock_device_user_pin_schedule_unlatch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_modular() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_modular) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_modular)) + subscribe_request:merge(cluster_base.subscribe(mock_device_modular, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_modular)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_modular)) + + -- add test device + test.mock_device.add_test_device(mock_device_modular) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "added" }) + test.socket.capability:__expect_send( + mock_device_modular:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "init" }) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "doConfigure" }) + mock_device_modular:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test modular lock profile change when BatChargeLevel attribute is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = {{"main", {"batteryLevel"}}} }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test modular lock profile change when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = {{"main", {"battery"}}} }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-unlatch profile change with unlatch when BatChargeLevel attribute is available", + function() + test.socket.matter:__queue_receive( + { + mock_device_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_unlatch:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"batteryLevel"}}} }) + end, + { + test_init = test_init_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test modular lock profile change with unlatch when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_unlatch:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"battery"}}} }) + end, + { + test_init = test_init_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-user-pin profile change when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_user_pin:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = {{"main", {"lockUsers", "lockCredentials", "battery"}}} }) + end, + { + test_init = test_init_user_pin, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test modular lock profile change with user, pin. schedule, and unlatch supported when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin_schedule_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin_schedule_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"lockUsers", "lockCredentials", "lockSchedules", "battery"}}} }) + + end, + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test modular lock profile update (modular to modular) with user, pin. schedule, and unlatch supported. Ensure infoChanged updates subscription", + function() + test.socket.matter:__queue_receive( + { + mock_device_modular.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_modular, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_modular:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device_modular:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_modular:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"lockUsers", "lockCredentials", "lockSchedules", "battery"}}} }) + end, + { test_init = test_init_modular } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({})) + end, + { test_init = test_init_modular } +) + +test.register_coroutine_test( + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed( + {profile = {id = "00000000-1111-2222-3333-000000000010", components = { main = {capabilities={ + ["lock"]= {id="lock", version=1}, ["lockAlarm"] = {id="lockAlarm", version=1}, ["remoteControlStatus"] = {id="remoteControlStatus", version=1}, + ["lockUsers"] = {id="lockUsers", version=1}, ["lockCredentials"] = {id="lockCredentials", version=1}, ["lockSchedules"] = {id="lockSchedules", version=1}, + ["battery"] = {id="battery", version=1}, ["firmwareUpdate"] = {id="firmwareUpdate", version=1}, ["refresh"] = {id="refresh", version=1}}}}}}) + ) + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_modular) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device_modular)) + subscribe_request:merge(cluster_base.subscribe(mock_device_modular, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_modular)) + subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_modular)) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device_modular)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_modular)) + + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device_modular:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.capability:__expect_send( + mock_device_modular:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) + ) + end, + { + test_init = test_init_modular, + min_api_version = 17 + } +) + + +local mock_nuki_smart_lock_ultra = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x135D, + product_id = 0x00A1, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1000, -- UNBOLT + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" +} + +local profiling_data = { + BATTERY_SUPPORT = "__BATTERY_SUPPORT", +} + +test.register_coroutine_test( + "Test Nuki Smart Lock Ultra profile change with user and pin supported", + function() + -- technically, since power source attributes must be read, this wouldn't be running via doConfigure, but this is straightforward. + test.socket.device_lifecycle:__queue_receive({ mock_nuki_smart_lock_ultra.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_nuki_smart_lock_ultra:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_nuki_smart_lock_ultra:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_nuki_smart_lock_ultra:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"battery"}}}}) + mock_nuki_smart_lock_ultra:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_nuki_smart_lock_ultra) + mock_nuki_smart_lock_ultra:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_PERCENTAGE, {persist = true}) -- assume this has been set previously + end, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua new file mode 100644 index 0000000000..f040c2b26e --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua @@ -0,0 +1,387 @@ +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local test = require "integration_test" +test.set_rpc_version(0) +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local DoorLock = clusters.DoorLock +local types = DoorLock.types + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x115f, + product_id = 0x2802, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1000, -- UNBOLT + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} +local function test_init() + test.disable_startup_messages() + -- subscribe_request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm("clear", {state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-unlatch" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle received OperatingMode(Normal, Vacation) from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.NORMAL + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("true", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.VACATION + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("true", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received OperatingMode(Privacy, No Remote Lock UnLock, Passage) from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.PRIVACY + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("false", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.NO_REMOTE_LOCK_UNLOCK + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("false", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.PASSAGE + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("false", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Lock command received from SmartThings.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "lock", component = "main", command = "lock", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = {mock_device.id, DoorLock.server.commands.LockDoor(mock_device, 1)}, + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Unlock command received from SmartThings.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "lock", component = "main", command = "unlock", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + DoorLock.server.commands.UnboltDoor(mock_device, 1), + }, + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Unlatch command received from SmartThings.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "lock", component = "main", command = "unlatch", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + DoorLock.server.commands.UnlockDoor(mock_device, 1), + }, + }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.LOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, DoorLock.attributes.LockState.LOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.UNLOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, DoorLock.attributes.LockState.UNLOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.UNLATCHED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, DoorLock.attributes.LockState.UNLATCHED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlatched()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Unlatch Operation event from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLATCH, + operation_source = types.OperationSourceEnum.MANUAL, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlatched( + {data = {method = "manual", userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLATCH, + operation_source = types.OperationSourceEnum.BUTTON, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlatched( + {data = {method = "button", userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLATCH, + operation_source = types.OperationSourceEnum.RFID, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlatched( + {data = {method = "rfid", userIndex = 1}, state_change = true} + ) + ), + } + }, + { + min_api_version = 17 + } +) +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua new file mode 100644 index 0000000000..35acbfbff6 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua @@ -0,0 +1,2616 @@ +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- These are test units designed to validate User, Credential, and Schedule features. +local test = require "integration_test" +test.set_rpc_version(0) +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local DoorLock = clusters.DoorLock +local types = DoorLock.types +local lock_utils = require "lock_utils" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock.yml"), + manufacturer_info = { + vendor_id = 0x115f, + product_id = 0x2802, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0591, -- PIN & WDSCH & USR & COTA & YDSCH + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} +local function test_init() + test.disable_startup_messages() + -- device_added + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device)) + subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device)) + subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device)) + subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device)) + subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device)) + subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device)) + subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) + subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + -- device_init + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + -- do_configure + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-user-pin-schedule" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + -- info_changed + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("lock-user-pin-schedule.yml")})) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle driverSwitched event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-user-pin-schedule" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received OperatingMode(Normal, Vacation) from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.NORMAL + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("true", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.VACATION + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("true", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received OperatingMode(Privacy, No Remote Lock UnLock, Passage) from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.PRIVACY + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("false", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.NO_REMOTE_LOCK_UNLOCK + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("false", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.OperatingMode:build_test_report_data( + mock_device, 1, DoorLock.attributes.OperatingMode.PASSAGE + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.remoteControlStatus.remoteControlEnabled("false", {visibility = {displayed = true}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received NumberOfTotalUsersSupported from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfTotalUsersSupported:build_test_report_data( + mock_device, 1, DoorLock.attributes.NumberOfTotalUsersSupported(10) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(10, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received NumberOfPINUsersSupported from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_report_data( + mock_device, 1, DoorLock.attributes.NumberOfPINUsersSupported(10) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(10, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received MinPINCodeLength from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.MinPINCodeLength:build_test_report_data( + mock_device, 1, DoorLock.attributes.MinPINCodeLength(6) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received MaxPINCodeLength from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.MaxPINCodeLength:build_test_report_data( + mock_device, 1, DoorLock.attributes.MaxPINCodeLength(8) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(8, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received NumberOfWeekDaySchedulesSupportedPerUser from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:build_test_report_data( + mock_device, 1, DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser(5) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockSchedules.weekDaySchedulesPerUser(5, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received NumberOfYearDaySchedulesSupportedPerUser from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:build_test_report_data( + mock_device, 1, DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser(5) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockSchedules.yearDaySchedulesPerUser(5, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received RequirePINforRemoteOperation(true) from Matter device.", + function() + test.socket.matter:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.RequirePINforRemoteOperation:build_test_report_data( + mock_device, 1, DoorLock.attributes.RequirePINforRemoteOperation(true) + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.mock_time.advance_time(1) + test.wait_for_events() + mock_device:set_field(lock_utils.COTA_CRED, "654123", {persist = true}) --overwrite random cred for test expectation + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + DoorLock.types.UserStatusEnum.OCCUPIED_ENABLED, -- user_status + DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER -- user_type + ), + } + ) + test.mock_time.advance_time(1) + test.wait_for_events() + + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + nil, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 2} + ), -- credential + "654123", -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER -- user_type + ), + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( +"Handle for duplicated pincode during COTA setting", +function() + test.socket.matter:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.RequirePINforRemoteOperation:build_test_report_data( + mock_device, 1, DoorLock.attributes.RequirePINforRemoteOperation(true) + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.mock_time.advance_time(1) + test.wait_for_events() + mock_device:set_field(lock_utils.COTA_CRED, "654123", {persist = true}) --overwrite random cred for test expectation + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + DoorLock.types.UserStatusEnum.OCCUPIED_ENABLED, -- user_status + DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER -- user_type + ), + } + ) + test.mock_time.advance_time(1) + test.wait_for_events() + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.DUPLICATE, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(11, "oneshot") + test.mock_time.advance_time(10) --trigger remote pin handling + test.wait_for_events() + mock_device:set_field(lock_utils.COTA_CRED, "654123", {persist = true}) --overwrite random cred for test expectation + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + DoorLock.types.UserStatusEnum.OCCUPIED_ENABLED, -- user_status + DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER -- user_type + ), + } + ) + test.mock_time.advance_time(1) + test.wait_for_events() +end, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test( + "Handle received RequirePINforRemoteOperation(false) from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.RequirePINforRemoteOperation:build_test_report_data( + mock_device, 1, DoorLock.attributes.RequirePINforRemoteOperation(false) + ), + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "UnlockDoor uses cota cred when present", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = capabilities.lock.ID, command = "unlock", args = {}} + } + ) + mock_device:set_field(lock_utils.COTA_CRED, "654123") + test.socket.matter:__expect_send({ + mock_device.id, + clusters.DoorLock.server.commands.UnlockDoor(mock_device, 1, "654123"), + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "LockDoor uses cota cred when present", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = capabilities.lock.ID, command = "lock", args = {}} + } + ) + mock_device:set_field(lock_utils.COTA_CRED, "654123") + test.socket.matter:__expect_send({ + mock_device.id, + clusters.DoorLock.server.commands.LockDoor(mock_device, 1, "654123"), + }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Lock command received from SmartThings.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "lock", component = "main", command = "lock", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = {mock_device.id, DoorLock.server.commands.LockDoor(mock_device, 1)}, + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Unlock command received from SmartThings.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "lock", component = "main", command = "unlock", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + DoorLock.server.commands.UnlockDoor(mock_device, 1), + }, + }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.LOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, DoorLock.attributes.LockState.LOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.UNLOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, DoorLock.attributes.LockState.UNLOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received LockState.NOT_FULLY_LOCKED from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.LockState:build_test_report_data( + mock_device, 1, DoorLock.attributes.LockState.NOT_FULLY_LOCKED + ), + } + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.not_fully_locked()) + ) + end, + { + min_api_version = 17 + } +) + +local function refresh_commands(dev) + local req = DoorLock.attributes.LockState:read(dev) + return req +end + +test.register_message_test( + "Handle received refresh.", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "refresh", component = "main", command = "refresh", args = {}}, + }, + }, + { + channel = "matter", + direction = "send", + message = {mock_device.id, refresh_commands(mock_device)}, + }, + }, + { + min_api_version = 17 + } +) + +local AlarmCodeEnum = DoorLock.types.AlarmCodeEnum +test.register_message_test( + "Handle DoorLockAlarm event from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = AlarmCodeEnum.LOCK_JAMMED} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.unableToLockTheDoor({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = AlarmCodeEnum.LOCK_FACTORY_RESET} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.lockFactoryReset({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = AlarmCodeEnum.WRONG_CODE_ENTRY_LIMIT} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.attemptsExceeded({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = AlarmCodeEnum.FRONT_ESCEUTCHEON_REMOVED} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.damaged({state_change = true}) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.DoorLockAlarm:build_test_event_report( + mock_device, 1, {alarm_code = AlarmCodeEnum.DOOR_FORCED_OPEN} + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lockAlarm.alarm.forcedOpeningAttempt({state_change = true}) + ), + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Lock Operation event from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLOCK, + operation_source = types.OperationSourceEnum.KEYPAD, + user_index = 1, + fabric_index = 1, + source_node = 1, + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ) + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlocked( + {data = {method = "keypad", userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.LOCK, + operation_source = types.OperationSourceEnum.UNSPECIFIED, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.locked( + {data = {userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLOCK, + operation_source = types.OperationSourceEnum.PROPRIETARY_REMOTE, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlocked( + {data = {method = "proprietaryRemote", userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.LOCK, + operation_source = types.OperationSourceEnum.AUTO, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.locked( + {data = {method = "auto", userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLOCK, + operation_source = types.OperationSourceEnum.SCHEDULE, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlocked( + {data = {userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.LOCK, + operation_source = types.OperationSourceEnum.REMOTE, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.locked( + {data = {method = "command", userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLOCK, + operation_source = types.OperationSourceEnum.BIOMETRIC, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlocked( + {data = {userIndex = 1}, state_change = true} + ) + ), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + DoorLock.events.LockOperation:build_test_event_report( + mock_device, 1, + { + lock_operation_type = types.LockOperationTypeEnum.UNLOCK, + operation_source = types.OperationSourceEnum.ALIRO, + user_index = 1, + fabric_index = 1, + source_node = 1 + } + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", + capabilities.lock.lock.unlocked( + {data = {method = "digitalKey", userIndex = 1}, state_change = true} + ) + ), + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Add User command received from SmartThings.", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = {"Guest1", "adminMember"} + }, + }) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetUser( + mock_device, 1, + 1 -- user_index + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetUserResponse:build_test_command_response( + mock_device, 1, + 1, --user_index + nil, nil, nil, nil, nil, nil, nil, nil, nil + ), + }) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetUser( + mock_device, 1, + types.DataOperationTypeEnum.ADD, + 1, + "Guest1", + nil, + nil, + types.UserTypeEnum.UNRESTRICTED_USER, + nil + ) + }) + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetUser:build_test_command_response( + mock_device, 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({{userIndex = 1, userName="Guest1", userType = "adminMember"}}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="addUser", statusCode="success", userIndex=1}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add User command received from SmartThings and commandResult is busy", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = {"Guest1", "adminMember"} + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="addUser", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Add User command received from SmartThings and commandResult is resourceExhausted", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "addUser", + args = {"Guest1", "adminMember"} + }, + }) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetUser( + mock_device, 1, + 1 -- user_index + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetUserResponse:build_test_command_response( + mock_device, 1, + 10, --user_index + nil, nil, + DoorLock.types.UserStatusEnum.OCCUPIED_ENABLED, --user_state + nil, nil, nil, nil, nil, nil + ), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="addUser", statusCode="resourceExhausted", userIndex=10}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Update User command received from SmartThings.", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = {1, "Guest1", "adminMember"} + }, + }) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetUser( + mock_device, 1, + types.DataOperationTypeEnum.MODIFY, + 1, + "Guest1", + nil, + nil, + types.UserTypeEnum.UNRESTRICTED_USER, + nil + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetUser:build_test_command_response( + mock_device, 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="updateUser", statusCode="success", userIndex=1}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Update User command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "updateUser", + args = {1, "Guest1", "adminMember"} + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="updateUser", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Delete User command received from SmartThings.", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = {1} + }, + }) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.ClearUser( + mock_device, 1, + 1 + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearUser:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="deleteUser", statusCode="success", userIndex=1}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Delete User command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteUser", + args = {1} + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="deleteUser", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Delete All Users command received from SmartThings.", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteAllUsers", + args = {} + }, + }) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.ClearUser( + mock_device, 1, + 0xFFFE + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearUser:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="deleteAllUsers", statusCode="success", userIndex=65534}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Delete All Users command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = capabilities.lockUsers.ID, + command = "deleteAllUsers", + args = {} + }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.commandResult( + {commandName="deleteAllUsers", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Add Credential command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {1, "adminMember", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + 1, -- user_index + nil, -- user_status + nil -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {{credentialIndex=1, credentialType="pin", userIndex=1}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", credentialIndex=1, statusCode="success", userIndex=1}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Credential command received from SmartThings and commandResult is busy", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {1, "adminMember", "pin", "654123"} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Credential command received from SmartThings and commandResult is invalidCommand", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {1, "adminMember", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + 1, -- user_index + nil, -- user_status + nil -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.INVALID_FIELD, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", statusCode="invalidCommand"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Credential command received from SmartThings and user_index is occupied", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {1, "adminMember", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + 1, -- user_index + nil, -- user_status + nil -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 2} + ), -- credential + "654123", -- credential_data + 1, -- user_index + nil, -- user_status + nil -- user_type + ), + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Credential command received from SmartThings and commandResult is resourceExhausted", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {1, "adminMember", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + 1, -- user_index + nil, -- user_status + nil -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + nil -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", statusCode="resourceExhausted"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Update Credential for Guest user should not add default schedule again", + function() + -- Add Guest user with credential. This sets the USER_TYPE field to "guest". + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {0, "guest", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest"}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {{credentialIndex=1, credentialType="pin", userIndex=1}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + 0xffffffff -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS -- status + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", credentialIndex=1, statusCode="success", userIndex=1}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.wait_for_events() + -- Update the Guest user's credential. The stale "guest" USER_TYPE field must + -- be cleared so the default schedule is not added again on the response. + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = {1, 1, "pin", "111213"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.MODIFY, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "111213", -- credential_data + 1, -- user_index + nil, -- user_status + nil -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + -- The commandResult must be emitted without sending SetYearDaySchedule + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="updateCredential", credentialIndex=1, statusCode="success", userIndex=1}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Update Credential command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "updateCredential", + args = {1, 1, "pin", "654123"} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="updateCredential", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Delete Credential command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = {1, "pin"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.ClearCredential( + mock_device, 1, -- endpoint + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearCredential:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="deleteCredential", credentialIndex=1, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Delete Credential command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteCredential", + args = {1, "pin"} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="deleteCredential", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Delete all Credentials command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {"pin"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.ClearCredential( + mock_device, 1, -- endpoint + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 0xFFFE} + ) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearCredential:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="deleteAllCredentials", userIndex=65534, credentialIndex=65534, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Delete all Credentials command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "deleteAllCredentials", + args = {"pin"} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="deleteAllCredentials", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Add Week Day Schedule command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "setWeekDaySchedule", + args = {1, 1, {weekDays={"Monday"}, startHour=12, startMinute=30, endHour=17, endMinute=30}} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetWeekDaySchedule( + mock_device, 1, -- endpoint + 1, -- Week Day Schedule Index + 1, -- User Index + 2, -- Days Mask + 12, -- Start Hour + 30, -- Start Minute + 17, -- End Hour + 30 -- End Minute + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetWeekDaySchedule:build_test_command_response( + mock_device, 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules( + {{ + userIndex=1, + schedules={{ + scheduleIndex=1, + weekDays={"Monday"}, + startHour=12, + startMinute=30, + endHour=17, + endMinute=30 + }}, + }}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="setWeekDaySchedule", userIndex=1, scheduleIndex=1, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Week Day Schedule command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "setWeekDaySchedule", + args = {1, 1, {weekDays={"Monday"}, startHour=12, startMinute=30, endHour=17, endMinute=30}} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="setWeekDaySchedule", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Clear Week Day Schedule command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "clearWeekDaySchedules", + args = { + 1, -- user index + 1, -- schedule index + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.ClearWeekDaySchedule( + mock_device, 1, -- endpoint + 1, -- schedule index + 1 -- user index + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearWeekDaySchedule:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="clearWeekDaySchedules", userIndex = 1, scheduleIndex=1, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Clear Week Day Schedule command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "clearWeekDaySchedules", + args = { + 1, -- user index + 1, -- schedule index + } + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="clearWeekDaySchedules", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +local LOCAL_START_TIME = "2025-01-15T09:00:00" +local LOCAL_END_TIME = "2025-01-15T17:00:00" + +test.register_coroutine_test( + "Handle Add Year Day Schedule command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "setYearDaySchedule", + args = {1, 1, {localStartTime=LOCAL_START_TIME, localEndTime=LOCAL_END_TIME}} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- Schedule Index + 1, -- User Index + lock_utils.iso8601_to_epoch(LOCAL_START_TIME), -- Start Time (epoch) + lock_utils.iso8601_to_epoch(LOCAL_END_TIME) -- End Time (epoch) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules( + {{ + userIndex=1, + schedules={{ + scheduleIndex = 1, + localStartTime = LOCAL_START_TIME, + localEndTime = LOCAL_END_TIME + }}, + }}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="setYearDaySchedule", userIndex=1, scheduleIndex=1, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Year Day Schedule command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "setYearDaySchedule", + args = {1, 1, {localStartTime=LOCAL_START_TIME, localEndTime=LOCAL_END_TIME}} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="setYearDaySchedule", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Clear Year Day Schedule command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "clearYearDaySchedules", + args = { + 1, -- user index + 1, -- schedule index + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + lock_utils.MAX_EPOCH_S -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="clearYearDaySchedules", userIndex=1, scheduleIndex=1, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Clear Year Day Schedule command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "clearYearDaySchedules", + args = { + 1, -- user index + 1, -- schedule index + } + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="clearYearDaySchedules", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Guest User and failure response ", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {0, "guest", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({{userIndex = 1, userType = "guest"}}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {{credentialIndex=1, credentialType="pin", userIndex=1}}, {visibility={displayed=false}} + ) + ) + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + 0xffffffff -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE -- status + ), + } + ) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.ClearUser( + mock_device, 1, + 1 + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearUser:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", statusCode="failure", userIndex=1}, {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Add Guest User and failure response, and ClearUser command fails as well", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {0, "guest", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({{userIndex = 1, userType = "guest"}}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {{credentialIndex=1, credentialType="pin", userIndex=1}}, {visibility={displayed=false}} + ) + ) + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + 0xffffffff -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE -- status + ), + } + ) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.ClearUser( + mock_device, 1, + 1 + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearUser:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE -- status + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", statusCode="success", userIndex=1}, {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_aliro.lua b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_aliro.lua new file mode 100644 index 0000000000..250fcb8af5 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_aliro.lua @@ -0,0 +1,1263 @@ +-- Copyright 2023 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local FIXTURE_DER_B64 = + "MHcCAQEEIHlEZiE0cRiQ+Jp+RAGQ/Rq8miEBQXfRQNeSlyNR0Cv1oAoGCCqGSM49AwEH" .. + "oUQDQgAEeBuCTahXpt/rGLvVnOxjTlbmNYdKQF3vlHZYMK/LtISNUXJsJ1BfBX9nGgwY" .. + "WlJ775K5woV3zzMW7X4dVV5C7Q==" +local EXPECTED_PRIV_HEX = "7944662134711890f89a7e440190fd1abc9a21014177d140d792972351d02bf5" +local EXPECTED_PUB_HEX = "04781b824da857a6dfeb18bbd59cec634e56e635874a405def94765830afcbb48" .. + "48d51726c27505f057f671a0c185a527bef92b9c28577cf3316ed7e1d555e42ed" +local EXPECTED_GROUP_ID_HEX = "64c8cce93255c4478d7aa05d83f3eaa2" + +local base64 = require "base64" +local function default_generate_self_signed_cert(_opts) + local der = base64.decode(FIXTURE_DER_B64) + return { + cert_pem = "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----", + key_pem = "-----BEGIN EC PRIVATE KEY-----\nfake\n-----END EC PRIVATE KEY-----", + cert_der = der, + key_der = der, + } +end +local security_stub = { + generate_self_signed_cert = default_generate_self_signed_cert, +} +package.loaded["st.security"] = security_stub + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local DoorLock = clusters.DoorLock +local OctetString1 = require "st.matter.data_types.OctetString1" +local lock_utils = require "lock_utils" +lock_utils.create_group_id_resolving_key = function() + return EXPECTED_GROUP_ID_HEX +end + +local key_id = "vTNt0oPoHvIvwGMHa3AuXE3ZcY+Oocv5KZ+R0yveEag=" +local endpoint_key = "041a748a78566aaee985d9141730fa72bd83bf34e7b93072a0ca7b56a79b6debac9493eded05a65701b5148517bd49a6c91c78ed6811543491eff1d257280ed809" +local request_id = "1f3acdf6-8930-45f7-ae3d-f0b47851c3e2" + +local enabled_optional_component_capability_pairs = {{ + "main", + { + capabilities.lockUsers.ID, + capabilities.lockSchedules.ID, + capabilities.lockAliro.ID + } +}} +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition( + "lock-modular.yml", + {enabled_optional_capabilities = enabled_optional_component_capability_pairs} + ), + manufacturer_info = { + vendor_id = 0x135D, + product_id = 0x00C1, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x2510, -- WDSCH & YDSCH & USR & ALIRO + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} +local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) +subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroReaderVerificationKey:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroReaderGroupIdentifier:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroReaderGroupSubIdentifier:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroGroupResolvingKey:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroBLEAdvertisingVersion:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfAliroEndpointKeysSupported:subscribe(mock_device)) +subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) +subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle received AliroReaderVerificationKey from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroReaderVerificationKey:build_test_report_data( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_PUB_HEX) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.readerVerificationKey(EXPECTED_PUB_HEX, {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle received AliroReaderGroupIdentifier from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroReaderGroupIdentifier:build_test_report_data( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.readerGroupIdentifier(EXPECTED_GROUP_ID_HEX, {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle received AliroExpeditedTransactionSupportedProtocolVersions from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions:build_test_report_data( + mock_device, 1, + {OctetString1("\x00\x09"), OctetString1("\x01\x00")} + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.expeditedTransactionProtocolVersions( + {"0.9", "1.0"}, + {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle received AliroSupportedBLEUWBProtocolVersions from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions:build_test_report_data( + mock_device, 1, + {OctetString1("\x00\x09"), OctetString1("\x01\x00")} + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.bleUWBProtocolVersions( + {"0.9", "1.0"}, + {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle received AliroReaderVerificationKey from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported:build_test_report_data( + mock_device, 1, + 35 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.maxCredentialIssuerKeys( + 35, + {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle received AliroGroupResolvingKey from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroGroupResolvingKey:build_test_report_data( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.groupResolvingKey(EXPECTED_GROUP_ID_HEX, {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle received AliroBLEAdvertisingVersion from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroBLEAdvertisingVersion:build_test_report_data( + mock_device, 1, + 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.bleAdvertisingVersion( + "1", + {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle received NumberOfAliroEndpointKeysSupported from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfAliroEndpointKeysSupported:build_test_report_data( + mock_device, 1, + 10 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.maxEndpointKeys( + 10, + {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle Set Card Id command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setCardId", + args = {"3icub18c8pr00"} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.cardId("3icub18c8pr00", {visibility = {displayed = false}}) + ) + ) + end +) + +test.register_coroutine_test( + "Handle Set Reader Config command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setReaderConfig", + args = { + EXPECTED_PRIV_HEX, + EXPECTED_PUB_HEX, + EXPECTED_GROUP_ID_HEX, + nil + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetAliroReaderConfig( + mock_device, 1, -- endpoint + lock_utils.hex_string_to_octet_string(EXPECTED_PRIV_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_PUB_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX), + nil + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetAliroReaderConfig:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS -- status + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + {commandName="setReaderConfig", statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command and Clear Endpoint Key command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex=1, userType="adminMember"}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials( + {{ + keyId=key_id, + keyIndex=1, + keyType="nonEvictableEndpointKey", + userIndex=1 + }}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "clearEndpointKey", + args = {1, key_id, "nonEvictableEndpointKey"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.ClearCredential( + mock_device, 1, -- endpoint + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, credential_index = 1} + ) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearCredential:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="clearEndpointKey", + keyId=key_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and busy status", + function() + lock_utils.is_busy_state_set(mock_device) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="busy" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and user_index is occupied", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 2 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and user_index is occupied and next_credential_index is nil", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + nil -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="resourceExhausted" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and user_index is failure", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="failure" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command and Clear Issuer Key command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex=1, userType="adminMember"}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials( + {{ + keyIndex=1, + keyType="issuerKey", + userIndex=1 + }}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "clearIssuerKey", + args = {1, request_id} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.ClearCredential( + mock_device, 1, -- endpoint + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, credential_index = 1} + ) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearCredential:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="clearIssuerKey", + requestId=request_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and busy status", + function() + lock_utils.is_busy_state_set(mock_device) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="busy" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and user_index is occupied", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 2 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and user_index is occupied and next_credential_index is nil", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + nil -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="resourceExhausted" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and user_index is failure", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="failure" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "set_reader_config should send SetAliroReaderConfig command on device init", + function() + mock_device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, nil, {persist = true}) + mock_device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("lock.yml")})) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetAliroReaderConfig( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_PRIV_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_PUB_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX), + nil + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) + ) + end +) + +test.register_coroutine_test( + "Set Reader Config command sets busy state in command result when busy", + function() + mock_device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, nil, {persist = true}) + lock_utils.is_busy_state_set(mock_device) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("lock.yml")})) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setReaderConfig", + statusCode="busy" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua new file mode 100644 index 0000000000..8ec2a2a781 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua @@ -0,0 +1,849 @@ +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +test.set_rpc_version(0) +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" +local cluster_base = require "st.matter.cluster_base" +local DoorLock = clusters.DoorLock + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_unlatch = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1000, -- UNLATCH + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_user_pin = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-user-pin.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0181, -- PIN & USR & COTA + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_user_pin_schedule_unlatch = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-user-pin-schedule-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1591, -- PIN & USR & COTA & WDSCH & YDSCH & UNLATCH + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_battery = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_battery_level = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-batteryLevel.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + 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 = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} +local function test_init() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_unlatch() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_unlatch) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_unlatch)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_unlatch)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_unlatch) + test.socket.matter:__expect_send({mock_device_unlatch.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "added" }) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "init" }) + test.socket.matter:__expect_send({mock_device_unlatch.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "doConfigure" }) + mock_device_unlatch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_user_pin() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_user_pin) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin)) + subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_user_pin) + test.socket.matter:__expect_send({mock_device_user_pin.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "added" }) + test.socket.capability:__expect_send( + mock_device_user_pin:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "init" }) + test.socket.matter:__expect_send({mock_device_user_pin.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "doConfigure" }) + mock_device_user_pin:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_user_pin_schedule_unlatch() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_user_pin_schedule_unlatch) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin_schedule_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin_schedule_unlatch)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_user_pin_schedule_unlatch) + test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "added" }) + test.socket.capability:__expect_send( + mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "init" }) + test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "doConfigure" }) + mock_device_user_pin_schedule_unlatch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_battery() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_battery) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_battery)) + subscribe_request:merge(cluster_base.subscribe(mock_device_battery, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_battery)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_battery)) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device_battery)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_battery)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_battery) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_battery_level() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_battery_level) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_battery_level)) + subscribe_request:merge(cluster_base.subscribe(mock_device_battery_level, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_battery_level)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_battery_level)) + subscribe_request:merge(clusters.PowerSource.attributes.BatChargeLevel:subscribe(mock_device_battery_level)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_battery_level)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_battery_level) + test.socket.matter:__expect_send({mock_device_battery_level.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_battery_level.id, "added" }) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery_level.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery_level.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery_level.id, "doConfigure" }) + mock_device_battery_level:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test lock profile change when attributes related to BAT feature is not available.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock profile change when BatChargeLevel attribute is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-batteryLevel" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock profile change when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-battery" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-unlatch profile change when attributes related to BAT feature is not available.", + function() + test.socket.matter:__queue_receive( + { + mock_device_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_unlatch:expect_metadata_update({ profile = "lock-unlatch" }) + end, + { + test_init = test_init_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-unlatch profile change when BatChargeLevel attribute is available", + function() + test.socket.matter:__queue_receive( + { + mock_device_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_unlatch:expect_metadata_update({ profile = "lock-unlatch-batteryLevel" }) + end, + { + test_init = test_init_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-unlatch profile change when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_unlatch:expect_metadata_update({ profile = "lock-unlatch-battery" }) + end, + { + test_init = test_init_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-user-pin profile change when attributes related to BAT feature is not available.", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin:expect_metadata_update({ profile = "lock-user-pin" }) + end, + { + test_init = test_init_user_pin, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-user-pin profile change when BatChargeLevel attribute is available", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin:expect_metadata_update({ profile = "lock-user-pin-batteryLevel" }) + end, + { + test_init = test_init_user_pin, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-user-pin profile change when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin:expect_metadata_update({ profile = "lock-user-pin-battery" }) + end, + { + test_init = test_init_user_pin, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-user-pin-schedule-unlatch profile change when attributes related to BAT feature is not available.", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin_schedule_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin_schedule_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-user-pin-schedule-unlatch" }) + end, + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-user-pin-schedule-unlatch profile change when BatChargeLevel attribute is available", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin_schedule_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin_schedule_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-user-pin-schedule-unlatch-batteryLevel" }) + end, + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test lock-user-pin-schedule-unlatch profile change when BatChargeLevel and BatPercentRemaining attributes are available", + function() + test.socket.matter:__queue_receive( + { + mock_device_user_pin_schedule_unlatch.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_user_pin_schedule_unlatch, 1, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), -- BatPercentRemaining + uint32(14), -- BatChargeLevel + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + test.socket.capability:__expect_send( + mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-user-pin-schedule-unlatch-battery" }) + end, + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received BatPercentRemaining from device.", + function() + test.socket.matter:__queue_receive( + { + mock_device_battery.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device_battery, 1, 150 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) + ) + end, + { + test_init = test_init_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received BatChargeLevel from device.", + function() + test.socket.matter:__queue_receive( + { + mock_device_battery_level.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device_battery_level, 1, clusters.PowerSource.types.BatChargeLevelEnum.OK + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.batteryLevel.battery.normal()) + ) + test.socket.matter:__queue_receive( + { + mock_device_battery_level.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device_battery_level, 1, clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.batteryLevel.battery.critical()) + ) + test.socket.matter:__queue_receive( + { + mock_device_battery_level.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device_battery_level, 1, clusters.PowerSource.types.BatChargeLevelEnum.WARNING + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.batteryLevel.battery.warning()) + ) + end, + { + test_init = test_init_battery_level, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/can_handle.lua b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/can_handle.lua new file mode 100644 index 0000000000..3da6b948bf --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +local function is_unmigrated_matter_lock_product(opts, driver, device) + local is_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME) or nil + if device:supports_capability(capabilities.lockAlarm) then + return false + elseif device:supports_capability(capabilities.lockCodes) and is_migrated == true then + return false + else + return true, require("unmigrated-matter-lock") + end +end + +return is_unmigrated_matter_lock_product diff --git a/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/init.lua b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/init.lua new file mode 100644 index 0000000000..6e7d55d895 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/init.lua @@ -0,0 +1,758 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local DoorLock = clusters.DoorLock +local PowerSource = clusters.PowerSource +local capabilities = require "st.capabilities" +local im = require "st.matter.interaction_model" +local lock_utils = require "lock_utils" + +local PROFILE_BASE_NAME = "__profile_base_name" +local INITIAL_COTA_INDEX = 1 +-- add this definition for locks to work on older lua libs +local UNLATCHED_STATE = 0x3 + +local subscribed_attributes = { + [capabilities.lock.ID] = { + DoorLock.attributes.LockState + }, + [capabilities.battery.ID] = { + PowerSource.attributes.BatPercentRemaining + }, + [capabilities.batteryLevel.ID] = { + PowerSource.attributes.BatChargeLevel + } +} + +--- If a device needs a cota credential this function attempts to set the credential +--- at the index provided. The set_credential_response_handler handles all failures +--- and retries with the appropriate index when necessary. +local function set_cota_credential(device, credential_index) + local eps = device:get_endpoints(DoorLock.ID) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred == nil then + -- Shouldn't happen but defensive to try to figure out if we need the cota cred and set it. + device:send(DoorLock.attributes.RequirePINforRemoteOperation:read(device, #eps > 0 and eps[1] or 1)) + return + elseif cota_cred == false then + device.log.debug("Device does not require PIN for remote operation. Not setting COTA credential") + return + end + + if device:get_field(lock_utils.SET_CREDENTIAL) ~= nil then + device.log.debug("delaying setting COTA credential since a credential is currently being set") + device.thread:call_with_delay(2, function(t) + set_cota_credential(device, credential_index) + end) + return + end + + device:set_field(lock_utils.COTA_CRED_INDEX, credential_index, {persist = true}) + local credential = {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = credential_index} + -- Set the credential to a code + device:set_field(lock_utils.SET_CREDENTIAL, credential_index) + device.log.info(string.format("Attempting to set COTA credential at index %s", credential_index)) + device:send(DoorLock.server.commands.SetCredential( + device, + #eps > 0 and eps[1] or 1, + DoorLock.types.DlDataOperationType.ADD, + credential, + device:get_field(lock_utils.COTA_CRED), + nil, -- nil user_index creates a new user + DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, + DoorLock.types.DlUserType.REMOTE_ONLY_USER + )) +end + +local function generate_cota_cred_for_device(device) + local len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) or 4 + local cred_data = math.floor(math.random() * (10 ^ len)) + cred_data = string.format("%0" .. tostring(len) .. "d", cred_data) + device:set_field(lock_utils.COTA_CRED, cred_data, {persist = true}) +end + +local function lock_state_handler(driver, device, ib, response) + local LockState = DoorLock.attributes.LockState + local attr = capabilities.lock.lock + local LOCK_STATE = { + [LockState.NOT_FULLY_LOCKED] = attr.not_fully_locked(), + [LockState.LOCKED] = attr.locked(), + [LockState.UNLOCKED] = attr.unlocked(), + [UNLATCHED_STATE] = attr.unlocked(), -- Fully unlocked with latch pulled + } + + if ib.data.value ~= nil then + local event = LOCK_STATE[ib.data.value] + if event ~= nil then + device:emit_event(event) + else + device.log.warn(string.format("Received unknown lock state value (%s), emitting unknown", ib.data.value)) + device:emit_event(attr.unknown()) + end + else + device:emit_event(LOCK_STATE[LockState.NOT_FULLY_LOCKED]) + end +end + +local function handle_battery_percent_remaining(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +local function handle_battery_charge_level(driver, device, ib, response) + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +local function handle_power_source_attribute_list(driver, device, ib, response) + local support_battery_percentage = false + local support_battery_level = false + for _, attr in ipairs(ib.data.elements) do + -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or + -- BatChargeLevel (Attribute ID 0x0E) is present. + if attr.value == 0x0C then + support_battery_percentage = true + elseif attr.value == 0x0E then + support_battery_level = true + end + end + local profile_name = device:get_field(PROFILE_BASE_NAME) + if profile_name ~= nil then + if not support_battery_percentage then + if support_battery_level then + profile_name = profile_name .. "-batteryLevel" + else + profile_name = profile_name .. "-nobattery" + end + end + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + end +end + +local function max_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCodes.maxCodeLength(ib.data.value, {visibility = {displayed = false}})) +end + +local function min_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCodes.minCodeLength(ib.data.value, {visibility = {displayed = false}})) +end + +local function num_pin_users_handler(driver, device, ib, response) + device:set_field(lock_utils.TOTAL_PIN_USERS, ib.data.value) + device:emit_event(capabilities.lockCodes.maxCodes(ib.data.value, {visibility = {displayed = false}})) +end + +local function apply_cota_credentials_if_absent(device) + if not device:get_field(lock_utils.COTA_CRED) then + --Process after all other info blocks have been dispatched to ensure MaxPINCodeLength has been processed + device.thread:call_with_delay(0, function(t) + generate_cota_cred_for_device(device) + -- delay needed to allow test to override the random credential data + device.thread:call_with_delay(0, function(t) + -- Attempt to set cota credential at the lowest index + set_cota_credential(device, INITIAL_COTA_INDEX) + end) + end) + end +end + +local function require_remote_pin_handler(driver, device, ib, response) + if ib.data.value then + apply_cota_credentials_if_absent(device) + else + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + end +end + +local function clear_credential_response_handler(driver, device, ib, response) + local deleted_code_slot = device:get_field(lock_utils.DELETING_CODE) + if deleted_code_slot == nil and ib.status == im.InteractionResponse.Status.SUCCESS then + device.log.debug("Cleared space in lock credential db for COTA credential") + return + end + if ib.status == im.InteractionResponse.Status.SUCCESS then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(deleted_code_slot))) + --make sure cota credential exists if the user deletes it or if space was created for the COTA cred + if deleted_code_slot == device:get_field(lock_utils.COTA_CRED_INDEX) or + device:get_field(lock_utils.NONFUNCTIONAL) then + set_cota_credential(device, device:get_field(lock_utils.COTA_CRED_INDEX) or INITIAL_COTA_INDEX) + end + else + device.log.error(string.format("Failed to delete code slot %s", deleted_code_slot)) + end + device:set_field(lock_utils.DELETING_CODE, nil) +end + +local function set_credential_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.error("Failed to set code for device") + return + end + local elements = ib.info_block.data.elements + local credential_index = device:get_field(lock_utils.SET_CREDENTIAL) + device:set_field(lock_utils.SET_CREDENTIAL, nil) + if credential_index == nil then + device.log.error("Received unexpected SetCredentialResponse") + return + end + local code_slot = tostring(credential_index) + local status = elements.status.value + if status == DoorLock.types.DlStatus.SUCCESS then + local event = capabilities.lockCodes.codeChanged("", {state_change = true}) + local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) + local code_name = (credential_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or + lock_utils.get_code_name(device, code_slot) + event.data = {codeName = code_name} + event.value = lock_utils.get_change_type(device, tostring(code_slot)) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + if credential_index == cota_cred_index then + device:emit_event( + capabilities.lockCodes.codeChanged( + code_slot .. " renamed", {state_change = true} + ) + ) + end + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + if device:get_field(lock_utils.NONFUNCTIONAL) and cota_cred_index == credential_index then + device.log.info("Successfully set COTA credential after being non-functional") + device:set_field(lock_utils.NONFUNCTIONAL, false, {persist = true}) + local profile_name = "base-lock" + device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps == 0 then + profile_name = profile_name .. "-nobattery" + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name, provisioning_state = "PROVISIONED"}) + else + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(req) + end + end + elseif device:get_field(lock_utils.COTA_CRED) and credential_index == device:get_field(lock_utils.COTA_CRED_INDEX) then + -- Handle failure to set a COTA credential + if status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value ~= nil then + --This credential index is unavailable, but there is another available + set_cota_credential(device, elements.next_credential_index.value) + elseif status == DoorLock.types.DlStatus.OCCUPIED and + elements.next_credential_index.value == nil and + credential_index == INITIAL_COTA_INDEX then + --There are no credential indices available on the device + device.log.error("Device requires COTA credential, but has no credential indexes available!") + device.log.error("Lock and Unlock commands will no longer work!!") + device:set_field(PROFILE_BASE_NAME, "nonfunctional-lock", {persist = true}) + device:try_update_metadata({profile = "nonfunctional-lock", provisioning_state = "NONFUNCTIONAL"}) + device:set_field(lock_utils.NONFUNCTIONAL, true, {persist = true}) + elseif status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value == nil then + --There are no credential indices available, but we must ensure we search all indices. + set_cota_credential(device, INITIAL_COTA_INDEX) + elseif status == DoorLock.types.DlStatus.DUPLICATE then + --The credential we randomly generated already exists + generate_cota_cred_for_device(device) + --delay 0 needed for unit test verification of random value + device.thread:call_with_delay(0, function(t) set_cota_credential(device, credential_index) end) + elseif status == DoorLock.types.DlStatus.INVALID_FIELD then + device.log.error("Invalid SetCredential command sent to set a COTA credential. This is a bug.") + elseif elements.next_credential_index.value ~= nil then + device.log.warn(string.format( + "Received non-success SetCredentialResponse status (%s), but there is a next credential index available", elements.status + )) + set_cota_credential(device, elements.next_credential_index.value) + end + else + device.log.error( + string.format( + "Failed to set user code for device, SetCredential status received: %s", elements.status + ) + ) + end +end + +local function get_credential_status_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.warn("Not taking action on GetCredentialStatusResponse because failed status") + end + local cred_index = device:get_field(lock_utils.CHECKING_CREDENTIAL) + if cred_index == nil then + device.log.warn("Received unexpected CredentialStatusResponse") + return + end + local elements = ib.info_block.data.elements + local credential_exists = elements.credential_exists.value + local next_credential_index = elements.next_credential_index and elements.next_credential_index.value or nil + + local event = capabilities.lockCodes.codeChanged("", {state_change = true}) + local code_slot = tostring(cred_index) + local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) + local code_name = (cred_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) + event.data = {codeName = code_name} + if credential_exists then + -- Code slot is occupied + event.value = lock_utils.get_change_type(device, code_slot) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + else + -- Code slot is unoccupied + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + -- Code has been deleted + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + if cred_index == cota_cred_index then --make sure cota credential exists if it was deleted + set_cota_credential(device, INITIAL_COTA_INDEX) + end + else + -- Code is unset + event.value = code_slot .. " unset" + device:emit_event(event) + end + end + device:set_field(lock_utils.CHECKING_CREDENTIAL, nil) + + local is_scanning = device:get_latest_state( + "main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME + ) == "Scanning" + if not is_scanning then + return + end + if (next_credential_index == nil) then + device:emit_event( + capabilities.lockCodes.scanCodes( + "Complete", {visibility = {displayed = false}} + ) + ) + local lock_codes = lock_utils.get_lock_codes(device) + lock_utils.lock_codes_event(device, lock_codes) + elseif next_credential_index ~= nil then + device:set_field(lock_utils.CHECKING_CREDENTIAL, next_credential_index) + device:send( + DoorLock.server.commands.GetCredentialStatus( + device, + ib.info_block.endpoint_id, + {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} + ) + ) + end +end + +local function alarm_event_handler(driver, device, ib, response) + local DlAlarmCode = DoorLock.types.DlAlarmCode + local alarm_code = ib.data.elements.alarm_code + if alarm_code.value == DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED or alarm_code.value + == DlAlarmCode.WRONG_CODE_ENTRY_LIMIT or alarm_code.value == DlAlarmCode.FORCED_USER + or alarm_code.value == DlAlarmCode.DOOR_FORCED_OPEN then + device:emit_event(capabilities.tamperAlert.tamper.detected()) + end +end + +local function lock_op_event_handler(driver, device, ib, response) + local tamper_detected = device:get_latest_state( + device:endpoint_to_component(ib.endpoint_id), + capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME + ) + if nil == tamper_detected or tamper_detected == capabilities.tamperAlert.tamper.detected.NAME then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function lock_user_change_event_handler(driver, device, ib, response) + local event = capabilities.lockCodes.codeChanged("", {state_change = true}) + local elements = ib.data.elements + local data_type_changed = elements.lock_data_type.value + local operation_type = elements.data_operation_type.value + local user_index = elements.user_index.value + local data_index = elements.data_index and elements.data_index.value + local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) + + if data_type_changed == DoorLock.types.DlLockDataType.PIN then -- pin added or removed + local code_slot = data_index and tostring(data_index) or nil + if (operation_type == DoorLock.types.DlDataOperationType.ADD or operation_type + == DoorLock.types.DlDataOperationType.MODIFY) and code_slot ~= nil then + local change_type = lock_utils.get_change_type(device, code_slot) + event.value = change_type + local code_name = (data_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) + event.data = {codeName = code_name} + device:emit_event(event) + if string.match(change_type, "%d+ set") ~= nil then + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + end + elseif operation_type == DoorLock.types.DlDataOperationType.CLEAR and code_slot ~= nil then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(code_slot))) + --make sure cota credential is created if the user deletes it or a space is made for it + if data_index == cota_cred_index or device:get_field(lock_utils.NONFUNCTIONAL) then + set_cota_credential(device, cota_cred_index or INITIAL_COTA_INDEX) + end + else -- invalid event because no credential index + device.log.error( + "Received unhandled LockUserChangeEvent because it didn't affect a PIN credential" + ) + end + elseif data_type_changed == DoorLock.types.DlLockDataType.USER_INDEX and operation_type + == DoorLock.types.DlDataOperationType.CLEAR then + if user_index == 0xFFFE then + device.log.warn("All users were cleared by another fabric") -- we never do this + for cs, _ in pairs(lock_utils.get_lock_codes(device)) do + lock_utils.code_deleted(device, cs) + end + lock_utils.lock_codes_event(device, {}) + if device:get_field(lock_utils.COTA_CRED) ~= nil then set_cota_credential(device, INITIAL_COTA_INDEX) end + else + device.log.info("Not handling LockUserChange event") + end + -- Note when a Lock User is deleted, the credentials associated with that user are also deleted. + -- Change events are created for each credential as well as the user. + else + device.log.info( + string.format( + "Not handling LockUserChange event because the data type (%s) doesn't affect lock codes", + elements.lock_data_type + ) + ) + end +end + +local function handle_refresh(driver, device, command) + -- Note: no endpoint specified indicates a wildcard endpoint + local req = DoorLock.attributes.LockState:read(device) + req:merge(PowerSource.attributes.BatPercentRemaining:read(device)) + device:send(req) +end + +local function handle_lock(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.LockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.LockDoor(device, ep)) + end +end + +local function handle_unlock(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + end +end + +local function handle_delete_code(driver, device, command) + local endpoint = device:component_to_endpoint(command.component) + device:set_field(lock_utils.DELETING_CODE, command.args.codeSlot) + device:send(DoorLock.server.commands.ClearCredential( + device, + endpoint, + {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = command.args.codeSlot} + )) +end + +local function handle_reload_all_codes(driver, device, command) + if (device:get_field(lock_utils.CHECKING_CREDENTIAL) == nil) then + lock_utils.lock_codes_event(device, {}) + device:set_field(lock_utils.CHECKING_CREDENTIAL, 1) + else + device.log.info(string.format("Delaying scanning since currently checking credential %d", device:get_field(lock_utils.CHECKING_CREDENTIAL))) + device.thread:call_with_delay(2, function(t) handle_reload_all_codes(driver, device, command) end) + return + end + device.thread:call_with_delay(5, function(t) + device:emit_event(capabilities.lockCodes.scanCodes("Scanning")) + device:send( + clusters.DoorLock.server.commands.GetCredentialStatus( + device, device:component_to_endpoint(command.component), + {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} + ) + ) + end) +end + +local function handle_request_code(driver, device, command) + local endpoint_id = device:component_to_endpoint(command.component) + device:set_field(lock_utils.CHECKING_CREDENTIAL, command.args.codeSlot) + local credential = { + credential_type = DoorLock.types.DlCredentialType.PIN, + credential_index = command.args.codeSlot, + } + device:send(clusters.DoorLock.server.commands.GetCredentialStatus(device, endpoint_id, credential)) +end + +local function handle_set_code(driver, device, command) + local endpoint = device:component_to_endpoint(command.component) + if (command.args.codePIN == "") then + driver:inject_capability_command( + device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = {command.args.codeSlot, command.args.codeName}, + } + ) + else + local credential = { + credential_type = DoorLock.types.DlCredentialType.PIN, + credential_index = command.args.codeSlot, + } + device:set_field(lock_utils.SET_CREDENTIAL, command.args.codeSlot) + device:send( + DoorLock.server.commands.SetCredential( + device, endpoint, DoorLock.types.DlDataOperationType.ADD, -- operation_type + credential, command.args.codePIN, -- credential_data + nil, -- nil user_index creates a new user + DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, DoorLock.types.DlUserType.UNRESTRICTED_USER + ) + ) + if (command.args.codeName ~= nil) then + -- wait for confirmation from the lock to commit this to memory + -- Groovy driver has a lot more info passed here as a description string, may need to be investigated + local codeState = device:get_field(lock_utils.CODE_STATE) or {} + codeState["setName" .. command.args.codeSlot] = command.args.codeName + device:set_field(lock_utils.CODE_STATE, codeState, {persist = true}) + end + end +end + +local function handle_name_slot(driver, device, command) + local code_slot = tostring(command.args.codeSlot) + local lock_codes = lock_utils.get_lock_codes(device) + if (lock_codes[code_slot] ~= nil) then + lock_codes[code_slot] = command.args.codeName + device:emit_event( + capabilities.lockCodes.codeChanged( + code_slot .. " renamed", {state_change = true} + ) + ) + lock_utils.lock_codes_event(device, lock_codes) + end +end + +local function json_to_table(input) + if #input == 0 then return end + local result = {} + for key, value in string.gmatch(input, '"(.-)"%s*:%s*"(.-)"') do + table.insert(result, {key, value}) + end + return result +end + +local function store_lock_codes(device) + local lockCodes = device:get_latest_state( + "main", + capabilities.lockCodes.ID, + capabilities.lockCodes.lockCodes.NAME, + {} + ) + lockCodes = json_to_table(lockCodes) + device:set_field(lock_utils.LOCK_CODES_FOR_MIGRATION, lockCodes, {persist = true}) +end + +local function handle_migrate(driver, device, command) + store_lock_codes(device) + device:set_field(lock_utils.LOCK_CODES_COPY_REQUIRED, true, {persist = true}) + device:emit_event(capabilities.lockCodes.migrated(true)) + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) +end + +local function find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = device:get_endpoints(cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return res +end + +local function component_to_endpoint(device, component_name) + return find_default_endpoint(device, clusters.DoorLock.ID) +end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() + end +end + +local function do_configure(driver, device) + -- check if the device is NOT currently profiled as base-lock + -- by ANDing a query for every capability in the base-lock profiles. + -- If it does not use base-lock, it is WWST and does not need re-profiling. + if not (device:supports_capability(capabilities.lock) and + device:supports_capability(capabilities.lockCodes) and + device:supports_capability(capabilities.tamperAlert) and + device:supports_capability(capabilities.battery)) then + return + end + + -- if not fingerprinted, dynamically configure base-lock profile + local profile_name = "base-lock" + device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps == 0 then + profile_name = profile_name .. "-nobattery" + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + else + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(req) + end +end + +local function device_init(driver, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:subscribe() + + -- check if we have a missing COTA credential. Only run this if it has not been run before (i.e. in device added), + -- because there is a delay built into the COTA process and we do not want to start two COTA generations at the same time + -- in the event this was triggered on add. + if not device:get_field(lock_utils.COTA_READ_INITIALIZED) or not device:get_field(lock_utils.COTA_CRED) then + local eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.CREDENTIALSOTA | DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) + if #eps == 0 then + device.log.debug("Device will not require PIN for remote operation") + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + else + device:send(DoorLock.attributes.RequirePINforRemoteOperation:read(device, eps[1])) + device:set_field(lock_utils.COTA_READ_INITIALIZED, true, {persist = true}) + end + end +end + +local function device_added(driver, device) + --Note: May want to write OperatingMode to NORMAL, to attempt to ensure remote operation works + --Note: May want to write RequirePINForRemoteOperation, to avoid cota cases if possible. + device:emit_event(capabilities.tamperAlert.tamper.clear()) + local eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) + if #eps == 0 then + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device.log.debug("Device does not support lockCodes. Switching profile.") + local profile_name = "lock-without-codes" + device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps == 0 then + profile_name = profile_name .. "-nobattery" + end + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + else + device.log.debug("Device supports neither lock codes nor tamper. Unable to switch profile.") + end + else + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(DoorLock.attributes.MaxPINCodeLength:read(device, eps[1])) + req:merge(DoorLock.attributes.MinPINCodeLength:read(device, eps[1])) + req:merge(DoorLock.attributes.NumberOfPINUsersSupported:read(device, eps[1])) + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + + --Device may require pin for remote operation if it supports COTA and PIN features. + eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.CREDENTIALSOTA | DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) + if #eps == 0 then + device.log.debug("Device will not require PIN for remote operation") + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + else + req:merge(DoorLock.attributes.RequirePINforRemoteOperation:read(device, eps[1])) + device:set_field(lock_utils.COTA_READ_INITIALIZED, true, {persist = true}) + end + device:send(req) + end +end + +local unmigrated_matter_lock_driver = { + NAME = "Unmigrated Matter Lock Driver", + matter_handlers = { + attr = { + [DoorLock.ID] = { + [DoorLock.attributes.LockState.ID] = lock_state_handler, + [DoorLock.attributes.MaxPINCodeLength.ID] = max_pin_code_len_handler, + [DoorLock.attributes.MinPINCodeLength.ID] = min_pin_code_len_handler, + [DoorLock.attributes.NumberOfPINUsersSupported.ID] = num_pin_users_handler, + [DoorLock.attributes.RequirePINforRemoteOperation.ID] = require_remote_pin_handler, + }, + [PowerSource.ID] = { + [PowerSource.attributes.AttributeList.ID] = handle_power_source_attribute_list, + [PowerSource.attributes.BatPercentRemaining.ID] = handle_battery_percent_remaining, + [PowerSource.attributes.BatChargeLevel.ID] = handle_battery_charge_level, + }, + }, + event = { + [DoorLock.ID] = { + [DoorLock.events.DoorLockAlarm.ID] = alarm_event_handler, + [DoorLock.events.LockOperation.ID] = lock_op_event_handler, + [DoorLock.events.LockUserChange.ID] = lock_user_change_event_handler, + }, + }, + cmd_response = { + [DoorLock.ID] = { + [DoorLock.client.commands.SetCredentialResponse.ID] = set_credential_response_handler, + [DoorLock.client.commands.GetCredentialStatusResponse.ID] = get_credential_status_response_handler, + [DoorLock.server.commands.ClearCredential.ID] = clear_credential_response_handler, + }, + }, + }, + subscribed_attributes = subscribed_attributes, + subscribed_events = { + [capabilities.tamperAlert.ID] = {DoorLock.events.DoorLockAlarm, DoorLock.events.LockOperation}, + [capabilities.lockAlarm.ID] = {DoorLock.events.DoorLockAlarm}, + [capabilities.lockCodes.ID] = {DoorLock.events.LockUserChange}, + }, + capability_handlers = { + [capabilities.refresh.ID] = {[capabilities.refresh.commands.refresh.NAME] = handle_refresh}, + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = handle_lock, + [capabilities.lock.commands.unlock.NAME] = handle_unlock, + }, + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.deleteCode.NAME] = handle_delete_code, + [capabilities.lockCodes.commands.reloadAllCodes.NAME] = handle_reload_all_codes, + [capabilities.lockCodes.commands.requestCode.NAME] = handle_request_code, + [capabilities.lockCodes.commands.setCode.NAME] = handle_set_code, + [capabilities.lockCodes.commands.nameSlot.NAME] = handle_name_slot, + [capabilities.lockCodes.commands.migrate.NAME] = handle_migrate, + }, + }, + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.tamperAlert, + capabilities.battery, + capabilities.batteryLevel, + }, + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + infoChanged = info_changed, + }, + can_handle = require("unmigrated-matter-lock.can_handle"), +} + +return unmigrated_matter_lock_driver diff --git a/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua b/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua index 5444652b29..b795f6438a 100644 --- a/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua +++ b/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua @@ -15,7 +15,6 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" - local clusters = require "st.matter.clusters" local mock_device = test.mock_device.build_test_matter_device({ @@ -49,286 +48,295 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) - local function test_init() - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel - } - test.socket.matter:__set_channel_ordering("relaxed") - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.disable_startup_messages() test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device) + subscribe_request:merge(clusters.LevelControl.attributes.CurrentLevel:subscribe(mock_device)) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.set_test_init_function(test_init) test.register_message_test( - "Mute and unmute commands should send the appropriate commands", + "Mute and unmute commands should send the appropriate commands", + { { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioMute", component = "main", command = "mute", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.Off(mock_device, 10) - } - }, - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioMute", component = "main", command = "unmute", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, 10) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 10, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.audioMute.mute.unmuted()) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 10, false) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.audioMute.mute.muted()) - } + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioMute", component = "main", command = "mute", args = { } } } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, 10) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioMute", component = "main", command = "unmute", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, 10) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 10, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.audioMute.mute.unmuted()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 10, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.audioMute.mute.muted()) + } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Set mute command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioMute", component = "main", command = "setMute", args = { "muted" } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.Off(mock_device, 10) - } - }, - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioMute", component = "main", command = "setMute", args = { "unmuted" } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, 10) - } - } + "Set mute command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioMute", component = "main", command = "setMute", args = { "muted" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, 10) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioMute", component = "main", command = "setMute", args = { "unmuted" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, 10) + } } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Set volume command should send the appropriate commands", + "Set volume command should send the appropriate commands", + { { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioVolume", component = "main", command = "setVolume", args = { 20 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(20/100.0 * 254), 0, 0, 0) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) - } + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioVolume", component = "main", command = "setVolume", args = { 20 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(20/100.0 * 254), 0, 0, 0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 50) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Volume up/down command should send the appropriate commands", + "Volume up/down command should send the appropriate commands", + { { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioVolume", component = "main", command = "setVolume", args = { 20 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(20/100.0 * 254), 0, 0, 0) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 50 ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) - }, - -- volume up - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioVolume", component = "main", command = "volumeUp", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(25/100.0 * 254), 0, 0, 0) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 63 ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(25)) - }, - -- volume down - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "audioVolume", component = "main", command = "volumeDown", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(20/100.0 * 254), 0, 0, 0) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 50 ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) - }, - } + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioVolume", component = "main", command = "setVolume", args = { 20 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(20/100.0 * 254), 0, 0, 0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 50 ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) + }, + -- volume up + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioVolume", component = "main", command = "volumeUp", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(25/100.0 * 254), 0, 0, 0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 63 ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(25)) + }, + -- volume down + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "audioVolume", component = "main", command = "volumeDown", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 10, math.floor(20/100.0 * 254), 0, 0, 0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 10) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 50 ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) + }, + }, + { + min_api_version = 17 + } ) local function refresh_commands(dev) @@ -338,25 +346,27 @@ local function refresh_commands(dev) end test.register_message_test( - "Handle received refresh.", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "refresh", component = "main", command = "refresh", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - refresh_commands(mock_device) - } - }, - } + "Handle received refresh.", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + refresh_commands(mock_device) + } + }, + }, + { + min_api_version = 17 + } ) - test.run_registered_tests() diff --git a/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua b/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua index 52cde71ed3..dc3a4e4c08 100644 --- a/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua +++ b/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua @@ -15,7 +15,6 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" - local clusters = require "st.matter.clusters" local mock_device = test.mock_device.build_test_matter_device({ @@ -84,34 +83,88 @@ local mock_device_variable_speed = test.mock_device.build_test_matter_device({ } }) +local supported_key_codes = { + "UP", + "DOWN", + "LEFT", + "RIGHT", + "SELECT", + "BACK", + "EXIT", + "MENU", + "SETTINGS", + "HOME", + "NUMBER0", + "NUMBER1", + "NUMBER2", + "NUMBER3", + "NUMBER4", + "NUMBER5", + "NUMBER6", + "NUMBER7", + "NUMBER8", + "NUMBER9" +} local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local cluster_subscribe_list = { clusters.OnOff.attributes.OnOff, clusters.MediaPlayback.attributes.CurrentState } - test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - print(i) - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - print(subscribe_request) - end + subscribe_request:merge(cluster_subscribe_list[2]:subscribe(mock_device)) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.mediaPlayback.supportedPlaybackCommands({"play", "pause", "stop"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.mediaTrackControl.supportedTrackControlCommands({"previousTrack", "nextTrack"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.keypadInput.supportedKeyCodes(supported_key_codes) + ) + ) + + test.mock_device.add_test_device(mock_device_variable_speed) + test.socket.device_lifecycle:__queue_receive({ mock_device_variable_speed.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_variable_speed.id, "init" }) subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_variable_speed) - for i, cluster in ipairs(cluster_subscribe_list) do - print(i) - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_variable_speed)) - end - print(subscribe_request) - end + subscribe_request:merge(cluster_subscribe_list[2]:subscribe(mock_device_variable_speed)) test.socket.matter:__expect_send({mock_device_variable_speed.id, subscribe_request}) - test.mock_device.add_test_device(mock_device_variable_speed) + + test.socket.device_lifecycle:__queue_receive({ mock_device_variable_speed.id, "doConfigure" }) + mock_device_variable_speed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + test.socket.capability:__expect_send( + mock_device_variable_speed:generate_test_message( + "main", capabilities.mediaPlayback.supportedPlaybackCommands({"play", "pause", "stop", "rewind", "fastForward"}) + ) + ) + test.socket.capability:__expect_send( + mock_device_variable_speed:generate_test_message( + "main", capabilities.mediaTrackControl.supportedTrackControlCommands({"previousTrack", "nextTrack"}) + ) + ) + test.socket.capability:__expect_send( + mock_device_variable_speed:generate_test_message( + "main", capabilities.keypadInput.supportedKeyCodes(supported_key_codes) + ) + ) end test.set_test_init_function(test_init) @@ -185,6 +238,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + }, + { + min_api_version = 17 } ) @@ -220,6 +276,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.mediaPlayback.playbackStatus.playing()) }, + }, + { + min_api_version = 17 } ) @@ -255,6 +314,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.mediaPlayback.playbackStatus.paused()) }, + }, + { + min_api_version = 17 } ) @@ -290,6 +352,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.mediaPlayback.playbackStatus.stopped()) }, + }, + { + min_api_version = 17 } ) @@ -328,6 +393,9 @@ test.register_message_test( clusters.MediaPlayback.server.commands.FastForward(mock_device, 10) } }, + }, + { + min_api_version = 17 } ) @@ -366,6 +434,9 @@ test.register_message_test( clusters.MediaPlayback.server.commands.Next(mock_device, 10) } }, + }, + { + min_api_version = 17 } ) @@ -532,6 +603,9 @@ test.register_message_test( clusters.KeypadInput.server.commands.SendKey(mock_device, 10, clusters.KeypadInput.types.CecKeyCode.ROOT_MENU) } } + }, + { + min_api_version = 17 } ) @@ -557,33 +631,15 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message( "main", - capabilities.keypadInput.supportedKeyCodes({ - "UP", - "DOWN", - "LEFT", - "RIGHT", - "SELECT", - "BACK", - "EXIT", - "MENU", - "SETTINGS", - "HOME", - "NUMBER0", - "NUMBER1", - "NUMBER2", - "NUMBER3", - "NUMBER4", - "NUMBER5", - "NUMBER6", - "NUMBER7", - "NUMBER8", - "NUMBER9", - }) + capabilities.keypadInput.supportedKeyCodes(supported_key_codes) ) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -608,33 +664,15 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device_variable_speed:generate_test_message( "main", - capabilities.keypadInput.supportedKeyCodes({ - "UP", - "DOWN", - "LEFT", - "RIGHT", - "SELECT", - "BACK", - "EXIT", - "MENU", - "SETTINGS", - "HOME", - "NUMBER0", - "NUMBER1", - "NUMBER2", - "NUMBER3", - "NUMBER4", - "NUMBER5", - "NUMBER6", - "NUMBER7", - "NUMBER8", - "NUMBER9", - }) + capabilities.keypadInput.supportedKeyCodes(supported_key_codes) ) ) mock_device_variable_speed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-pump/config.yml b/drivers/SmartThings/matter-pump/config.yml new file mode 100644 index 0000000000..0e14f67579 --- /dev/null +++ b/drivers/SmartThings/matter-pump/config.yml @@ -0,0 +1,4 @@ +name: 'Matter Pump' +packageKey: 'matter-pump' +permissions: + matter: {} \ No newline at end of file diff --git a/drivers/SmartThings/matter-pump/fingerprints.yml b/drivers/SmartThings/matter-pump/fingerprints.yml new file mode 100644 index 0000000000..238353abcc --- /dev/null +++ b/drivers/SmartThings/matter-pump/fingerprints.yml @@ -0,0 +1,6 @@ +matterGeneric: + - id: "matter/pump" + deviceLabel: Matter Pump + deviceTypes: + - id: 0x0303 + deviceProfileName: pump-level \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/profiles/switch-2-level.yml b/drivers/SmartThings/matter-pump/profiles/pump-level.yml similarity index 56% rename from drivers/SmartThings/matter-switch/profiles/switch-2-level.yml rename to drivers/SmartThings/matter-pump/profiles/pump-level.yml index 549a766f93..ae47ef6de4 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-2-level.yml +++ b/drivers/SmartThings/matter-pump/profiles/pump-level.yml @@ -1,24 +1,21 @@ -name: switch-2-level -components: -- id: main - capabilities: - - id: switch - version: 1 - - id: switchLevel - version: 1 - - id: firmwareUpdate - version: 1 - - id: refresh - version: 1 - categories: - - name: Switch -- id: switch2 - capabilities: - - id: switch - version: 1 - - id: switchLevel - version: 1 - - id: refresh - version: 1 - categories: - - name: Switch +name: pump-level +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: pumpOperationMode + version: 1 + - id: pumpControlMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Pump +metadata: + mnmn: SmartThingsEdge + vid: generic-pump-level \ No newline at end of file diff --git a/drivers/SmartThings/matter-pump/profiles/pump-only.yml b/drivers/SmartThings/matter-pump/profiles/pump-only.yml new file mode 100644 index 0000000000..f51fe87943 --- /dev/null +++ b/drivers/SmartThings/matter-pump/profiles/pump-only.yml @@ -0,0 +1,19 @@ +name: pump-only +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: pumpOperationMode + version: 1 + - id: pumpControlMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Pump +metadata: + mnmn: SmartThingsEdge + vid: generic-pump-level \ No newline at end of file diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/init.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/init.lua new file mode 100644 index 0000000000..de70e847fb --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/init.lua @@ -0,0 +1,119 @@ +local cluster_base = require "st.matter.cluster_base" +local PumpConfigurationAndControlServerAttributes = require "PumpConfigurationAndControl.server.attributes" +local PumpConfigurationAndControlTypes = require "PumpConfigurationAndControl.types" + +local PumpConfigurationAndControl = {} + +PumpConfigurationAndControl.ID = 0x0200 +PumpConfigurationAndControl.NAME = "PumpConfigurationAndControl" +PumpConfigurationAndControl.server = {} +PumpConfigurationAndControl.client = {} +PumpConfigurationAndControl.server.attributes = PumpConfigurationAndControlServerAttributes:set_parent_cluster(PumpConfigurationAndControl) +PumpConfigurationAndControl.types = PumpConfigurationAndControlTypes + +function PumpConfigurationAndControl:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "MaxPressure", + [0x0001] = "MaxSpeed", + [0x0002] = "MaxFlow", + [0x0003] = "MinConstPressure", + [0x0004] = "MaxConstPressure", + [0x0005] = "MinCompPressure", + [0x0006] = "MaxCompPressure", + [0x0007] = "MinConstSpeed", + [0x0008] = "MaxConstSpeed", + [0x0009] = "MinConstFlow", + [0x000A] = "MaxConstFlow", + [0x000B] = "MinConstTemp", + [0x000C] = "MaxConstTemp", + [0x0010] = "PumpStatus", + [0x0011] = "EffectiveOperationMode", + [0x0012] = "EffectiveControlMode", + [0x0013] = "Capacity", + [0x0014] = "Speed", + [0x0015] = "LifetimeRunningHours", + [0x0016] = "Power", + [0x0017] = "LifetimeEnergyConsumed", + [0x0020] = "OperationMode", + [0x0021] = "ControlMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +PumpConfigurationAndControl.attribute_direction_map = { + ["MaxPressure"] = "server", + ["MaxSpeed"] = "server", + ["MaxFlow"] = "server", + ["MinConstPressure"] = "server", + ["MaxConstPressure"] = "server", + ["MinCompPressure"] = "server", + ["MaxCompPressure"] = "server", + ["MinConstSpeed"] = "server", + ["MaxConstSpeed"] = "server", + ["MinConstFlow"] = "server", + ["MaxConstFlow"] = "server", + ["MinConstTemp"] = "server", + ["MaxConstTemp"] = "server", + ["PumpStatus"] = "server", + ["EffectiveOperationMode"] = "server", + ["EffectiveControlMode"] = "server", + ["Capacity"] = "server", + ["Speed"] = "server", + ["LifetimeRunningHours"] = "server", + ["Power"] = "server", + ["LifetimeEnergyConsumed"] = "server", + ["OperationMode"] = "server", + ["ControlMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +PumpConfigurationAndControl.FeatureMap = PumpConfigurationAndControl.types.Feature + +function PumpConfigurationAndControl.are_features_supported(feature, feature_map) + if (PumpConfigurationAndControl.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = PumpConfigurationAndControl.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, PumpConfigurationAndControl.NAME)) + end + return PumpConfigurationAndControl[direction].attributes[key] +end +PumpConfigurationAndControl.attributes = {} +setmetatable(PumpConfigurationAndControl.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = PumpConfigurationAndControl.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, PumpConfigurationAndControl.NAME)) + end + return PumpConfigurationAndControl[direction].commands[key] +end +PumpConfigurationAndControl.commands = {} +setmetatable(PumpConfigurationAndControl.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return PumpConfigurationAndControl.server.events[key] +end +PumpConfigurationAndControl.events = {} +setmetatable(PumpConfigurationAndControl.events, event_helper_mt) + +setmetatable(PumpConfigurationAndControl, {__index = cluster_base}) + +return PumpConfigurationAndControl diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/ControlMode.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/ControlMode.lua new file mode 100644 index 0000000000..5a72c3cfd8 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/ControlMode.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ControlMode = { + ID = 0x0021, + NAME = "ControlMode", + base_type = require "PumpConfigurationAndControl.types.ControlModeEnum", +} + +function ControlMode:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function ControlMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ControlMode:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function ControlMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ControlMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ControlMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ControlMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ControlMode, {__call = ControlMode.new_value, __index = ControlMode.base_type}) +return ControlMode diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveControlMode.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveControlMode.lua new file mode 100644 index 0000000000..71445d1d06 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveControlMode.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local EffectiveControlMode = { + ID = 0x0012, + NAME = "EffectiveControlMode", + base_type = require "PumpConfigurationAndControl.types.ControlModeEnum", +} + +function EffectiveControlMode:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function EffectiveControlMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EffectiveControlMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EffectiveControlMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EffectiveControlMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EffectiveControlMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(EffectiveControlMode, {__call = EffectiveControlMode.new_value, __index = EffectiveControlMode.base_type}) +return EffectiveControlMode diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveOperationMode.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveOperationMode.lua new file mode 100644 index 0000000000..997ba930c2 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/EffectiveOperationMode.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local EffectiveOperationMode = { + ID = 0x0011, + NAME = "EffectiveOperationMode", + base_type = require "PumpConfigurationAndControl.types.OperationModeEnum", +} + +function EffectiveOperationMode:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function EffectiveOperationMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EffectiveOperationMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EffectiveOperationMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EffectiveOperationMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EffectiveOperationMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(EffectiveOperationMode, {__call = EffectiveOperationMode.new_value, __index = EffectiveOperationMode.base_type}) +return EffectiveOperationMode diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/OperationMode.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/OperationMode.lua new file mode 100644 index 0000000000..cdf31e814c --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/OperationMode.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperationMode = { + ID = 0x0020, + NAME = "OperationMode", + base_type = require "PumpConfigurationAndControl.types.OperationModeEnum", +} + +function OperationMode:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OperationMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationMode:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function OperationMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperationMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OperationMode, {__call = OperationMode.new_value, __index = OperationMode.base_type}) +return OperationMode diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/PumpStatus.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/PumpStatus.lua new file mode 100644 index 0000000000..d9f69eaa82 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/PumpStatus.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PumpStatus = { + ID = 0x0010, + NAME = "PumpStatus", + base_type = require "PumpConfigurationAndControl.types.PumpStatusBitmap", +} + +function PumpStatus:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function PumpStatus:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PumpStatus:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PumpStatus:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PumpStatus:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PumpStatus:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(PumpStatus, {__call = PumpStatus.new_value, __index = PumpStatus.base_type}) +return PumpStatus diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/Speed.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/Speed.lua new file mode 100644 index 0000000000..527d98912e --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/Speed.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Speed = { + ID = 0x0014, + NAME = "Speed", + base_type = require "st.matter.data_types.Uint16", +} + +function Speed:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Speed:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Speed:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Speed:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Speed:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Speed:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Speed, {__call = Speed.new_value, __index = Speed.base_type}) +return Speed diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/init.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/init.lua new file mode 100644 index 0000000000..3c83b642ce --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("PumpConfigurationAndControl.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local PumpConfigurationAndControlServerAttributes = {} + +function PumpConfigurationAndControlServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(PumpConfigurationAndControlServerAttributes, attr_mt) + +return PumpConfigurationAndControlServerAttributes diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/ControlModeEnum.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/ControlModeEnum.lua new file mode 100644 index 0000000000..b17627ba43 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/ControlModeEnum.lua @@ -0,0 +1,46 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ControlModeEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.CONSTANT_SPEED] = "CONSTANT_SPEED", + [self.CONSTANT_PRESSURE] = "CONSTANT_PRESSURE", + [self.PROPORTIONAL_PRESSURE] = "PROPORTIONAL_PRESSURE", + [self.CONSTANT_FLOW] = "CONSTANT_FLOW", + [self.CONSTANT_TEMPERATURE] = "CONSTANT_TEMPERATURE", + [self.AUTOMATIC] = "AUTOMATIC", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.CONSTANT_SPEED = 0x00 +new_mt.__index.CONSTANT_PRESSURE = 0x01 +new_mt.__index.PROPORTIONAL_PRESSURE = 0x02 +new_mt.__index.CONSTANT_FLOW = 0x03 +new_mt.__index.CONSTANT_TEMPERATURE = 0x05 +new_mt.__index.AUTOMATIC = 0x07 + +ControlModeEnum.CONSTANT_SPEED = 0x00 +ControlModeEnum.CONSTANT_PRESSURE = 0x01 +ControlModeEnum.PROPORTIONAL_PRESSURE = 0x02 +ControlModeEnum.CONSTANT_FLOW = 0x03 +ControlModeEnum.CONSTANT_TEMPERATURE = 0x05 +ControlModeEnum.AUTOMATIC = 0x07 + +ControlModeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ControlModeEnum, new_mt) + +local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.PumpConfigurationAndControl.types.ControlModeEnum") +if has_aliases then + aliases:add_to_class(ControlModeEnum) +end + +return ControlModeEnum diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/Feature.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/Feature.lua new file mode 100644 index 0000000000..aaf52da9dd --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/Feature.lua @@ -0,0 +1,185 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.CONSTANT_PRESSURE = 0x0001 +Feature.COMPENSATED_PRESSURE = 0x0002 +Feature.CONSTANT_FLOW = 0x0004 +Feature.CONSTANT_SPEED = 0x0008 +Feature.CONSTANT_TEMPERATURE = 0x0010 +Feature.AUTOMATIC = 0x0020 +Feature.LOCAL_OPERATION = 0x0040 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + CONSTANT_PRESSURE = 0x0001, + COMPENSATED_PRESSURE = 0x0002, + CONSTANT_FLOW = 0x0004, + CONSTANT_SPEED = 0x0008, + CONSTANT_TEMPERATURE = 0x0010, + AUTOMATIC = 0x0020, + LOCAL_OPERATION = 0x0040, +} + +Feature.is_constant_pressure_set = function(self) + return (self.value & self.CONSTANT_PRESSURE) ~= 0 +end + +Feature.set_constant_pressure = function(self) + if self.value ~= nil then + self.value = self.value | self.CONSTANT_PRESSURE + else + self.value = self.CONSTANT_PRESSURE + end +end + +Feature.unset_constant_pressure = function(self) + self.value = self.value & (~self.CONSTANT_PRESSURE & self.BASE_MASK) +end + +Feature.is_compensated_pressure_set = function(self) + return (self.value & self.COMPENSATED_PRESSURE) ~= 0 +end + +Feature.set_compensated_pressure = function(self) + if self.value ~= nil then + self.value = self.value | self.COMPENSATED_PRESSURE + else + self.value = self.COMPENSATED_PRESSURE + end +end + +Feature.unset_compensated_pressure = function(self) + self.value = self.value & (~self.COMPENSATED_PRESSURE & self.BASE_MASK) +end + +Feature.is_constant_flow_set = function(self) + return (self.value & self.CONSTANT_FLOW) ~= 0 +end + +Feature.set_constant_flow = function(self) + if self.value ~= nil then + self.value = self.value | self.CONSTANT_FLOW + else + self.value = self.CONSTANT_FLOW + end +end + +Feature.unset_constant_flow = function(self) + self.value = self.value & (~self.CONSTANT_FLOW & self.BASE_MASK) +end + +Feature.is_constant_speed_set = function(self) + return (self.value & self.CONSTANT_SPEED) ~= 0 +end + +Feature.set_constant_speed = function(self) + if self.value ~= nil then + self.value = self.value | self.CONSTANT_SPEED + else + self.value = self.CONSTANT_SPEED + end +end + +Feature.unset_constant_speed = function(self) + self.value = self.value & (~self.CONSTANT_SPEED & self.BASE_MASK) +end + +Feature.is_constant_temperature_set = function(self) + return (self.value & self.CONSTANT_TEMPERATURE) ~= 0 +end + +Feature.set_constant_temperature = function(self) + if self.value ~= nil then + self.value = self.value | self.CONSTANT_TEMPERATURE + else + self.value = self.CONSTANT_TEMPERATURE + end +end + +Feature.unset_constant_temperature = function(self) + self.value = self.value & (~self.CONSTANT_TEMPERATURE & self.BASE_MASK) +end + +Feature.is_automatic_set = function(self) + return (self.value & self.AUTOMATIC) ~= 0 +end + +Feature.set_automatic = function(self) + if self.value ~= nil then + self.value = self.value | self.AUTOMATIC + else + self.value = self.AUTOMATIC + end +end + +Feature.unset_automatic = function(self) + self.value = self.value & (~self.AUTOMATIC & self.BASE_MASK) +end + +Feature.is_local_operation_set = function(self) + return (self.value & self.LOCAL_OPERATION) ~= 0 +end + +Feature.set_local_operation = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCAL_OPERATION + else + self.value = self.LOCAL_OPERATION + end +end + +Feature.unset_local_operation = function(self) + self.value = self.value & (~self.LOCAL_OPERATION & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.CONSTANT_PRESSURE | + Feature.COMPENSATED_PRESSURE | + Feature.CONSTANT_FLOW | + Feature.CONSTANT_SPEED | + Feature.CONSTANT_TEMPERATURE | + Feature.AUTOMATIC | + Feature.LOCAL_OPERATION + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_constant_pressure_set = Feature.is_constant_pressure_set, + set_constant_pressure = Feature.set_constant_pressure, + unset_constant_pressure = Feature.unset_constant_pressure, + is_compensated_pressure_set = Feature.is_compensated_pressure_set, + set_compensated_pressure = Feature.set_compensated_pressure, + unset_compensated_pressure = Feature.unset_compensated_pressure, + is_constant_flow_set = Feature.is_constant_flow_set, + set_constant_flow = Feature.set_constant_flow, + unset_constant_flow = Feature.unset_constant_flow, + is_constant_speed_set = Feature.is_constant_speed_set, + set_constant_speed = Feature.set_constant_speed, + unset_constant_speed = Feature.unset_constant_speed, + is_constant_temperature_set = Feature.is_constant_temperature_set, + set_constant_temperature = Feature.set_constant_temperature, + unset_constant_temperature = Feature.unset_constant_temperature, + is_automatic_set = Feature.is_automatic_set, + set_automatic = Feature.set_automatic, + unset_automatic = Feature.unset_automatic, + is_local_operation_set = Feature.is_local_operation_set, + set_local_operation = Feature.set_local_operation, + unset_local_operation = Feature.unset_local_operation, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/OperationModeEnum.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/OperationModeEnum.lua new file mode 100644 index 0000000000..2ce2ce7692 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/OperationModeEnum.lua @@ -0,0 +1,35 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local OperationModeEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NORMAL] = "NORMAL", + [self.MINIMUM] = "MINIMUM", + [self.MAXIMUM] = "MAXIMUM", + [self.LOCAL] = "LOCAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NORMAL = 0x00 +new_mt.__index.MINIMUM = 0x01 +new_mt.__index.MAXIMUM = 0x02 +new_mt.__index.LOCAL = 0x03 + +OperationModeEnum.NORMAL = 0x00 +OperationModeEnum.MINIMUM = 0x01 +OperationModeEnum.MAXIMUM = 0x02 +OperationModeEnum.LOCAL = 0x03 + +OperationModeEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(OperationModeEnum, new_mt) + +return OperationModeEnum diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/PumpStatusBitmap.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/PumpStatusBitmap.lua new file mode 100644 index 0000000000..8fd1a790d5 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/PumpStatusBitmap.lua @@ -0,0 +1,211 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local PumpStatusBitmap = {} +local new_mt = UintABC.new_mt({NAME = "PumpStatusBitmap", ID = data_types.name_to_id_map["Uint16"]}, 2) + +PumpStatusBitmap.BASE_MASK = 0xFFFF +PumpStatusBitmap.DEVICE_FAULT = 0x0001 +PumpStatusBitmap.SUPPLYFAULT = 0x0002 +PumpStatusBitmap.SPEED_LOW = 0x0004 +PumpStatusBitmap.SPEED_HIGH = 0x0008 +PumpStatusBitmap.LOCAL_OVERRIDE = 0x0010 +PumpStatusBitmap.RUNNING = 0x0020 +PumpStatusBitmap.REMOTE_PRESSURE = 0x0040 +PumpStatusBitmap.REMOTE_FLOW = 0x0080 +PumpStatusBitmap.REMOTE_TEMPERATURE = 0x0100 + +PumpStatusBitmap.mask_fields = { + BASE_MASK = 0xFFFF, + DEVICE_FAULT = 0x0001, + SUPPLYFAULT = 0x0002, + SPEED_LOW = 0x0004, + SPEED_HIGH = 0x0008, + LOCAL_OVERRIDE = 0x0010, + RUNNING = 0x0020, + REMOTE_PRESSURE = 0x0040, + REMOTE_FLOW = 0x0080, + REMOTE_TEMPERATURE = 0x0100, +} + +PumpStatusBitmap.is_device_fault_set = function(self) + return (self.value & self.DEVICE_FAULT) ~= 0 +end + +PumpStatusBitmap.set_device_fault = function(self) + if self.value ~= nil then + self.value = self.value | self.DEVICE_FAULT + else + self.value = self.DEVICE_FAULT + end +end + +PumpStatusBitmap.unset_device_fault = function(self) + self.value = self.value & (~self.DEVICE_FAULT & self.BASE_MASK) +end + +PumpStatusBitmap.is_supplyfault_set = function(self) + return (self.value & self.SUPPLYFAULT) ~= 0 +end + +PumpStatusBitmap.set_supplyfault = function(self) + if self.value ~= nil then + self.value = self.value | self.SUPPLYFAULT + else + self.value = self.SUPPLYFAULT + end +end + +PumpStatusBitmap.unset_supplyfault = function(self) + self.value = self.value & (~self.SUPPLYFAULT & self.BASE_MASK) +end + +PumpStatusBitmap.is_speed_low_set = function(self) + return (self.value & self.SPEED_LOW) ~= 0 +end + +PumpStatusBitmap.set_speed_low = function(self) + if self.value ~= nil then + self.value = self.value | self.SPEED_LOW + else + self.value = self.SPEED_LOW + end +end + +PumpStatusBitmap.unset_speed_low = function(self) + self.value = self.value & (~self.SPEED_LOW & self.BASE_MASK) +end + +PumpStatusBitmap.is_speed_high_set = function(self) + return (self.value & self.SPEED_HIGH) ~= 0 +end + +PumpStatusBitmap.set_speed_high = function(self) + if self.value ~= nil then + self.value = self.value | self.SPEED_HIGH + else + self.value = self.SPEED_HIGH + end +end + +PumpStatusBitmap.unset_speed_high = function(self) + self.value = self.value & (~self.SPEED_HIGH & self.BASE_MASK) +end + +PumpStatusBitmap.is_local_override_set = function(self) + return (self.value & self.LOCAL_OVERRIDE) ~= 0 +end + +PumpStatusBitmap.set_local_override = function(self) + if self.value ~= nil then + self.value = self.value | self.LOCAL_OVERRIDE + else + self.value = self.LOCAL_OVERRIDE + end +end + +PumpStatusBitmap.unset_local_override = function(self) + self.value = self.value & (~self.LOCAL_OVERRIDE & self.BASE_MASK) +end + +PumpStatusBitmap.is_running_set = function(self) + return (self.value & self.RUNNING) ~= 0 +end + +PumpStatusBitmap.set_running = function(self) + if self.value ~= nil then + self.value = self.value | self.RUNNING + else + self.value = self.RUNNING + end +end + +PumpStatusBitmap.unset_running = function(self) + self.value = self.value & (~self.RUNNING & self.BASE_MASK) +end + +PumpStatusBitmap.is_remote_pressure_set = function(self) + return (self.value & self.REMOTE_PRESSURE) ~= 0 +end + +PumpStatusBitmap.set_remote_pressure = function(self) + if self.value ~= nil then + self.value = self.value | self.REMOTE_PRESSURE + else + self.value = self.REMOTE_PRESSURE + end +end + +PumpStatusBitmap.unset_remote_pressure = function(self) + self.value = self.value & (~self.REMOTE_PRESSURE & self.BASE_MASK) +end + +PumpStatusBitmap.is_remote_flow_set = function(self) + return (self.value & self.REMOTE_FLOW) ~= 0 +end + +PumpStatusBitmap.set_remote_flow = function(self) + if self.value ~= nil then + self.value = self.value | self.REMOTE_FLOW + else + self.value = self.REMOTE_FLOW + end +end + +PumpStatusBitmap.unset_remote_flow = function(self) + self.value = self.value & (~self.REMOTE_FLOW & self.BASE_MASK) +end + +PumpStatusBitmap.is_remote_temperature_set = function(self) + return (self.value & self.REMOTE_TEMPERATURE) ~= 0 +end + +PumpStatusBitmap.set_remote_temperature = function(self) + if self.value ~= nil then + self.value = self.value | self.REMOTE_TEMPERATURE + else + self.value = self.REMOTE_TEMPERATURE + end +end + +PumpStatusBitmap.unset_remote_temperature = function(self) + self.value = self.value & (~self.REMOTE_TEMPERATURE & self.BASE_MASK) +end + +PumpStatusBitmap.mask_methods = { + is_device_fault_set = PumpStatusBitmap.is_device_fault_set, + set_device_fault = PumpStatusBitmap.set_device_fault, + unset_device_fault = PumpStatusBitmap.unset_device_fault, + is_supplyfault_set = PumpStatusBitmap.is_supplyfault_set, + set_supplyfault = PumpStatusBitmap.set_supplyfault, + unset_supplyfault = PumpStatusBitmap.unset_supplyfault, + is_speed_low_set = PumpStatusBitmap.is_speed_low_set, + set_speed_low = PumpStatusBitmap.set_speed_low, + unset_speed_low = PumpStatusBitmap.unset_speed_low, + is_speed_high_set = PumpStatusBitmap.is_speed_high_set, + set_speed_high = PumpStatusBitmap.set_speed_high, + unset_speed_high = PumpStatusBitmap.unset_speed_high, + is_local_override_set = PumpStatusBitmap.is_local_override_set, + set_local_override = PumpStatusBitmap.set_local_override, + unset_local_override = PumpStatusBitmap.unset_local_override, + is_running_set = PumpStatusBitmap.is_running_set, + set_running = PumpStatusBitmap.set_running, + unset_running = PumpStatusBitmap.unset_running, + is_remote_pressure_set = PumpStatusBitmap.is_remote_pressure_set, + set_remote_pressure = PumpStatusBitmap.set_remote_pressure, + unset_remote_pressure = PumpStatusBitmap.unset_remote_pressure, + is_remote_flow_set = PumpStatusBitmap.is_remote_flow_set, + set_remote_flow = PumpStatusBitmap.set_remote_flow, + unset_remote_flow = PumpStatusBitmap.unset_remote_flow, + is_remote_temperature_set = PumpStatusBitmap.is_remote_temperature_set, + set_remote_temperature = PumpStatusBitmap.set_remote_temperature, + unset_remote_temperature = PumpStatusBitmap.unset_remote_temperature, +} + +PumpStatusBitmap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(PumpStatusBitmap, new_mt) + +return PumpStatusBitmap diff --git a/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/init.lua b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/init.lua new file mode 100644 index 0000000000..738561f1f6 --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/PumpConfigurationAndControl/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("PumpConfigurationAndControl.types." .. key) + end + return types_mt.__types_cache[key] +end + +local PumpConfigurationAndControlTypes = {} + +setmetatable(PumpConfigurationAndControlTypes, types_mt) + +return PumpConfigurationAndControlTypes diff --git a/drivers/SmartThings/matter-pump/src/embedded-cluster-utils.lua b/drivers/SmartThings/matter-pump/src/embedded-cluster-utils.lua new file mode 100644 index 0000000000..c19d7f6fff --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/embedded-cluster-utils.lua @@ -0,0 +1,49 @@ +local clusters = require "st.matter.clusters" +local utils = require "st.utils" + +-- Include driver-side definitions when lua libs api version is < 10 +local version = require "version" +if version.api < 10 then + clusters.PumpConfigurationAndControl = require "PumpConfigurationAndControl" +end + +local embedded_cluster_utils = {} + +local embedded_clusters = { + [clusters.PumpConfigurationAndControl.ID] = clusters.PumpConfigurationAndControl +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 10 and embedded_clusters[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end +end + +return embedded_cluster_utils \ No newline at end of file diff --git a/drivers/SmartThings/matter-pump/src/init.lua b/drivers/SmartThings/matter-pump/src/init.lua new file mode 100644 index 0000000000..0e79f66cbb --- /dev/null +++ b/drivers/SmartThings/matter-pump/src/init.lua @@ -0,0 +1,329 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local log = require "log" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "embedded-cluster-utils" +local MatterDriver = require "st.matter.driver" + +local IS_LOCAL_OVERRIDE = "__is_local_override" +-- Per matter spec, the pump level is in steps of 0.5% and the +-- max level value is 200. Anything above is considered 100% +local MAX_PUMP_ATTR_LEVEL = 200 +local MAX_CAP_SWITCH_LEVEL = 100 + +-- Include driver-side definitions when lua libs api version is < 10 +local version = require "version" +if version.api < 10 then + clusters.PumpConfigurationAndControl = require "PumpConfigurationAndControl" +end + +local pumpOperationMode = capabilities.pumpOperationMode +local pumpControlMode = capabilities.pumpControlMode + +local PUMP_OPERATION_MODE_MAP = { + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.NORMAL] = pumpOperationMode.operationMode.normal, + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.MINIMUM] = pumpOperationMode.operationMode.minimum, + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.MAXIMUM] = pumpOperationMode.operationMode.maximum, + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.LOCAL] = pumpOperationMode.operationMode.localSetting, +} + +local PUMP_CONTROL_MODE_MAP = { + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_SPEED] = pumpControlMode.controlMode.constantSpeed, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_PRESSURE] = pumpControlMode.controlMode.constantPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.PROPORTIONAL_PRESSURE] = pumpControlMode.controlMode.proportionalPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_FLOW] = pumpControlMode.controlMode.constantFlow, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_TEMPERATURE] = pumpControlMode.controlMode.constantTemperature, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.AUTOMATIC] = pumpControlMode.controlMode.automatic, +} + +local PUMP_CURRENT_CONTROL_MODE_MAP = { + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_SPEED] = pumpControlMode.currentControlMode.constantSpeed, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_PRESSURE] = pumpControlMode.currentControlMode.constantPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.PROPORTIONAL_PRESSURE] = pumpControlMode.currentControlMode.proportionalPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_FLOW] = pumpControlMode.currentControlMode.constantFlow, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_TEMPERATURE] = pumpControlMode.currentControlMode.constantTemperature, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.AUTOMATIC] = pumpControlMode.currentControlMode.automatic, +} + +local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff, + }, + [capabilities.switchLevel.ID] = { + clusters.LevelControl.attributes.CurrentLevel + }, + [capabilities.pumpOperationMode.ID]={ + clusters.PumpConfigurationAndControl.attributes.OperationMode, + clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode, + clusters.PumpConfigurationAndControl.attributes.PumpStatus, + }, + [capabilities.pumpControlMode.ID]={ + clusters.PumpConfigurationAndControl.attributes.EffectiveControlMode, + }, +} + +local function find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = embedded_cluster_utils.get_endpoints(device, cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return res +end + +local function component_to_endpoint(device, component_name) + -- Use the find_default_endpoint function to return the first endpoint that + -- supports a given cluster. + return find_default_endpoint(device, clusters.PumpConfigurationAndControl.ID) +end + +local function device_init(driver, device) + device:subscribe() + device:set_component_to_endpoint_fn(component_to_endpoint) +end + +local function info_changed(driver, device, event, args) + --Note this is needed because device:subscribe() does not recalculate + -- the subscribed attributes each time it is run, that only happens at init. + -- This will change in the 0.48.x release of the lua libs. + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + device:subscribe() +end + +local function set_supported_op_mode(driver, device) + local spd_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_SPEED}) + local local_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.LOCAL_OPERATION}) + local supported_op_modes = {pumpOperationMode.operationMode.normal.NAME} + if #spd_eps > 0 then + table.insert(supported_op_modes, pumpOperationMode.operationMode.minimum.NAME) + table.insert(supported_op_modes, pumpOperationMode.operationMode.maximum.NAME) + end + if #local_eps > 0 then + table.insert(supported_op_modes, pumpOperationMode.operationMode.localSetting.NAME) + end + device:emit_event(pumpOperationMode.supportedOperationModes(supported_op_modes)) +end + +local function set_supported_control_mode(driver, device) + local spd_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_SPEED}) + local prsconst_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_PRESSURE}) + local prscomp_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.COMPENSATED_PRESSURE}) + local flw_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_FLOW}) + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_TEMPERATURE}) + local auto_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.AUTOMATIC}) + local supported_control_modes = {} + if #spd_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantSpeed.NAME) + end + if #prsconst_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantPressure.NAME) + end + if #prscomp_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.proportionalPressure.NAME) + end + if #flw_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantFlow.NAME) + end + if #temp_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantTemperature.NAME) + end + if #auto_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.automatic.NAME) + end + device:emit_event(pumpControlMode.supportedControlModes(supported_control_modes)) +end + +local function do_configure(driver, device) + local pump_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID) + local level_eps = embedded_cluster_utils.get_endpoints(device, clusters.LevelControl.ID) + local profile_name = "pump" + if #pump_eps == 1 then + if #level_eps > 0 then + profile_name = profile_name .. "-level" + else + profile_name = profile_name .. "-only" + end + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + else + device.log.warn_with({hub_logs=true}, "Device does not support pump configuration and control cluster") + end + set_supported_op_mode(driver, device) + set_supported_control_mode(driver, device) +end + +-- Matter Handlers -- +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) + end +end + +local function level_attr_handler(driver, device, ib, response) + if ib.data.value ~= nil then + local level = math.floor((ib.data.value / MAX_PUMP_ATTR_LEVEL * MAX_CAP_SWITCH_LEVEL) + 0.5) + level = math.min(level, MAX_CAP_SWITCH_LEVEL) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.level(level)) + end +end + +local function effective_operation_mode_handler(driver, device, ib, response) + local modeEnum = clusters.PumpConfigurationAndControl.types.OperationModeEnum + local supported_control_modes = {} + local local_override = device:get_field(IS_LOCAL_OVERRIDE) + if not local_override then + set_supported_op_mode(driver, device) + end + if ib.data.value == modeEnum.NORMAL then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.normal()) + set_supported_control_mode(driver, device) + elseif ib.data.value == modeEnum.MINIMUM then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.minimum()) + device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) + elseif ib.data.value == modeEnum.MAXIMUM then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.maximum()) + device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) + elseif ib.data.value == modeEnum.LOCAL then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.localSetting()) + device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) + end +end + +local function effective_control_mode_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, PUMP_CURRENT_CONTROL_MODE_MAP[ib.data.value]()) +end + +local function pump_status_handler(driver, device, ib, response) + if ib.data.value == clusters.PumpConfigurationAndControl.types.PumpStatusBitmap.LOCAL_OVERRIDE then + device:set_field(IS_LOCAL_OVERRIDE, true, {persist = true}) + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.localSetting()) + local supported_op_modes = {} + local supported_control_modes = {} + device:emit_event(pumpOperationMode.supportedOperationModes(supported_op_modes)) + device:emit_event(pumpControlMode.supportedControlModes(supported_control_modes)) + elseif ib.data.value == clusters.PumpConfigurationAndControl.types.PumpStatusBitmap.RUNNING then + device:set_field(IS_LOCAL_OVERRIDE, false, {persist = true}) + device:send(clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode:read(device)) + end +end + +-- Capability Handlers -- +local function handle_switch_on(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.On(device, endpoint_id) + device:send(req) +end + +local function handle_switch_off(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.Off(device, endpoint_id) + device:send(req) +end + +local function handle_set_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local level = math.floor(cmd.args.level / MAX_CAP_SWITCH_LEVEL * MAX_PUMP_ATTR_LEVEL) + local req = clusters.LevelControl.server.commands.MoveToLevelWithOnOff(device, endpoint_id, level, cmd.args.rate or 0, 0 ,0) + device:send(req) +end + +local function set_operation_mode(driver, device, cmd) + local mode_id = nil + for id, mode in pairs(PUMP_OPERATION_MODE_MAP) do + if mode.NAME == cmd.args.operationMode then + mode_id = id + break + end + end + if mode_id then + device:send(clusters.PumpConfigurationAndControl.attributes.OperationMode:write(device, device:component_to_endpoint(cmd.component), mode_id)) + end +end + +local function set_control_mode(driver, device, cmd) + local mode_id = nil + for id, mode in pairs(PUMP_CONTROL_MODE_MAP) do + if mode.NAME == cmd.args.controlMode then + mode_id = id + break + end + end + if mode_id then + device:send(clusters.PumpConfigurationAndControl.attributes.ControlMode:write(device, device:component_to_endpoint(cmd.component), mode_id)) + end +end + +local matter_driver_template = { + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed, + }, + matter_handlers = { + attr = { + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, + }, + [clusters.LevelControl.ID] = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = level_attr_handler + }, + [clusters.PumpConfigurationAndControl.ID] = { + [clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode.ID] = effective_operation_mode_handler, + [clusters.PumpConfigurationAndControl.attributes.EffectiveControlMode.ID] = effective_control_mode_handler, + [clusters.PumpConfigurationAndControl.attributes.PumpStatus.ID] = pump_status_handler, + }, + }, + }, + subscribed_attributes = subscribed_attributes, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_switch_on, + [capabilities.switch.commands.off.NAME] = handle_switch_off, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level, + }, + [capabilities.pumpOperationMode.ID] = { + [capabilities.pumpOperationMode.commands.setOperationMode.NAME] = set_operation_mode, + }, + [capabilities.pumpControlMode.ID] = { + [capabilities.pumpControlMode.commands.setControlMode.NAME] = set_control_mode, + }, + }, + supported_capabilities = { + capabilities.switch, + capabilities.switchLevel, + capabilities.pumpOperationMode, + capabilities.pumpControlMode, + }, + shared_device_thread_enabled = true, +} + +local matter_driver = MatterDriver("matter-pump", matter_driver_template) +log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) +matter_driver:run() diff --git a/drivers/SmartThings/matter-rvc/capabilities/robotCleanerOperatingState.yml b/drivers/SmartThings/matter-rvc/capabilities/robotCleanerOperatingState.yml new file mode 100644 index 0000000000..d6cea7f3f2 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/capabilities/robotCleanerOperatingState.yml @@ -0,0 +1,108 @@ +name: Robot Cleaner Operating State +status: live +attributes: + operatingState: + schema: + type: object + additionalProperties: false + properties: + value: + type: string + enum: + - stopped + - running + - paused + - seekingCharger + - charging + - docked + - unableToStartOrResume + - unableToCompleteOperation + - commandInvalidInState + - failedToFindChargingDock + - stuck + - dustBinMissing + - dustBinFull + - waterTankEmpty + - waterTankMissing + - waterTankLidOpen + - mopCleaningPadMissing + required: + - value + enumCommands: + - command: start + value: running + - command: pause + value: paused + - command: goHome + value: seekingCharger + actedOnBy: + - start + - pause + - goHome + supportedOperatingStates: + schema: + type: object + additionalProperties: false + properties: + value: + type: array + items: + type: string + enum: + - stopped + - running + - paused + - seekingCharger + - charging + - docked + - unableToStartOrResume + - unableToCompleteOperation + - commandInvalidInState + - failedToFindChargingDock + - stuck + - dustBinMissing + - dustBinFull + - waterTankEmpty + - waterTankMissing + - waterTankLidOpen + - mopCleaningPadMissing + required: + - value + supportedCommands: + schema: + type: object + additionalProperties: false + properties: + value: + type: array + items: + type: string + enum: + - start + - pause + - goHome + supportedOperatingStateCommands: + schema: + type: object + additionalProperties: false + properties: + value: + type: array + items: + type: string + enum: + - start + - pause + - goHome +commands: + start: + arguments: [] + name: start + pause: + arguments: [] + name: pause + goHome: + arguments: [] + name: goHome +id: robotCleanerOperatingState +version: 1 diff --git a/drivers/SmartThings/matter-rvc/config.yml b/drivers/SmartThings/matter-rvc/config.yml new file mode 100644 index 0000000000..1ff59a38ac --- /dev/null +++ b/drivers/SmartThings/matter-rvc/config.yml @@ -0,0 +1,4 @@ +name: 'Matter RVC' +packageKey: 'matter-rvc' +permissions: + matter: {} diff --git a/drivers/SmartThings/matter-rvc/fingerprints.yml b/drivers/SmartThings/matter-rvc/fingerprints.yml new file mode 100644 index 0000000000..bbab2b1ec4 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/fingerprints.yml @@ -0,0 +1,13 @@ +matterManufacturer: + - id: "virtual/matter/rvc" + deviceLabel: Matter RVC + vendorId: 0x10e1 + productId: 0x1025 + deviceProfileName: rvc-clean-mode + +matterGeneric: + - id: "matter/rvc" + deviceLabel: Matter RVC + deviceTypes: + - id: 0x0074 + deviceProfileName: rvc-clean-mode diff --git a/drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode-service-area.yml b/drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode-service-area.yml new file mode 100644 index 0000000000..c15783f6d2 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode-service-area.yml @@ -0,0 +1,20 @@ +name: rvc-clean-mode-service-area +components: + - id: main + label: Main + capabilities: + - id: robotCleanerOperatingState + version: 1 + - id: mode + version: 1 + - id: serviceArea + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: RobotCleaner +metadata: + mnmn: SmartThingsEdge + vid: generic-rvc diff --git a/drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode.yml b/drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode.yml new file mode 100644 index 0000000000..33af113030 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/profiles/rvc-clean-mode.yml @@ -0,0 +1,18 @@ +name: rvc-clean-mode +components: + - id: main + label: Main + capabilities: + - id: robotCleanerOperatingState + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RobotCleaner +metadata: + mnmn: SmartThingsEdge + vid: generic-rvc diff --git a/drivers/SmartThings/matter-rvc/profiles/rvc-service-area.yml b/drivers/SmartThings/matter-rvc/profiles/rvc-service-area.yml new file mode 100644 index 0000000000..560eda12fa --- /dev/null +++ b/drivers/SmartThings/matter-rvc/profiles/rvc-service-area.yml @@ -0,0 +1,15 @@ +name: rvc-service-area +components: + - id: main + label: Main + capabilities: + - id: robotCleanerOperatingState + version: 1 + - id: serviceArea + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: RobotCleaner diff --git a/drivers/SmartThings/matter-rvc/profiles/rvc.yml b/drivers/SmartThings/matter-rvc/profiles/rvc.yml new file mode 100644 index 0000000000..84bed73495 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/profiles/rvc.yml @@ -0,0 +1,13 @@ +name: rvc +components: + - id: main + label: Main + capabilities: + - id: robotCleanerOperatingState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RobotCleaner diff --git a/drivers/SmartThings/matter-rvc/src/Global/init.lua b/drivers/SmartThings/matter-rvc/src/Global/init.lua new file mode 100644 index 0000000000..97726555b4 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/Global/init.lua @@ -0,0 +1,26 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local GlobalTypes = require "Global.types" + +--- @class Global +--- @alias Global +--- +--- @field public types GlobalTypes + +local Global = {} +Global.types = GlobalTypes +return Global diff --git a/drivers/SmartThings/matter-rvc/src/Global/types/AreaTypeTag.lua b/drivers/SmartThings/matter-rvc/src/Global/types/AreaTypeTag.lua new file mode 100644 index 0000000000..3e20ae1b8e --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/Global/types/AreaTypeTag.lua @@ -0,0 +1,423 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +--- @class Global.types.AreaTypeTag: st.matter.data_types.Uint8 +--- @alias AreaTypeTag +--- +--- @field public byte_length number 1 +--- @field public AISLE number 0 +--- @field public ATTIC number 1 +--- @field public BACK_DOOR number 2 +--- @field public BACK_YARD number 3 +--- @field public BALCONY number 4 +--- @field public BALLROOM number 5 +--- @field public BATHROOM number 6 +--- @field public BEDROOM number 7 +--- @field public BORDER number 8 +--- @field public BOXROOM number 9 +--- @field public BREAKFAST_ROOM number 10 +--- @field public CARPORT number 11 +--- @field public CELLAR number 12 +--- @field public CLOAKROOM number 13 +--- @field public CLOSET number 14 +--- @field public CONSERVATORY number 15 +--- @field public CORRIDOR number 16 +--- @field public CRAFT_ROOM number 17 +--- @field public CUPBOARD number 18 +--- @field public DECK number 19 +--- @field public DEN number 20 +--- @field public DINING number 21 +--- @field public DRAWING_ROOM number 22 +--- @field public DRESSING_ROOM number 23 +--- @field public DRIVEWAY number 24 +--- @field public ELEVATOR number 25 +--- @field public ENSUITE number 26 +--- @field public ENTRANCE number 27 +--- @field public ENTRYWAY number 28 +--- @field public FAMILY_ROOM number 29 +--- @field public FOYER number 30 +--- @field public FRONT_DOOR number 31 +--- @field public FRONT_YARD number 32 +--- @field public GAME_ROOM number 33 +--- @field public GARAGE number 34 +--- @field public GARAGE_DOOR number 35 +--- @field public GARDEN number 36 +--- @field public GARDEN_DOOR number 37 +--- @field public GUEST_BATHROOM number 38 +--- @field public GUEST_BEDROOM number 39 +--- @field public GUEST_RESTROOM number 40 +--- @field public GUEST_ROOM number 41 +--- @field public GYM number 42 +--- @field public HALLWAY number 43 +--- @field public HEARTH_ROOM number 44 +--- @field public KIDS_ROOM number 45 +--- @field public KIDS_BEDROOM number 46 +--- @field public KITCHEN number 47 +--- @field public LARDER number 48 +--- @field public LAUNDRY_ROOM number 49 +--- @field public LAWN number 50 +--- @field public LIBRARY number 51 +--- @field public LIVING_ROOM number 52 +--- @field public LOUNGE number 53 +--- @field public MEDIA_TV_ROOM number 54 +--- @field public MUD_ROOM number 55 +--- @field public MUSIC_ROOM number 56 +--- @field public NURSERY number 57 +--- @field public OFFICE number 58 +--- @field public OUTDOOR_KITCHEN number 59 +--- @field public OUTSIDE number 60 +--- @field public PANTRY number 61 +--- @field public PARKING_LOT number 62 +--- @field public PARLOR number 63 +--- @field public PATIO number 64 +--- @field public PLAY_ROOM number 65 +--- @field public POOL_ROOM number 66 +--- @field public PORCH number 67 +--- @field public PRIMARY_BATHROOM number 68 +--- @field public PRIMARY_BEDROOM number 69 +--- @field public RAMP number 70 +--- @field public RECEPTION_ROOM number 71 +--- @field public RECREATION_ROOM number 72 +--- @field public RESTROOM number 73 +--- @field public ROOF number 74 +--- @field public SAUNA number 75 +--- @field public SCULLERY number 76 +--- @field public SEWING_ROOM number 77 +--- @field public SHED number 78 +--- @field public SIDE_DOOR number 79 +--- @field public SIDE_YARD number 80 +--- @field public SITTING_ROOM number 81 +--- @field public SNUG number 82 +--- @field public SPA number 83 +--- @field public STAIRCASE number 84 +--- @field public STEAM_ROOM number 85 +--- @field public STORAGE_ROOM number 86 +--- @field public STUDIO number 87 +--- @field public STUDY number 88 +--- @field public SUN_ROOM number 89 +--- @field public SWIMMING_POOL number 90 +--- @field public TERRACE number 91 +--- @field public UTILITY_ROOM number 92 +--- @field public WARD number 93 +--- @field public WORKSHOP number 94 + +local AreaTypeTag = {} +local new_mt = UintABC.new_mt({NAME = "AreaTypeTag", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.AISLE] = "AISLE", + [self.ATTIC] = "ATTIC", + [self.BACK_DOOR] = "BACK_DOOR", + [self.BACK_YARD] = "BACK_YARD", + [self.BALCONY] = "BALCONY", + [self.BALLROOM] = "BALLROOM", + [self.BATHROOM] = "BATHROOM", + [self.BEDROOM] = "BEDROOM", + [self.BORDER] = "BORDER", + [self.BOXROOM] = "BOXROOM", + [self.BREAKFAST_ROOM] = "BREAKFAST_ROOM", + [self.CARPORT] = "CARPORT", + [self.CELLAR] = "CELLAR", + [self.CLOAKROOM] = "CLOAKROOM", + [self.CLOSET] = "CLOSET", + [self.CONSERVATORY] = "CONSERVATORY", + [self.CORRIDOR] = "CORRIDOR", + [self.CRAFT_ROOM] = "CRAFT_ROOM", + [self.CUPBOARD] = "CUPBOARD", + [self.DECK] = "DECK", + [self.DEN] = "DEN", + [self.DINING] = "DINING", + [self.DRAWING_ROOM] = "DRAWING_ROOM", + [self.DRESSING_ROOM] = "DRESSING_ROOM", + [self.DRIVEWAY] = "DRIVEWAY", + [self.ELEVATOR] = "ELEVATOR", + [self.ENSUITE] = "ENSUITE", + [self.ENTRANCE] = "ENTRANCE", + [self.ENTRYWAY] = "ENTRYWAY", + [self.FAMILY_ROOM] = "FAMILY_ROOM", + [self.FOYER] = "FOYER", + [self.FRONT_DOOR] = "FRONT_DOOR", + [self.FRONT_YARD] = "FRONT_YARD", + [self.GAME_ROOM] = "GAME_ROOM", + [self.GARAGE] = "GARAGE", + [self.GARAGE_DOOR] = "GARAGE_DOOR", + [self.GARDEN] = "GARDEN", + [self.GARDEN_DOOR] = "GARDEN_DOOR", + [self.GUEST_BATHROOM] = "GUEST_BATHROOM", + [self.GUEST_BEDROOM] = "GUEST_BEDROOM", + [self.GUEST_RESTROOM] = "GUEST_RESTROOM", + [self.GUEST_ROOM] = "GUEST_ROOM", + [self.GYM] = "GYM", + [self.HALLWAY] = "HALLWAY", + [self.HEARTH_ROOM] = "HEARTH_ROOM", + [self.KIDS_ROOM] = "KIDS_ROOM", + [self.KIDS_BEDROOM] = "KIDS_BEDROOM", + [self.KITCHEN] = "KITCHEN", + [self.LARDER] = "LARDER", + [self.LAUNDRY_ROOM] = "LAUNDRY_ROOM", + [self.LAWN] = "LAWN", + [self.LIBRARY] = "LIBRARY", + [self.LIVING_ROOM] = "LIVING_ROOM", + [self.LOUNGE] = "LOUNGE", + [self.MEDIA_TV_ROOM] = "MEDIA_TV_ROOM", + [self.MUD_ROOM] = "MUD_ROOM", + [self.MUSIC_ROOM] = "MUSIC_ROOM", + [self.NURSERY] = "NURSERY", + [self.OFFICE] = "OFFICE", + [self.OUTDOOR_KITCHEN] = "OUTDOOR_KITCHEN", + [self.OUTSIDE] = "OUTSIDE", + [self.PANTRY] = "PANTRY", + [self.PARKING_LOT] = "PARKING_LOT", + [self.PARLOR] = "PARLOR", + [self.PATIO] = "PATIO", + [self.PLAY_ROOM] = "PLAY_ROOM", + [self.POOL_ROOM] = "POOL_ROOM", + [self.PORCH] = "PORCH", + [self.PRIMARY_BATHROOM] = "PRIMARY_BATHROOM", + [self.PRIMARY_BEDROOM] = "PRIMARY_BEDROOM", + [self.RAMP] = "RAMP", + [self.RECEPTION_ROOM] = "RECEPTION_ROOM", + [self.RECREATION_ROOM] = "RECREATION_ROOM", + [self.RESTROOM] = "RESTROOM", + [self.ROOF] = "ROOF", + [self.SAUNA] = "SAUNA", + [self.SCULLERY] = "SCULLERY", + [self.SEWING_ROOM] = "SEWING_ROOM", + [self.SHED] = "SHED", + [self.SIDE_DOOR] = "SIDE_DOOR", + [self.SIDE_YARD] = "SIDE_YARD", + [self.SITTING_ROOM] = "SITTING_ROOM", + [self.SNUG] = "SNUG", + [self.SPA] = "SPA", + [self.STAIRCASE] = "STAIRCASE", + [self.STEAM_ROOM] = "STEAM_ROOM", + [self.STORAGE_ROOM] = "STORAGE_ROOM", + [self.STUDIO] = "STUDIO", + [self.STUDY] = "STUDY", + [self.SUN_ROOM] = "SUN_ROOM", + [self.SWIMMING_POOL] = "SWIMMING_POOL", + [self.TERRACE] = "TERRACE", + [self.UTILITY_ROOM] = "UTILITY_ROOM", + [self.WARD] = "WARD", + [self.WORKSHOP] = "WORKSHOP", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.AISLE = 0x00 +new_mt.__index.ATTIC = 0x01 +new_mt.__index.BACK_DOOR = 0x02 +new_mt.__index.BACK_YARD = 0x03 +new_mt.__index.BALCONY = 0x04 +new_mt.__index.BALLROOM = 0x05 +new_mt.__index.BATHROOM = 0x06 +new_mt.__index.BEDROOM = 0x07 +new_mt.__index.BORDER = 0x08 +new_mt.__index.BOXROOM = 0x09 +new_mt.__index.BREAKFAST_ROOM = 0x0A +new_mt.__index.CARPORT = 0x0B +new_mt.__index.CELLAR = 0x0C +new_mt.__index.CLOAKROOM = 0x0D +new_mt.__index.CLOSET = 0x0E +new_mt.__index.CONSERVATORY = 0x0F +new_mt.__index.CORRIDOR = 0x10 +new_mt.__index.CRAFT_ROOM = 0x11 +new_mt.__index.CUPBOARD = 0x12 +new_mt.__index.DECK = 0x13 +new_mt.__index.DEN = 0x14 +new_mt.__index.DINING = 0x15 +new_mt.__index.DRAWING_ROOM = 0x16 +new_mt.__index.DRESSING_ROOM = 0x17 +new_mt.__index.DRIVEWAY = 0x18 +new_mt.__index.ELEVATOR = 0x19 +new_mt.__index.ENSUITE = 0x1A +new_mt.__index.ENTRANCE = 0x1B +new_mt.__index.ENTRYWAY = 0x1C +new_mt.__index.FAMILY_ROOM = 0x1D +new_mt.__index.FOYER = 0x1E +new_mt.__index.FRONT_DOOR = 0x1F +new_mt.__index.FRONT_YARD = 0x20 +new_mt.__index.GAME_ROOM = 0x21 +new_mt.__index.GARAGE = 0x22 +new_mt.__index.GARAGE_DOOR = 0x23 +new_mt.__index.GARDEN = 0x24 +new_mt.__index.GARDEN_DOOR = 0x25 +new_mt.__index.GUEST_BATHROOM = 0x26 +new_mt.__index.GUEST_BEDROOM = 0x27 +new_mt.__index.GUEST_RESTROOM = 0x28 +new_mt.__index.GUEST_ROOM = 0x29 +new_mt.__index.GYM = 0x2A +new_mt.__index.HALLWAY = 0x2B +new_mt.__index.HEARTH_ROOM = 0x2C +new_mt.__index.KIDS_ROOM = 0x2D +new_mt.__index.KIDS_BEDROOM = 0x2E +new_mt.__index.KITCHEN = 0x2F +new_mt.__index.LARDER = 0x30 +new_mt.__index.LAUNDRY_ROOM = 0x31 +new_mt.__index.LAWN = 0x32 +new_mt.__index.LIBRARY = 0x33 +new_mt.__index.LIVING_ROOM = 0x34 +new_mt.__index.LOUNGE = 0x35 +new_mt.__index.MEDIA_TV_ROOM = 0x36 +new_mt.__index.MUD_ROOM = 0x37 +new_mt.__index.MUSIC_ROOM = 0x38 +new_mt.__index.NURSERY = 0x39 +new_mt.__index.OFFICE = 0x3A +new_mt.__index.OUTDOOR_KITCHEN = 0x3B +new_mt.__index.OUTSIDE = 0x3C +new_mt.__index.PANTRY = 0x3D +new_mt.__index.PARKING_LOT = 0x3E +new_mt.__index.PARLOR = 0x3F +new_mt.__index.PATIO = 0x40 +new_mt.__index.PLAY_ROOM = 0x41 +new_mt.__index.POOL_ROOM = 0x42 +new_mt.__index.PORCH = 0x43 +new_mt.__index.PRIMARY_BATHROOM = 0x44 +new_mt.__index.PRIMARY_BEDROOM = 0x45 +new_mt.__index.RAMP = 0x46 +new_mt.__index.RECEPTION_ROOM = 0x47 +new_mt.__index.RECREATION_ROOM = 0x48 +new_mt.__index.RESTROOM = 0x49 +new_mt.__index.ROOF = 0x4A +new_mt.__index.SAUNA = 0x4B +new_mt.__index.SCULLERY = 0x4C +new_mt.__index.SEWING_ROOM = 0x4D +new_mt.__index.SHED = 0x4E +new_mt.__index.SIDE_DOOR = 0x4F +new_mt.__index.SIDE_YARD = 0x50 +new_mt.__index.SITTING_ROOM = 0x51 +new_mt.__index.SNUG = 0x52 +new_mt.__index.SPA = 0x53 +new_mt.__index.STAIRCASE = 0x54 +new_mt.__index.STEAM_ROOM = 0x55 +new_mt.__index.STORAGE_ROOM = 0x56 +new_mt.__index.STUDIO = 0x57 +new_mt.__index.STUDY = 0x58 +new_mt.__index.SUN_ROOM = 0x59 +new_mt.__index.SWIMMING_POOL = 0x5A +new_mt.__index.TERRACE = 0x5B +new_mt.__index.UTILITY_ROOM = 0x5C +new_mt.__index.WARD = 0x5D +new_mt.__index.WORKSHOP = 0x5E + +AreaTypeTag.AISLE = 0x00 +AreaTypeTag.ATTIC = 0x01 +AreaTypeTag.BACK_DOOR = 0x02 +AreaTypeTag.BACK_YARD = 0x03 +AreaTypeTag.BALCONY = 0x04 +AreaTypeTag.BALLROOM = 0x05 +AreaTypeTag.BATHROOM = 0x06 +AreaTypeTag.BEDROOM = 0x07 +AreaTypeTag.BORDER = 0x08 +AreaTypeTag.BOXROOM = 0x09 +AreaTypeTag.BREAKFAST_ROOM = 0x0A +AreaTypeTag.CARPORT = 0x0B +AreaTypeTag.CELLAR = 0x0C +AreaTypeTag.CLOAKROOM = 0x0D +AreaTypeTag.CLOSET = 0x0E +AreaTypeTag.CONSERVATORY = 0x0F +AreaTypeTag.CORRIDOR = 0x10 +AreaTypeTag.CRAFT_ROOM = 0x11 +AreaTypeTag.CUPBOARD = 0x12 +AreaTypeTag.DECK = 0x13 +AreaTypeTag.DEN = 0x14 +AreaTypeTag.DINING = 0x15 +AreaTypeTag.DRAWING_ROOM = 0x16 +AreaTypeTag.DRESSING_ROOM = 0x17 +AreaTypeTag.DRIVEWAY = 0x18 +AreaTypeTag.ELEVATOR = 0x19 +AreaTypeTag.ENSUITE = 0x1A +AreaTypeTag.ENTRANCE = 0x1B +AreaTypeTag.ENTRYWAY = 0x1C +AreaTypeTag.FAMILY_ROOM = 0x1D +AreaTypeTag.FOYER = 0x1E +AreaTypeTag.FRONT_DOOR = 0x1F +AreaTypeTag.FRONT_YARD = 0x20 +AreaTypeTag.GAME_ROOM = 0x21 +AreaTypeTag.GARAGE = 0x22 +AreaTypeTag.GARAGE_DOOR = 0x23 +AreaTypeTag.GARDEN = 0x24 +AreaTypeTag.GARDEN_DOOR = 0x25 +AreaTypeTag.GUEST_BATHROOM = 0x26 +AreaTypeTag.GUEST_BEDROOM = 0x27 +AreaTypeTag.GUEST_RESTROOM = 0x28 +AreaTypeTag.GUEST_ROOM = 0x29 +AreaTypeTag.GYM = 0x2A +AreaTypeTag.HALLWAY = 0x2B +AreaTypeTag.HEARTH_ROOM = 0x2C +AreaTypeTag.KIDS_ROOM = 0x2D +AreaTypeTag.KIDS_BEDROOM = 0x2E +AreaTypeTag.KITCHEN = 0x2F +AreaTypeTag.LARDER = 0x30 +AreaTypeTag.LAUNDRY_ROOM = 0x31 +AreaTypeTag.LAWN = 0x32 +AreaTypeTag.LIBRARY = 0x33 +AreaTypeTag.LIVING_ROOM = 0x34 +AreaTypeTag.LOUNGE = 0x35 +AreaTypeTag.MEDIA_TV_ROOM = 0x36 +AreaTypeTag.MUD_ROOM = 0x37 +AreaTypeTag.MUSIC_ROOM = 0x38 +AreaTypeTag.NURSERY = 0x39 +AreaTypeTag.OFFICE = 0x3A +AreaTypeTag.OUTDOOR_KITCHEN = 0x3B +AreaTypeTag.OUTSIDE = 0x3C +AreaTypeTag.PANTRY = 0x3D +AreaTypeTag.PARKING_LOT = 0x3E +AreaTypeTag.PARLOR = 0x3F +AreaTypeTag.PATIO = 0x40 +AreaTypeTag.PLAY_ROOM = 0x41 +AreaTypeTag.POOL_ROOM = 0x42 +AreaTypeTag.PORCH = 0x43 +AreaTypeTag.PRIMARY_BATHROOM = 0x44 +AreaTypeTag.PRIMARY_BEDROOM = 0x45 +AreaTypeTag.RAMP = 0x46 +AreaTypeTag.RECEPTION_ROOM = 0x47 +AreaTypeTag.RECREATION_ROOM = 0x48 +AreaTypeTag.RESTROOM = 0x49 +AreaTypeTag.ROOF = 0x4A +AreaTypeTag.SAUNA = 0x4B +AreaTypeTag.SCULLERY = 0x4C +AreaTypeTag.SEWING_ROOM = 0x4D +AreaTypeTag.SHED = 0x4E +AreaTypeTag.SIDE_DOOR = 0x4F +AreaTypeTag.SIDE_YARD = 0x50 +AreaTypeTag.SITTING_ROOM = 0x51 +AreaTypeTag.SNUG = 0x52 +AreaTypeTag.SPA = 0x53 +AreaTypeTag.STAIRCASE = 0x54 +AreaTypeTag.STEAM_ROOM = 0x55 +AreaTypeTag.STORAGE_ROOM = 0x56 +AreaTypeTag.STUDIO = 0x57 +AreaTypeTag.STUDY = 0x58 +AreaTypeTag.SUN_ROOM = 0x59 +AreaTypeTag.SWIMMING_POOL = 0x5A +AreaTypeTag.TERRACE = 0x5B +AreaTypeTag.UTILITY_ROOM = 0x5C +AreaTypeTag.WARD = 0x5D +AreaTypeTag.WORKSHOP = 0x5E + +AreaTypeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(AreaTypeTag, new_mt) + +return AreaTypeTag + diff --git a/drivers/SmartThings/matter-rvc/src/Global/types/LandmarkTag.lua b/drivers/SmartThings/matter-rvc/src/Global/types/LandmarkTag.lua new file mode 100644 index 0000000000..1c70376a78 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/Global/types/LandmarkTag.lua @@ -0,0 +1,247 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +--- @class Global.types.LandmarkTag: st.matter.data_types.Uint8 +--- @alias LandmarkTag +--- +--- @field public byte_length number 1 +--- @field public AIR_CONDITIONER number 0 +--- @field public AIR_PURIFIER number 1 +--- @field public BACK_DOOR number 2 +--- @field public BAR_STOOL number 3 +--- @field public BATH_MAT number 4 +--- @field public BATHTUB number 5 +--- @field public BED number 6 +--- @field public BOOKSHELF number 7 +--- @field public CHAIR number 8 +--- @field public CHRISTMAS_TREE number 9 +--- @field public COAT_RACK number 10 +--- @field public COFFEE_TABLE number 11 +--- @field public COOKING_RANGE number 12 +--- @field public COUCH number 13 +--- @field public COUNTERTOP number 14 +--- @field public CRADLE number 15 +--- @field public CRIB number 16 +--- @field public DESK number 17 +--- @field public DINING_TABLE number 18 +--- @field public DISHWASHER number 19 +--- @field public DOOR number 20 +--- @field public DRESSER number 21 +--- @field public LAUNDRY_DRYER number 22 +--- @field public FAN number 23 +--- @field public FIREPLACE number 24 +--- @field public FREEZER number 25 +--- @field public FRONT_DOOR number 26 +--- @field public HIGH_CHAIR number 27 +--- @field public KITCHEN_ISLAND number 28 +--- @field public LAMP number 29 +--- @field public LITTER_BOX number 30 +--- @field public MIRROR number 31 +--- @field public NIGHTSTAND number 32 +--- @field public OVEN number 33 +--- @field public PET_BED number 34 +--- @field public PET_BOWL number 35 +--- @field public PET_CRATE number 36 +--- @field public REFRIGERATOR number 37 +--- @field public SCRATCHING_POST number 38 +--- @field public SHOE_RACK number 39 +--- @field public SHOWER number 40 +--- @field public SIDE_DOOR number 41 +--- @field public SINK number 42 +--- @field public SOFA number 43 +--- @field public STOVE number 44 +--- @field public TABLE number 45 +--- @field public TOILET number 46 +--- @field public TRASH_CAN number 47 +--- @field public LAUNDRY_WASHER number 48 +--- @field public WINDOW number 49 +--- @field public WINE_COOLER number 50 + +local LandmarkTag = {} +local new_mt = UintABC.new_mt({NAME = "LandmarkTag", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.AIR_CONDITIONER] = "AIR_CONDITIONER", + [self.AIR_PURIFIER] = "AIR_PURIFIER", + [self.BACK_DOOR] = "BACK_DOOR", + [self.BAR_STOOL] = "BAR_STOOL", + [self.BATH_MAT] = "BATH_MAT", + [self.BATHTUB] = "BATHTUB", + [self.BED] = "BED", + [self.BOOKSHELF] = "BOOKSHELF", + [self.CHAIR] = "CHAIR", + [self.CHRISTMAS_TREE] = "CHRISTMAS_TREE", + [self.COAT_RACK] = "COAT_RACK", + [self.COFFEE_TABLE] = "COFFEE_TABLE", + [self.COOKING_RANGE] = "COOKING_RANGE", + [self.COUCH] = "COUCH", + [self.COUNTERTOP] = "COUNTERTOP", + [self.CRADLE] = "CRADLE", + [self.CRIB] = "CRIB", + [self.DESK] = "DESK", + [self.DINING_TABLE] = "DINING_TABLE", + [self.DISHWASHER] = "DISHWASHER", + [self.DOOR] = "DOOR", + [self.DRESSER] = "DRESSER", + [self.LAUNDRY_DRYER] = "LAUNDRY_DRYER", + [self.FAN] = "FAN", + [self.FIREPLACE] = "FIREPLACE", + [self.FREEZER] = "FREEZER", + [self.FRONT_DOOR] = "FRONT_DOOR", + [self.HIGH_CHAIR] = "HIGH_CHAIR", + [self.KITCHEN_ISLAND] = "KITCHEN_ISLAND", + [self.LAMP] = "LAMP", + [self.LITTER_BOX] = "LITTER_BOX", + [self.MIRROR] = "MIRROR", + [self.NIGHTSTAND] = "NIGHTSTAND", + [self.OVEN] = "OVEN", + [self.PET_BED] = "PET_BED", + [self.PET_BOWL] = "PET_BOWL", + [self.PET_CRATE] = "PET_CRATE", + [self.REFRIGERATOR] = "REFRIGERATOR", + [self.SCRATCHING_POST] = "SCRATCHING_POST", + [self.SHOE_RACK] = "SHOE_RACK", + [self.SHOWER] = "SHOWER", + [self.SIDE_DOOR] = "SIDE_DOOR", + [self.SINK] = "SINK", + [self.SOFA] = "SOFA", + [self.STOVE] = "STOVE", + [self.TABLE] = "TABLE", + [self.TOILET] = "TOILET", + [self.TRASH_CAN] = "TRASH_CAN", + [self.LAUNDRY_WASHER] = "LAUNDRY_WASHER", + [self.WINDOW] = "WINDOW", + [self.WINE_COOLER] = "WINE_COOLER", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.AIR_CONDITIONER = 0x00 +new_mt.__index.AIR_PURIFIER = 0x01 +new_mt.__index.BACK_DOOR = 0x02 +new_mt.__index.BAR_STOOL = 0x03 +new_mt.__index.BATH_MAT = 0x04 +new_mt.__index.BATHTUB = 0x05 +new_mt.__index.BED = 0x06 +new_mt.__index.BOOKSHELF = 0x07 +new_mt.__index.CHAIR = 0x08 +new_mt.__index.CHRISTMAS_TREE = 0x09 +new_mt.__index.COAT_RACK = 0x0A +new_mt.__index.COFFEE_TABLE = 0x0B +new_mt.__index.COOKING_RANGE = 0x0C +new_mt.__index.COUCH = 0x0D +new_mt.__index.COUNTERTOP = 0x0E +new_mt.__index.CRADLE = 0x0F +new_mt.__index.CRIB = 0x10 +new_mt.__index.DESK = 0x11 +new_mt.__index.DINING_TABLE = 0x12 +new_mt.__index.DISHWASHER = 0x13 +new_mt.__index.DOOR = 0x14 +new_mt.__index.DRESSER = 0x15 +new_mt.__index.LAUNDRY_DRYER = 0x16 +new_mt.__index.FAN = 0x17 +new_mt.__index.FIREPLACE = 0x18 +new_mt.__index.FREEZER = 0x19 +new_mt.__index.FRONT_DOOR = 0x1A +new_mt.__index.HIGH_CHAIR = 0x1B +new_mt.__index.KITCHEN_ISLAND = 0x1C +new_mt.__index.LAMP = 0x1D +new_mt.__index.LITTER_BOX = 0x1E +new_mt.__index.MIRROR = 0x1F +new_mt.__index.NIGHTSTAND = 0x20 +new_mt.__index.OVEN = 0x21 +new_mt.__index.PET_BED = 0x22 +new_mt.__index.PET_BOWL = 0x23 +new_mt.__index.PET_CRATE = 0x24 +new_mt.__index.REFRIGERATOR = 0x25 +new_mt.__index.SCRATCHING_POST = 0x26 +new_mt.__index.SHOE_RACK = 0x27 +new_mt.__index.SHOWER = 0x28 +new_mt.__index.SIDE_DOOR = 0x29 +new_mt.__index.SINK = 0x2A +new_mt.__index.SOFA = 0x2B +new_mt.__index.STOVE = 0x2C +new_mt.__index.TABLE = 0x2D +new_mt.__index.TOILET = 0x2E +new_mt.__index.TRASH_CAN = 0x2F +new_mt.__index.LAUNDRY_WASHER = 0x30 +new_mt.__index.WINDOW = 0x31 +new_mt.__index.WINE_COOLER = 0x32 + +LandmarkTag.AIR_CONDITIONER = 0x00 +LandmarkTag.AIR_PURIFIER = 0x01 +LandmarkTag.BACK_DOOR = 0x02 +LandmarkTag.BAR_STOOL = 0x03 +LandmarkTag.BATH_MAT = 0x04 +LandmarkTag.BATHTUB = 0x05 +LandmarkTag.BED = 0x06 +LandmarkTag.BOOKSHELF = 0x07 +LandmarkTag.CHAIR = 0x08 +LandmarkTag.CHRISTMAS_TREE = 0x09 +LandmarkTag.COAT_RACK = 0x0A +LandmarkTag.COFFEE_TABLE = 0x0B +LandmarkTag.COOKING_RANGE = 0x0C +LandmarkTag.COUCH = 0x0D +LandmarkTag.COUNTERTOP = 0x0E +LandmarkTag.CRADLE = 0x0F +LandmarkTag.CRIB = 0x10 +LandmarkTag.DESK = 0x11 +LandmarkTag.DINING_TABLE = 0x12 +LandmarkTag.DISHWASHER = 0x13 +LandmarkTag.DOOR = 0x14 +LandmarkTag.DRESSER = 0x15 +LandmarkTag.LAUNDRY_DRYER = 0x16 +LandmarkTag.FAN = 0x17 +LandmarkTag.FIREPLACE = 0x18 +LandmarkTag.FREEZER = 0x19 +LandmarkTag.FRONT_DOOR = 0x1A +LandmarkTag.HIGH_CHAIR = 0x1B +LandmarkTag.KITCHEN_ISLAND = 0x1C +LandmarkTag.LAMP = 0x1D +LandmarkTag.LITTER_BOX = 0x1E +LandmarkTag.MIRROR = 0x1F +LandmarkTag.NIGHTSTAND = 0x20 +LandmarkTag.OVEN = 0x21 +LandmarkTag.PET_BED = 0x22 +LandmarkTag.PET_BOWL = 0x23 +LandmarkTag.PET_CRATE = 0x24 +LandmarkTag.REFRIGERATOR = 0x25 +LandmarkTag.SCRATCHING_POST = 0x26 +LandmarkTag.SHOE_RACK = 0x27 +LandmarkTag.SHOWER = 0x28 +LandmarkTag.SIDE_DOOR = 0x29 +LandmarkTag.SINK = 0x2A +LandmarkTag.SOFA = 0x2B +LandmarkTag.STOVE = 0x2C +LandmarkTag.TABLE = 0x2D +LandmarkTag.TOILET = 0x2E +LandmarkTag.TRASH_CAN = 0x2F +LandmarkTag.LAUNDRY_WASHER = 0x30 +LandmarkTag.WINDOW = 0x31 +LandmarkTag.WINE_COOLER = 0x32 + +LandmarkTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LandmarkTag, new_mt) + +return LandmarkTag + diff --git a/drivers/SmartThings/matter-rvc/src/Global/types/LocationDescriptorStruct.lua b/drivers/SmartThings/matter-rvc/src/Global/types/LocationDescriptorStruct.lua new file mode 100644 index 0000000000..facd03def3 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/Global/types/LocationDescriptorStruct.lua @@ -0,0 +1,106 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +--- @alias LocationDescriptorStruct +--- @class Global.types.LocationDescriptorStruct: st.matter.data_types.Structure +--- +--- @field public location_name st.matter.data_types.UTF8String1 +--- @field public floor_number st.matter.data_types.Int16 +--- @field public area_type Global.types.AreaTypeTag +local LocationDescriptorStruct = {} +local new_mt = StructureABC.new_mt({NAME = "LocationDescriptorStruct", ID = data_types.name_to_id_map["Structure"]}) + +LocationDescriptorStruct.field_defs = { + { + name = "location_name", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "floor_number", + field_id = 1, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Int16", + }, + { + name = "area_type", + field_id = 2, + is_nullable = true, + is_optional = false, + data_type = require "Global.types.AreaTypeTag", + }, +} + +LocationDescriptorStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +LocationDescriptorStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = LocationDescriptorStruct.init +new_mt.__index.serialize = LocationDescriptorStruct.serialize + +LocationDescriptorStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(LocationDescriptorStruct, new_mt) + +return LocationDescriptorStruct + diff --git a/drivers/SmartThings/matter-rvc/src/Global/types/RelativePositionTag.lua b/drivers/SmartThings/matter-rvc/src/Global/types/RelativePositionTag.lua new file mode 100644 index 0000000000..d351e592a9 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/Global/types/RelativePositionTag.lua @@ -0,0 +1,71 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +--- @class Global.types.RelativePositionTag: st.matter.data_types.Uint8 +--- @alias RelativePositionTag +--- +--- @field public byte_length number 1 +--- @field public UNDER number 0 +--- @field public NEXT_TO number 1 +--- @field public AROUND number 2 +--- @field public ON number 3 +--- @field public ABOVE number 4 +--- @field public FRONT_OF number 5 +--- @field public BEHIND number 6 + +local RelativePositionTag = {} +local new_mt = UintABC.new_mt({NAME = "RelativePositionTag", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNDER] = "UNDER", + [self.NEXT_TO] = "NEXT_TO", + [self.AROUND] = "AROUND", + [self.ON] = "ON", + [self.ABOVE] = "ABOVE", + [self.FRONT_OF] = "FRONT_OF", + [self.BEHIND] = "BEHIND", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNDER = 0x00 +new_mt.__index.NEXT_TO = 0x01 +new_mt.__index.AROUND = 0x02 +new_mt.__index.ON = 0x03 +new_mt.__index.ABOVE = 0x04 +new_mt.__index.FRONT_OF = 0x05 +new_mt.__index.BEHIND = 0x06 + +RelativePositionTag.UNDER = 0x00 +RelativePositionTag.NEXT_TO = 0x01 +RelativePositionTag.AROUND = 0x02 +RelativePositionTag.ON = 0x03 +RelativePositionTag.ABOVE = 0x04 +RelativePositionTag.FRONT_OF = 0x05 +RelativePositionTag.BEHIND = 0x06 + +RelativePositionTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(RelativePositionTag, new_mt) + +return RelativePositionTag + diff --git a/drivers/SmartThings/matter-rvc/src/Global/types/init.lua b/drivers/SmartThings/matter-rvc/src/Global/types/init.lua new file mode 100644 index 0000000000..55f3c97bd7 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/Global/types/init.lua @@ -0,0 +1,36 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("Global.types." .. key) + end + return types_mt.__types_cache[key] +end + +--- @class GlobalTypes +--- +--- @field public RelativePositionTag Global.types.RelativePositionTag +--- @field public LandmarkTag Global.types.LandmarkTag +--- @field public AreaTypeTag Global.types.AreaTypeTag + +local GlobalTypes = {} + +setmetatable(GlobalTypes, types_mt) + +return GlobalTypes diff --git a/drivers/SmartThings/matter-rvc/src/OperationalState/init.lua b/drivers/SmartThings/matter-rvc/src/OperationalState/init.lua new file mode 100644 index 0000000000..303af8f5ed --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/OperationalState/init.lua @@ -0,0 +1,11 @@ +local OperationalStateTypes = require "OperationalState.types" + +local OperationalState = {} + +OperationalState.ID = 0x0060 +OperationalState.NAME = "OperationalState" +OperationalState.server = {} +OperationalState.client = {} +OperationalState.types = OperationalStateTypes + +return OperationalState diff --git a/drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateEnum.lua b/drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateEnum.lua new file mode 100644 index 0000000000..df4c3f72e0 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateEnum.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ErrorStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "ErrorStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NO_ERROR] = "NO_ERROR", + [self.UNABLE_TO_START_OR_RESUME] = "UNABLE_TO_START_OR_RESUME", + [self.UNABLE_TO_COMPLETE_OPERATION] = "UNABLE_TO_COMPLETE_OPERATION", + [self.COMMAND_INVALID_IN_STATE] = "COMMAND_INVALID_IN_STATE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NO_ERROR = 0x00 +new_mt.__index.UNABLE_TO_START_OR_RESUME = 0x01 +new_mt.__index.UNABLE_TO_COMPLETE_OPERATION = 0x02 +new_mt.__index.COMMAND_INVALID_IN_STATE = 0x03 + +ErrorStateEnum.NO_ERROR = 0x00 +ErrorStateEnum.UNABLE_TO_START_OR_RESUME = 0x01 +ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION = 0x02 +ErrorStateEnum.COMMAND_INVALID_IN_STATE = 0x03 + +ErrorStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ErrorStateEnum, new_mt) + +return ErrorStateEnum diff --git a/drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateStruct.lua b/drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateStruct.lua new file mode 100644 index 0000000000..5b3f3a0105 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/OperationalState/types/ErrorStateStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ErrorStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ErrorStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +ErrorStateStruct.field_defs = { + { + name = "error_state_id", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "error_state_label", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "error_state_details", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +ErrorStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ErrorStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ErrorStateStruct.init +new_mt.__index.serialize = ErrorStateStruct.serialize + +ErrorStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ErrorStateStruct, new_mt) + +return ErrorStateStruct diff --git a/drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateEnum.lua b/drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateEnum.lua new file mode 100644 index 0000000000..a183f65383 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateEnum.lua @@ -0,0 +1,33 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local OperationalStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "OperationalStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.STOPPED] = "STOPPED", + [self.RUNNING] = "RUNNING", + [self.PAUSED] = "PAUSED", + [self.ERROR] = "ERROR", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.STOPPED = 0x00 +new_mt.__index.RUNNING = 0x01 +new_mt.__index.PAUSED = 0x02 +new_mt.__index.ERROR = 0x03 + +OperationalStateEnum.STOPPED = 0x00 +OperationalStateEnum.RUNNING = 0x01 +OperationalStateEnum.PAUSED = 0x02 +OperationalStateEnum.ERROR = 0x03 + +OperationalStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(OperationalStateEnum, new_mt) + +return OperationalStateEnum diff --git a/drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateStruct.lua b/drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateStruct.lua new file mode 100644 index 0000000000..f421d8c796 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/OperationalState/types/OperationalStateStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OperationalStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "OperationalStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +OperationalStateStruct.field_defs = { + { + name = "operational_state_id", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "operational_state_label", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +OperationalStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +OperationalStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = OperationalStateStruct.init +new_mt.__index.serialize = OperationalStateStruct.serialize + +OperationalStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(OperationalStateStruct, new_mt) + +return OperationalStateStruct diff --git a/drivers/SmartThings/matter-rvc/src/OperationalState/types/init.lua b/drivers/SmartThings/matter-rvc/src/OperationalState/types/init.lua new file mode 100644 index 0000000000..8c735ed06a --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/OperationalState/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("OperationalState.types." .. key) + end + return types_mt.__types_cache[key] +end + +local OperationalStateTypes = {} + +setmetatable(OperationalStateTypes, types_mt) + +return OperationalStateTypes diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/ChangeToModeResponse.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/ChangeToModeResponse.lua new file mode 100644 index 0000000000..22b8a3247e --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/ChangeToModeResponse.lua @@ -0,0 +1,108 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToModeResponse = {} + +ChangeToModeResponse.NAME = "ChangeToModeResponse" +ChangeToModeResponse.ID = 0x0001 +ChangeToModeResponse.field_defs = { + { + name = "status", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "status_text", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +function ChangeToModeResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToModeResponse:build_test_command_response(device, endpoint_id, status, status_text, interaction_status) + local function init(self, device, endpoint_id, status, status_text) + local out = {} + local args = {status, status_text} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToModeResponse, + __tostring = ChangeToModeResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) + end + local self_request = init(self, device, endpoint_id, status, status_text) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function ChangeToModeResponse:init() + return nil +end + +function ChangeToModeResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToModeResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToModeResponse, {__call = ChangeToModeResponse.init}) + +return ChangeToModeResponse diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/init.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/init.lua new file mode 100644 index 0000000000..0fdc38f73f --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/client/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("RvcCleanMode.client.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local RvcCleanModeClientCommands = {} + +function RvcCleanModeClientCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcCleanModeClientCommands, command_mt) + +return RvcCleanModeClientCommands diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/init.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/init.lua new file mode 100644 index 0000000000..dd1aefb829 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/init.lua @@ -0,0 +1,103 @@ +local cluster_base = require "st.matter.cluster_base" +local RvcCleanModeServerAttributes = require "RvcCleanMode.server.attributes" +local RvcCleanModeServerCommands = require "RvcCleanMode.server.commands" +local RvcCleanModeClientCommands = require "RvcCleanMode.client.commands" +local RvcCleanModeTypes = require "RvcCleanMode.types" + +local RvcCleanMode = {} + +RvcCleanMode.ID = 0x0055 +RvcCleanMode.NAME = "RvcCleanMode" +RvcCleanMode.server = {} +RvcCleanMode.client = {} +RvcCleanMode.server.attributes = RvcCleanModeServerAttributes:set_parent_cluster(RvcCleanMode) +RvcCleanMode.server.commands = RvcCleanModeServerCommands:set_parent_cluster(RvcCleanMode) +RvcCleanMode.client.commands = RvcCleanModeClientCommands:set_parent_cluster(RvcCleanMode) +RvcCleanMode.types = RvcCleanModeTypes + +function RvcCleanMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0x0002] = "StartUpMode", + [0x0003] = "OnMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function RvcCleanMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function RvcCleanMode:get_client_command_by_id(command_id) + local client_id_map = { + [0x0001] = "ChangeToModeResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +RvcCleanMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["StartUpMode"] = "server", + ["OnMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +RvcCleanMode.command_direction_map = { + ["ChangeToMode"] = "server", + ["ChangeToModeResponse"] = "client", +} + +RvcCleanMode.FeatureMap = RvcCleanMode.types.Feature + +function RvcCleanMode.are_features_supported(feature, feature_map) + if (RvcCleanMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = RvcCleanMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, RvcCleanMode.NAME)) + end + return RvcCleanMode[direction].attributes[key] +end +RvcCleanMode.attributes = {} +setmetatable(RvcCleanMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = RvcCleanMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, RvcCleanMode.NAME)) + end + return RvcCleanMode[direction].commands[key] +end +RvcCleanMode.commands = {} +setmetatable(RvcCleanMode.commands, command_helper_mt) + +setmetatable(RvcCleanMode, {__index = cluster_base}) + +return RvcCleanMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..eea1df2db1 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/CurrentMode.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..f75efd5cad --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/SupportedModes.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "RvcCleanMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/init.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/init.lua new file mode 100644 index 0000000000..6ccc2b06dc --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("RvcCleanMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local RvcCleanModeServerAttributes = {} + +function RvcCleanModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcCleanModeServerAttributes, attr_mt) + +return RvcCleanModeServerAttributes diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..4e492640a9 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/ChangeToMode.lua @@ -0,0 +1,86 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/init.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/init.lua new file mode 100644 index 0000000000..b55ea8dd6d --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("RvcCleanMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local RvcCleanModeServerCommands = {} + +function RvcCleanModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcCleanModeServerCommands, command_mt) + +return RvcCleanModeServerCommands diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/Feature.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/Feature.lua new file mode 100644 index 0000000000..7f38a4994f --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/Feature.lua @@ -0,0 +1,53 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..c69d310012 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeOptionStruct.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "RvcCleanMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTag.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTag.lua new file mode 100644 index 0000000000..a495bde468 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTag.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.DEEP_CLEAN] = "DEEP_CLEAN", + [self.VACUUM] = "VACUUM", + [self.MOP] = "MOP", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.DEEP_CLEAN = 0x4000 +new_mt.__index.VACUUM = 0x4001 +new_mt.__index.MOP = 0x4002 + +ModeTag.DEEP_CLEAN = 0x4000 +ModeTag.VACUUM = 0x4001 +ModeTag.MOP = 0x4002 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..610fb90ad7 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/ModeTagStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/StatusCode.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/StatusCode.lua new file mode 100644 index 0000000000..66cc49610f --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/StatusCode.lua @@ -0,0 +1,24 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local StatusCode = {} +local new_mt = UintABC.new_mt({NAME = "StatusCode", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.CLEANING_IN_PROGRESS] = "CLEANING_IN_PROGRESS", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.CLEANING_IN_PROGRESS = 0x40 + +StatusCode.CLEANING_IN_PROGRESS = 0x40 + +StatusCode.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(StatusCode, new_mt) + +return StatusCode diff --git a/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/init.lua b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/init.lua new file mode 100644 index 0000000000..c9793a7dc4 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcCleanMode/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("RvcCleanMode.types." .. key) + end + return types_mt.__types_cache[key] +end + +local RvcCleanModeTypes = {} + +setmetatable(RvcCleanModeTypes, types_mt) + +return RvcCleanModeTypes diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/init.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/init.lua new file mode 100644 index 0000000000..3be13eaef6 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/init.lua @@ -0,0 +1,113 @@ +local cluster_base = require "st.matter.cluster_base" +local RvcOperationalStateServerAttributes = require "RvcOperationalState.server.attributes" +local RvcOperationalStateServerCommands = require "RvcOperationalState.server.commands" +local RvcOperationalStateEvents = require "RvcOperationalState.server.events" +local RvcOperationalStateTypes = require "RvcOperationalState.types" + +local RvcOperationalState = {} + +RvcOperationalState.ID = 0x0061 +RvcOperationalState.NAME = "RvcOperationalState" +RvcOperationalState.server = {} +RvcOperationalState.server.attributes = RvcOperationalStateServerAttributes:set_parent_cluster(RvcOperationalState) +RvcOperationalState.server.commands = RvcOperationalStateServerCommands:set_parent_cluster(RvcOperationalState) +RvcOperationalState.server.events = RvcOperationalStateEvents:set_parent_cluster(RvcOperationalState) +RvcOperationalState.types = RvcOperationalStateTypes + +function RvcOperationalState:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "PhaseList", + [0x0001] = "CurrentPhase", + [0x0002] = "CountdownTime", + [0x0003] = "OperationalStateList", + [0x0004] = "OperationalState", + [0x0005] = "OperationalError", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function RvcOperationalState:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "Pause", + [0x0001] = "Stop", + [0x0002] = "Start", + [0x0003] = "Resume", + [0x0080] = "GoHome", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function RvcOperationalState:get_event_by_id(event_id) + local event_id_map = { + [0x0000] = "OperationalError", + [0x0001] = "OperationCompletion", + } + if event_id_map[event_id] ~= nil then + return self.server.events[event_id_map[event_id]] + end + return nil +end + +RvcOperationalState.attribute_direction_map = { + ["PhaseList"] = "server", + ["CurrentPhase"] = "server", + ["CountdownTime"] = "server", + ["OperationalStateList"] = "server", + ["OperationalState"] = "server", + ["OperationalError"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +RvcOperationalState.command_direction_map = { + ["Pause"] = "server", + ["Stop"] = "server", + ["Start"] = "server", + ["Resume"] = "server", + ["GoHome"] = "server", + ["OperationalCommandResponse"] = "client", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = RvcOperationalState.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, RvcOperationalState.NAME)) + end + return RvcOperationalState[direction].attributes[key] +end +RvcOperationalState.attributes = {} +setmetatable(RvcOperationalState.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = RvcOperationalState.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, RvcOperationalState.NAME)) + end + return RvcOperationalState[direction].commands[key] +end +RvcOperationalState.commands = {} +setmetatable(RvcOperationalState.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return RvcOperationalState.server.events[key] +end +RvcOperationalState.events = {} +setmetatable(RvcOperationalState.events, event_helper_mt) + +setmetatable(RvcOperationalState, {__index = cluster_base}) + +return RvcOperationalState diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..dd92f54543 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,75 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList + diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalError.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalError.lua new file mode 100644 index 0000000000..4f6ce2fb06 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalError.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperationalError = { + ID = 0x0005, + NAME = "OperationalError", + base_type = require "RvcOperationalState.types.ErrorStateStruct", +} + +function OperationalError:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OperationalError:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalError:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalError:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperationalError:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalError:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OperationalError, {__call = OperationalError.new_value, __index = OperationalError.base_type}) +return OperationalError diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalState.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalState.lua new file mode 100644 index 0000000000..eb403ebf3a --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalState.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperationalState = { + ID = 0x0004, + NAME = "OperationalState", + base_type = require "st.matter.data_types.Uint8", +} + +function OperationalState:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function OperationalState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function OperationalState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperationalState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(OperationalState, {__call = OperationalState.new_value, __index = OperationalState.base_type}) +return OperationalState diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalStateList.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalStateList.lua new file mode 100644 index 0000000000..2291c911ba --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/OperationalStateList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OperationalStateList = { + ID = 0x0003, + NAME = "OperationalStateList", + base_type = require "st.matter.data_types.Array", + element_type = require "RvcOperationalState.types.OperationalStateStruct", +} + +function OperationalStateList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, OperationalStateList.element_type) + end +end + +function OperationalStateList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OperationalStateList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalStateList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OperationalStateList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OperationalStateList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalStateList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OperationalStateList, {__call = OperationalStateList.new_value, __index = OperationalStateList.base_type}) +return OperationalStateList diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/init.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/init.lua new file mode 100644 index 0000000000..e5b4bf8583 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("RvcOperationalState.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local RvcOperationalStateServerAttributes = {} + +function RvcOperationalStateServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcOperationalStateServerAttributes, attr_mt) + +return RvcOperationalStateServerAttributes diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/GoHome.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/GoHome.lua new file mode 100644 index 0000000000..4328914a1a --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/GoHome.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GoHome = {} + +GoHome.NAME = "GoHome" +GoHome.ID = 0x0080 +GoHome.field_defs = { +} + +function GoHome:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = GoHome, + __tostring = GoHome.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function GoHome:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GoHome:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function GoHome:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GoHome, {__call = GoHome.init}) + +return GoHome diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Pause.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Pause.lua new file mode 100644 index 0000000000..c9ee879783 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Pause.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Pause = {} + +Pause.NAME = "Pause" +Pause.ID = 0x0000 +Pause.field_defs = { +} + +function Pause:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Pause, + __tostring = Pause.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Pause:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Pause:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Pause:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Pause, {__call = Pause.init}) + +return Pause diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Resume.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Resume.lua new file mode 100644 index 0000000000..fa397af871 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Resume.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Resume = {} + +Resume.NAME = "Resume" +Resume.ID = 0x0003 +Resume.field_defs = { +} + +function Resume:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Resume, + __tostring = Resume.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Resume:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Resume:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Resume:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Resume, {__call = Resume.init}) + +return Resume diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Start.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Start.lua new file mode 100644 index 0000000000..297ba1b4c2 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Start.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Start = {} + +Start.NAME = "Start" +Start.ID = 0x0002 +Start.field_defs = { +} + +function Start:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Start, + __tostring = Start.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Start:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Start:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Start:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Start, {__call = Start.init}) + +return Start diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Stop.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Stop.lua new file mode 100644 index 0000000000..67ea96d5bc --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/Stop.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Stop = {} + +Stop.NAME = "Stop" +Stop.ID = 0x0001 +Stop.field_defs = { +} + +function Stop:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Stop, + __tostring = Stop.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Stop:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Stop:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Stop:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Stop, {__call = Stop.init}) + +return Stop diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/init.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/init.lua new file mode 100644 index 0000000000..4180142aa1 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("RvcOperationalState.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local RvcOperationalStateServerCommands = {} + +function RvcOperationalStateServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcOperationalStateServerCommands, command_mt) + +return RvcOperationalStateServerCommands diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/OperationalError.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/OperationalError.lua new file mode 100644 index 0000000000..52aa5dd9a3 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/OperationalError.lua @@ -0,0 +1,100 @@ +local data_types = require "st.matter.data_types" +local cluster_base = require "st.matter.cluster_base" +local TLVParser = require "st.matter.TLV.TLVParser" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OperationalError = { + ID = 0x0000, + NAME = "OperationalError", + base_type = data_types.Structure, +} + +OperationalError.field_defs = { + { + name = "error_state", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "RvcOperationalState.types.ErrorStateStruct", + }, +} + +function OperationalError:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and not + ((field_def.is_nullable or field_def.is_optional) and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function OperationalError:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function OperationalError:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + nil, + self.ID + ) +end + +function OperationalError:build_test_event_report( + device, + endpoint_id, + fields, + status +) + local data = {} + data.elements = {} + data.num_elements = 0 + setmetatable(data, StructureABC.new_mt({NAME = "OperationalErrorEventData", ID = 0x15})) + for idx, field_def in ipairs(self.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not fields[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif fields[field_def.name] then + data.elements[field_def.name] = data_types.validate_or_build_type(fields[field_def.name], field_def.data_type, field_def.name) + data.elements[field_def.name].field_id = field_def.field_id + data.num_elements = data.num_elements + 1 + end + end + return cluster_base.build_test_event_report( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OperationalError:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +function OperationalError:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +return OperationalError diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/init.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/init.lua new file mode 100644 index 0000000000..d2489a44b4 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/server/events/init.lua @@ -0,0 +1,23 @@ +local event_mt = {} +event_mt.__event_cache = {} +event_mt.__index = function(self, key) + if event_mt.__event_cache[key] == nil then + local req_loc = string.format("RvcOperationalState.server.events.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + event_mt.__event_cache[key] = raw_def + end + return event_mt.__event_cache[key] +end + +local RvcOperationalStateEvents = {} + +function RvcOperationalStateEvents:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcOperationalStateEvents, event_mt) + +return RvcOperationalStateEvents diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateEnum.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateEnum.lua new file mode 100644 index 0000000000..af6e6f23d0 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateEnum.lua @@ -0,0 +1,45 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ErrorStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "ErrorStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.FAILED_TO_FIND_CHARGING_DOCK] = "FAILED_TO_FIND_CHARGING_DOCK", + [self.STUCK] = "STUCK", + [self.DUST_BIN_MISSING] = "DUST_BIN_MISSING", + [self.DUST_BIN_FULL] = "DUST_BIN_FULL", + [self.WATER_TANK_EMPTY] = "WATER_TANK_EMPTY", + [self.WATER_TANK_MISSING] = "WATER_TANK_MISSING", + [self.WATER_TANK_LID_OPEN] = "WATER_TANK_LID_OPEN", + [self.MOP_CLEANING_PAD_MISSING] = "MOP_CLEANING_PAD_MISSING", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.FAILED_TO_FIND_CHARGING_DOCK = 0x40 +new_mt.__index.STUCK = 0x41 +new_mt.__index.DUST_BIN_MISSING = 0x42 +new_mt.__index.DUST_BIN_FULL = 0x43 +new_mt.__index.WATER_TANK_EMPTY = 0x44 +new_mt.__index.WATER_TANK_MISSING = 0x45 +new_mt.__index.WATER_TANK_LID_OPEN = 0x46 +new_mt.__index.MOP_CLEANING_PAD_MISSING = 0x47 + +ErrorStateEnum.FAILED_TO_FIND_CHARGING_DOCK = 0x40 +ErrorStateEnum.STUCK = 0x41 +ErrorStateEnum.DUST_BIN_MISSING = 0x42 +ErrorStateEnum.DUST_BIN_FULL = 0x43 +ErrorStateEnum.WATER_TANK_EMPTY = 0x44 +ErrorStateEnum.WATER_TANK_MISSING = 0x45 +ErrorStateEnum.WATER_TANK_LID_OPEN = 0x46 +ErrorStateEnum.MOP_CLEANING_PAD_MISSING = 0x47 + +ErrorStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ErrorStateEnum, new_mt) + +return ErrorStateEnum diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateStruct.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateStruct.lua new file mode 100644 index 0000000000..5b3f3a0105 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/ErrorStateStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ErrorStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ErrorStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +ErrorStateStruct.field_defs = { + { + name = "error_state_id", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "error_state_label", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "error_state_details", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +ErrorStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ErrorStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ErrorStateStruct.init +new_mt.__index.serialize = ErrorStateStruct.serialize + +ErrorStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ErrorStateStruct, new_mt) + +return ErrorStateStruct diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateEnum.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateEnum.lua new file mode 100644 index 0000000000..4a3b87dab6 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateEnum.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local OperationalStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "OperationalStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.SEEKING_CHARGER] = "SEEKING_CHARGER", + [self.CHARGING] = "CHARGING", + [self.DOCKED] = "DOCKED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.SEEKING_CHARGER = 0x40 +new_mt.__index.CHARGING = 0x41 +new_mt.__index.DOCKED = 0x42 + +OperationalStateEnum.SEEKING_CHARGER = 0x40 +OperationalStateEnum.CHARGING = 0x41 +OperationalStateEnum.DOCKED = 0x42 + +OperationalStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(OperationalStateEnum, new_mt) + +return OperationalStateEnum diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateStruct.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateStruct.lua new file mode 100644 index 0000000000..938f1e9e00 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/OperationalStateStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OperationalStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "OperationalStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +OperationalStateStruct.field_defs = { + { + name = "operational_state_id", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "operational_state_label", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +OperationalStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +OperationalStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = OperationalStateStruct.init +new_mt.__index.serialize = OperationalStateStruct.serialize + +OperationalStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(OperationalStateStruct, new_mt) + +return OperationalStateStruct diff --git a/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/init.lua b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/init.lua new file mode 100644 index 0000000000..5d053bf497 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcOperationalState/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("RvcOperationalState.types." .. key) + end + return types_mt.__types_cache[key] +end + +local RvcOperationalStateTypes = {} + +setmetatable(RvcOperationalStateTypes, types_mt) + +return RvcOperationalStateTypes diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/ChangeToModeResponse.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/ChangeToModeResponse.lua new file mode 100644 index 0000000000..22b8a3247e --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/ChangeToModeResponse.lua @@ -0,0 +1,108 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToModeResponse = {} + +ChangeToModeResponse.NAME = "ChangeToModeResponse" +ChangeToModeResponse.ID = 0x0001 +ChangeToModeResponse.field_defs = { + { + name = "status", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "status_text", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +function ChangeToModeResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToModeResponse:build_test_command_response(device, endpoint_id, status, status_text, interaction_status) + local function init(self, device, endpoint_id, status, status_text) + local out = {} + local args = {status, status_text} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToModeResponse, + __tostring = ChangeToModeResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) + end + local self_request = init(self, device, endpoint_id, status, status_text) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function ChangeToModeResponse:init() + return nil +end + +function ChangeToModeResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToModeResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToModeResponse, {__call = ChangeToModeResponse.init}) + +return ChangeToModeResponse diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/init.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/init.lua new file mode 100644 index 0000000000..fcd62b436a --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/client/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("RvcRunMode.client.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local RvcRunModeClientCommands = {} + +function RvcRunModeClientCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcRunModeClientCommands, command_mt) + +return RvcRunModeClientCommands diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/init.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/init.lua new file mode 100644 index 0000000000..9f98bacdc6 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/init.lua @@ -0,0 +1,103 @@ +local cluster_base = require "st.matter.cluster_base" +local RvcRunModeServerAttributes = require "RvcRunMode.server.attributes" +local RvcRunModeServerCommands = require "RvcRunMode.server.commands" +local RvcRunModeClientCommands = require "RvcRunMode.client.commands" +local RvcRunModeTypes = require "RvcRunMode.types" + +local RvcRunMode = {} + +RvcRunMode.ID = 0x0054 +RvcRunMode.NAME = "RvcRunMode" +RvcRunMode.server = {} +RvcRunMode.client = {} +RvcRunMode.server.attributes = RvcRunModeServerAttributes:set_parent_cluster(RvcRunMode) +RvcRunMode.server.commands = RvcRunModeServerCommands:set_parent_cluster(RvcRunMode) +RvcRunMode.client.commands = RvcRunModeClientCommands:set_parent_cluster(RvcRunMode) +RvcRunMode.types = RvcRunModeTypes + +function RvcRunMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0x0002] = "StartUpMode", + [0x0003] = "OnMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function RvcRunMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function RvcRunMode:get_client_command_by_id(command_id) + local client_id_map = { + [0x0001] = "ChangeToModeResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +RvcRunMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["StartUpMode"] = "server", + ["OnMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +RvcRunMode.command_direction_map = { + ["ChangeToMode"] = "server", + ["ChangeToModeResponse"] = "client", +} + +RvcRunMode.FeatureMap = RvcRunMode.types.Feature + +function RvcRunMode.are_features_supported(feature, feature_map) + if (RvcRunMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = RvcRunMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, RvcRunMode.NAME)) + end + return RvcRunMode[direction].attributes[key] +end +RvcRunMode.attributes = {} +setmetatable(RvcRunMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = RvcRunMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, RvcRunMode.NAME)) + end + return RvcRunMode[direction].commands[key] +end +RvcRunMode.commands = {} +setmetatable(RvcRunMode.commands, command_helper_mt) + +setmetatable(RvcRunMode, {__index = cluster_base}) + +return RvcRunMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..f2987e294d --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/CurrentMode.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/OnMode.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/OnMode.lua new file mode 100644 index 0000000000..90a19b942c --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/OnMode.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OnMode = { + ID = 0x0003, + NAME = "OnMode", + base_type = require "st.matter.data_types.Uint8", +} + +function OnMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function OnMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OnMode:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function OnMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OnMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OnMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OnMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(OnMode, {__call = OnMode.new_value, __index = OnMode.base_type}) +return OnMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/StartUpMode.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/StartUpMode.lua new file mode 100644 index 0000000000..b95c70d359 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/StartUpMode.lua @@ -0,0 +1,80 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local StartUpMode = { + ID = 0x0002, + NAME = "StartUpMode", + base_type = require "st.matter.data_types.Uint8", +} + +function StartUpMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function StartUpMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StartUpMode:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function StartUpMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StartUpMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function StartUpMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function StartUpMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(StartUpMode, {__call = StartUpMode.new_value, __index = StartUpMode.base_type}) +return StartUpMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..6b588cbd9a --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/SupportedModes.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "RvcRunMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/init.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/init.lua new file mode 100644 index 0000000000..b724e6f92d --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/attributes/init.lua @@ -0,0 +1,23 @@ +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("RvcRunMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local RvcRunModeServerAttributes = {} + +function RvcRunModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcRunModeServerAttributes, attr_mt) + +return RvcRunModeServerAttributes diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..4e492640a9 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/ChangeToMode.lua @@ -0,0 +1,86 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/init.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/init.lua new file mode 100644 index 0000000000..7e978c0d6d --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/server/commands/init.lua @@ -0,0 +1,22 @@ +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("RvcRunMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local RvcRunModeServerCommands = {} + +function RvcRunModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RvcRunModeServerCommands, command_mt) + +return RvcRunModeServerCommands diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/Feature.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/Feature.lua new file mode 100644 index 0000000000..7f38a4994f --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/Feature.lua @@ -0,0 +1,53 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..f6370bf8e5 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeOptionStruct.lua @@ -0,0 +1,85 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "RvcRunMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTag.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTag.lua new file mode 100644 index 0000000000..500d52ae17 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTag.lua @@ -0,0 +1,30 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.IDLE] = "IDLE", + [self.CLEANING] = "CLEANING", + [self.MAPPING] = "MAPPING", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.IDLE = 0x4000 +new_mt.__index.CLEANING = 0x4001 +new_mt.__index.MAPPING = 0x4002 + +ModeTag.IDLE = 0x4000 +ModeTag.CLEANING = 0x4001 +ModeTag.MAPPING = 0x4002 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..610fb90ad7 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/ModeTagStruct.lua @@ -0,0 +1,77 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/StatusCode.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/StatusCode.lua new file mode 100644 index 0000000000..65ce763d2e --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/StatusCode.lua @@ -0,0 +1,45 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local StatusCode = {} +local new_mt = UintABC.new_mt({NAME = "StatusCode", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.STUCK] = "STUCK", + [self.DUST_BIN_MISSING] = "DUST_BIN_MISSING", + [self.DUST_BIN_FULL] = "DUST_BIN_FULL", + [self.WATER_TANK_EMPTY] = "WATER_TANK_EMPTY", + [self.WATER_TANK_MISSING] = "WATER_TANK_MISSING", + [self.WATER_TANK_LID_OPEN] = "WATER_TANK_LID_OPEN", + [self.MOP_CLEANING_PAD_MISSING] = "MOP_CLEANING_PAD_MISSING", + [self.BATTERY_LOW] = "BATTERY_LOW", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.STUCK = 0x41 +new_mt.__index.DUST_BIN_MISSING = 0x42 +new_mt.__index.DUST_BIN_FULL = 0x43 +new_mt.__index.WATER_TANK_EMPTY = 0x44 +new_mt.__index.WATER_TANK_MISSING = 0x45 +new_mt.__index.WATER_TANK_LID_OPEN = 0x46 +new_mt.__index.MOP_CLEANING_PAD_MISSING = 0x47 +new_mt.__index.BATTERY_LOW = 0x48 + +StatusCode.STUCK = 0x41 +StatusCode.DUST_BIN_MISSING = 0x42 +StatusCode.DUST_BIN_FULL = 0x43 +StatusCode.WATER_TANK_EMPTY = 0x44 +StatusCode.WATER_TANK_MISSING = 0x45 +StatusCode.WATER_TANK_LID_OPEN = 0x46 +StatusCode.MOP_CLEANING_PAD_MISSING = 0x47 +StatusCode.BATTERY_LOW = 0x48 + +StatusCode.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(StatusCode, new_mt) + +return StatusCode diff --git a/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/init.lua b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/init.lua new file mode 100644 index 0000000000..2a6950a9fc --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/RvcRunMode/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("RvcRunMode.types." .. key) + end + return types_mt.__types_cache[key] +end + +local RvcRunModeTypes = {} + +setmetatable(RvcRunModeTypes, types_mt) + +return RvcRunModeTypes diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/SelectAreasResponse.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/SelectAreasResponse.lua new file mode 100644 index 0000000000..b27711b98b --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/SelectAreasResponse.lua @@ -0,0 +1,150 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +----------------------------------------------------------- +-- ServiceArea command SelectAreasResponse +----------------------------------------------------------- + +--- @class st.matter.clusters.ServiceArea.SelectAreasResponse +--- @alias SelectAreasResponse +--- +--- @field public ID number 0x0001 the ID of this command +--- @field public NAME string "SelectAreasResponse" the name of this command +--- @field public status ServiceArea.types.SelectAreasStatus +--- @field public status_text st.matter.data_types.UTF8String1 +local SelectAreasResponse = {} + +SelectAreasResponse.NAME = "SelectAreasResponse" +SelectAreasResponse.ID = 0x0001 +SelectAreasResponse.field_defs = { + { + name = "status", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "ServiceArea.types.SelectAreasStatus", + }, + { + name = "status_text", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +--- Add field names to each command field +--- +--- @param base_type_obj st.matter.data_types.Structure +function SelectAreasResponse:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +--- Builds an SelectAreasResponse test command reponse for the driver integration testing framework +--- +--- @param device st.matter.Device the device to build this message to +--- @param endpoint_id number|nil +--- @param status ServiceArea.types.SelectAreasStatus +--- @param status_text st.matter.data_types.UTF8String1 +--- @return st.matter.st.matter.interaction_model.InteractionResponse of type COMMAND_RESPONSE +function SelectAreasResponse:build_test_command_response(device, endpoint_id, status, status_text, interaction_status) + local function init(self, device, endpoint_id, status, status_text) + local out = {} + local args = {status, status_text} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SelectAreasResponse, + __tostring = SelectAreasResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + false, + true + ) + end + local self_request = init(self, device, endpoint_id, status, status_text) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +--- Initialize the SelectAreasResponse command +--- +--- @return nil +function SelectAreasResponse:init() + return nil +end + +function SelectAreasResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SelectAreasResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SelectAreasResponse, {__call = SelectAreasResponse.init}) + +return SelectAreasResponse diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/init.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/init.lua new file mode 100644 index 0000000000..85af7a6f7c --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/client/commands/init.lua @@ -0,0 +1,42 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("ServiceArea.client.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +--- @class ServiceAreaClientCommands +--- +--- @field public SelectAreasResponse ServiceArea.SelectAreasResponse +local ServiceAreaClientCommands = {} + +function ServiceAreaClientCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ServiceAreaClientCommands, command_mt) + +return ServiceAreaClientCommands + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/init.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/init.lua new file mode 100644 index 0000000000..f06dbdd7cb --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/init.lua @@ -0,0 +1,183 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local cluster_base = require "st.matter.cluster_base" +local ServiceAreaServerAttributes = require "ServiceArea.server.attributes" +local ServiceAreaServerCommands = require "ServiceArea.server.commands" +local ServiceAreaClientCommands = require "ServiceArea.client.commands" +local ServiceAreaTypes = require "ServiceArea.types" + +--- @class ServiceArea +--- @alias ServiceArea +--- +--- @field public ID number 0x0150 the ID of this cluster +--- @field public NAME string "ServiceArea" the name of this cluster +--- @field public attributes ServiceAreaServerAttributes | ServiceAreaClientAttributes +--- @field public commands ServiceAreaServerCommands | ServiceAreaClientCommands +--- @field public types ServiceAreaTypes + +local ServiceArea = {} + +ServiceArea.ID = 0x0150 +ServiceArea.NAME = "ServiceArea" +ServiceArea.server = {} +ServiceArea.client = {} +ServiceArea.server.attributes = ServiceAreaServerAttributes:set_parent_cluster(ServiceArea) +ServiceArea.server.commands = ServiceAreaServerCommands:set_parent_cluster(ServiceArea) +ServiceArea.client.commands = ServiceAreaClientCommands:set_parent_cluster(ServiceArea) +ServiceArea.types = ServiceAreaTypes + +--- Find an attribute by id +--- +--- @param attr_id number +function ServiceArea:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedAreas", + [0x0001] = "SupportedMaps", + [0x0002] = "SelectedAreas", + [0x0003] = "CurrentArea", + [0x0004] = "EstimatedEndTime", + [0x0005] = "Progress", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +--- Find a server command by id +--- +--- @param command_id number +function ServiceArea:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "SelectAreas", + [0x0002] = "SkipArea", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +--- Find a client command by id +--- +--- @param command_id number +function ServiceArea:get_client_command_by_id(command_id) + local client_id_map = { + [0x0001] = "SelectAreasResponse", + [0x0003] = "SkipAreaResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +-- Attribute Mapping +ServiceArea.attribute_direction_map = { + ["SupportedAreas"] = "server", + ["SupportedMaps"] = "server", + ["SelectedAreas"] = "server", + ["CurrentArea"] = "server", + ["EstimatedEndTime"] = "server", + ["Progress"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +do + local has_aliases, aliases = pcall(require, "aliases.ServiceArea.server.attributes") + if has_aliases then + for alias, _ in pairs(aliases) do + ServiceArea.attribute_direction_map[alias] = "server" + end + end +end + +-- Command Mapping +ServiceArea.command_direction_map = { + ["SelectAreas"] = "server", + ["SkipArea"] = "server", + ["SelectAreasResponse"] = "client", + ["SkipAreaResponse"] = "client", +} + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ServiceArea.server.commands") + if has_aliases then + for alias, _ in pairs(aliases) do + ServiceArea.command_direction_map[alias] = "server" + end + end +end + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ServiceArea.client.commands") + if has_aliases then + for alias, _ in pairs(aliases) do + ServiceArea.command_direction_map[alias] = "client" + end + end +end + +ServiceArea.FeatureMap = ServiceArea.types.Feature + +function ServiceArea.are_features_supported(feature, feature_map) + if (ServiceArea.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +-- Cluster Completion +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ServiceArea.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ServiceArea.NAME)) + end + return ServiceArea[direction].attributes[key] +end +ServiceArea.attributes = {} +setmetatable(ServiceArea.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ServiceArea.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ServiceArea.NAME)) + end + return ServiceArea[direction].commands[key] +end +ServiceArea.commands = {} +setmetatable(ServiceArea.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return ServiceArea.server.events[key] +end +ServiceArea.events = {} +setmetatable(ServiceArea.events, event_helper_mt) + +setmetatable(ServiceArea, {__index = cluster_base}) + +return ServiceArea + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SelectedAreas.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SelectedAreas.lua new file mode 100644 index 0000000000..01ff53c95c --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SelectedAreas.lua @@ -0,0 +1,126 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +--- @class st.matter.clusters.ServiceArea.SelectedAreas +--- @alias SelectedAreas +--- +--- @field public ID number 0x0002 the ID of this attribute +--- @field public NAME string "SelectedAreas" the name of this attribute +--- @field public data_type st.matter.data_types.Array the data type of this attribute + +local SelectedAreas = { + ID = 0x0002, + NAME = "SelectedAreas", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +--- Add additional functionality to the base type object +--- +--- @param base_type_obj st.matter.data_types.Array the base data type object to add functionality to +function SelectedAreas:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SelectedAreas.element_type) + end +end + +--- Create a Array object of this attribute with any additional features provided for the attribute +--- This is also usable with the SelectedAreas(...) syntax +--- +--- @vararg vararg the values needed to construct a Array +--- @return st.matter.data_types.Array +function SelectedAreas:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +--- Constructs an st.matter.interaction_model.InteractionRequest to read +--- this attribute from a device +--- @param device st.matter.Device +--- @param endpoint_id number|nil +--- @return st.matter.interaction_model.InteractionRequest containing an Interaction Request +function SelectedAreas:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + + +--- Reporting policy: SelectedAreas => true => mandatory + +--- Sets up a Subscribe Interaction +--- +--- @param device any +--- @param endpoint_id number|nil +--- @return any +function SelectedAreas:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SelectedAreas:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +--- Builds an SelectedAreas test attribute reponse for the driver integration testing framework +--- +--- @param device st.matter.Device the device to build this message for +--- @param endpoint_id number|nil +--- @param value any +--- @param status string Interaction status associated with the path +--- @return st.matter.interaction_model.InteractionResponse of type REPORT_DATA +function SelectedAreas:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SelectedAreas:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SelectedAreas, {__call = SelectedAreas.new_value, __index = SelectedAreas.base_type}) +return SelectedAreas + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SupportedAreas.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SupportedAreas.lua new file mode 100644 index 0000000000..205f907b64 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/SupportedAreas.lua @@ -0,0 +1,126 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +--- @class st.matter.clusters.ServiceArea.SupportedAreas +--- @alias SupportedAreas +--- +--- @field public ID number 0x0000 the ID of this attribute +--- @field public NAME string "SupportedAreas" the name of this attribute +--- @field public data_type st.matter.data_types.Array the data type of this attribute + +local SupportedAreas = { + ID = 0x0000, + NAME = "SupportedAreas", + base_type = require "st.matter.data_types.Array", + element_type = require "ServiceArea.types.AreaStruct", +} + +--- Add additional functionality to the base type object +--- +--- @param base_type_obj st.matter.data_types.Array the base data type object to add functionality to +function SupportedAreas:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedAreas.element_type) + end +end + +--- Create a Array object of this attribute with any additional features provided for the attribute +--- This is also usable with the SupportedAreas(...) syntax +--- +--- @vararg vararg the values needed to construct a Array +--- @return st.matter.data_types.Array +function SupportedAreas:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +--- Constructs an st.matter.interaction_model.InteractionRequest to read +--- this attribute from a device +--- @param device st.matter.Device +--- @param endpoint_id number|nil +--- @return st.matter.interaction_model.InteractionRequest containing an Interaction Request +function SupportedAreas:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + + +--- Reporting policy: SupportedAreas => true => mandatory + +--- Sets up a Subscribe Interaction +--- +--- @param device any +--- @param endpoint_id number|nil +--- @return any +function SupportedAreas:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SupportedAreas:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +--- Builds an SupportedAreas test attribute reponse for the driver integration testing framework +--- +--- @param device st.matter.Device the device to build this message for +--- @param endpoint_id number|nil +--- @param value any +--- @param status string Interaction status associated with the path +--- @return st.matter.interaction_model.InteractionResponse of type REPORT_DATA +function SupportedAreas:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedAreas:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedAreas, {__call = SupportedAreas.new_value, __index = SupportedAreas.base_type}) +return SupportedAreas + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/init.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/init.lua new file mode 100644 index 0000000000..6750f1fcdf --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/attributes/init.lua @@ -0,0 +1,44 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("ServiceArea.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +--- @class ServiceAreaServerAttributes +--- +--- @field public SupportedAreas ServiceArea.server.attributes.SupportedAreas +--- @field public SelectedAreas ServiceArea.server.attributes.SelectedAreas +local ServiceAreaServerAttributes = {} + +function ServiceAreaServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ServiceAreaServerAttributes, attr_mt) + +return ServiceAreaServerAttributes + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/server/commands/SelectAreas.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/commands/SelectAreas.lua new file mode 100644 index 0000000000..b62d393279 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/commands/SelectAreas.lua @@ -0,0 +1,127 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +----------------------------------------------------------- +-- ServiceArea command SelectAreas +----------------------------------------------------------- + +--- @class st.matter.clusters.ServiceArea.SelectAreas +--- @alias SelectAreas +--- +--- @field public ID number 0x0000 the ID of this command +--- @field public NAME string "SelectAreas" the name of this command +--- @field public new_areas st.matter.data_types.Array +local SelectAreas = {} + +SelectAreas.NAME = "SelectAreas" +SelectAreas.ID = 0x0000 +SelectAreas.field_defs = { + { + name = "new_areas", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", + }, +} + +--- Refer to SelectAreasResponse:build_test_command_response for +--- building a test command reponse for the driver integration testing framework + +--- Initialize the SelectAreas command +--- +--- @param self SelectAreas the template class for this command +--- @param device st.matter.Device the device to build this message to +--- @param new_areas st.matter.data_types.Array + +--- @return st.matter.interaction_model.InteractionRequest of type INVOKE +function SelectAreas:init(device, endpoint_id, new_areas) + local out = {} + local args = {new_areas} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SelectAreas, + __tostring = SelectAreas.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function SelectAreas:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +--- Add field names to each command field +--- +--- @param base_type_obj st.matter.data_types.Structure +function SelectAreas:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SelectAreas:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SelectAreas, {__call = SelectAreas.init}) + +return SelectAreas + diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/commands/init.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/commands/init.lua similarity index 75% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/commands/init.lua rename to drivers/SmartThings/matter-rvc/src/ServiceArea/server/commands/init.lua index 3bb136bcb5..280462b1d0 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/commands/init.lua +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/commands/init.lua @@ -18,7 +18,7 @@ local command_mt = {} command_mt.__command_cache = {} command_mt.__index = function(self, key) if command_mt.__command_cache[key] == nil then - local req_loc = string.format("PressureMeasurement.server.commands.%s", key) + local req_loc = string.format("ServiceArea.server.commands.%s", key) local raw_def = require(req_loc) local cluster = rawget(self, "_cluster") command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) @@ -26,16 +26,17 @@ command_mt.__index = function(self, key) return command_mt.__command_cache[key] end ---- @class PressureMeasurementServerCommands +--- @class ServiceAreaServerCommands --- -local PressureMeasurementServerCommands = {} +--- @field public SelectAreas ServiceArea.SelectAreas +local ServiceAreaServerCommands = {} -function PressureMeasurementServerCommands:set_parent_cluster(cluster) +function ServiceAreaServerCommands:set_parent_cluster(cluster) self._cluster = cluster return self end -setmetatable(PressureMeasurementServerCommands, command_mt) +setmetatable(ServiceAreaServerCommands, command_mt) -return PressureMeasurementServerCommands +return ServiceAreaServerCommands diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/events/init.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/events/init.lua similarity index 78% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/events/init.lua rename to drivers/SmartThings/matter-rvc/src/ServiceArea/server/events/init.lua index c6fec3b6b5..d2d8ebaefe 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/events/init.lua +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/server/events/init.lua @@ -18,7 +18,7 @@ local event_mt = {} event_mt.__event_cache = {} event_mt.__index = function(self, key) if event_mt.__event_cache[key] == nil then - local req_loc = string.format("PressureMeasurement.server.events.%s", key) + local req_loc = string.format("ServiceArea.server.events.%s", key) local raw_def = require(req_loc) local cluster = rawget(self, "_cluster") raw_def:set_parent_cluster(cluster) @@ -27,16 +27,16 @@ event_mt.__index = function(self, key) return event_mt.__event_cache[key] end ---- @class PressureMeasurementEvents +--- @class ServiceAreaEvents --- -local PressureMeasurementEvents = {} +local ServiceAreaEvents = {} -function PressureMeasurementEvents:set_parent_cluster(cluster) +function ServiceAreaEvents:set_parent_cluster(cluster) self._cluster = cluster return self end -setmetatable(PressureMeasurementEvents, event_mt) +setmetatable(ServiceAreaEvents, event_mt) -return PressureMeasurementEvents +return ServiceAreaEvents diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaInfoStruct.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaInfoStruct.lua new file mode 100644 index 0000000000..9e7932857d --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaInfoStruct.lua @@ -0,0 +1,98 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +--- @alias AreaInfoStruct +--- @class st.matter.clusters.ServiceArea.types.AreaInfoStruct: st.matter.data_types.Structure +--- +--- @field public location_info Global.types.LocationDescriptorStruct +--- @field public landmark_info ServiceArea.types.LandmarkInfoStruct +local AreaInfoStruct = {} +local new_mt = StructureABC.new_mt({NAME = "AreaInfoStruct", ID = data_types.name_to_id_map["Structure"]}) + +AreaInfoStruct.field_defs = { + { + name = "location_info", + field_id = 0, + is_nullable = true, + is_optional = false, + data_type = require "Global.types.LocationDescriptorStruct", + }, + { + name = "landmark_info", + field_id = 1, + is_nullable = true, + is_optional = false, + data_type = require "ServiceArea.types.LandmarkInfoStruct", + }, +} + +AreaInfoStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +AreaInfoStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = AreaInfoStruct.init +new_mt.__index.serialize = AreaInfoStruct.serialize + +AreaInfoStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(AreaInfoStruct, new_mt) + +return AreaInfoStruct + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaStruct.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaStruct.lua new file mode 100644 index 0000000000..9277b81318 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/AreaStruct.lua @@ -0,0 +1,106 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +--- @alias AreaStruct +--- @class st.matter.clusters.ServiceArea.types.AreaStruct: st.matter.data_types.Structure +--- +--- @field public area_id st.matter.data_types.Uint32 +--- @field public map_id st.matter.data_types.Uint32 +--- @field public area_info ServiceArea.types.AreaInfoStruct +local AreaStruct = {} +local new_mt = StructureABC.new_mt({NAME = "AreaStruct", ID = data_types.name_to_id_map["Structure"]}) + +AreaStruct.field_defs = { + { + name = "area_id", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "map_id", + field_id = 1, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "area_info", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "ServiceArea.types.AreaInfoStruct", + }, +} + +AreaStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +AreaStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = AreaStruct.init +new_mt.__index.serialize = AreaStruct.serialize + +AreaStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(AreaStruct, new_mt) + +return AreaStruct + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/types/Feature.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/Feature.lua new file mode 100644 index 0000000000..e4878e14fd --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/Feature.lua @@ -0,0 +1,137 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +--- @class st.matter.clusters.ServiceArea.types.Feature +--- @alias Feature +--- +--- @field public SELECT_WHILE_RUNNING number 1 +--- @field public PROGRESS_REPORTING number 2 +--- @field public MAPS number 4 + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.SELECT_WHILE_RUNNING = 0x0001 +Feature.PROGRESS_REPORTING = 0x0002 +Feature.MAPS = 0x0004 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + SELECT_WHILE_RUNNING = 0x0001, + PROGRESS_REPORTING = 0x0002, + MAPS = 0x0004, +} + +--- @function Feature:is_select_while_running_set +--- @return boolean True if the value of SELECT_WHILE_RUNNING is non-zero +Feature.is_select_while_running_set = function(self) + return (self.value & self.SELECT_WHILE_RUNNING) ~= 0 +end + +--- @function Feature:set_select_while_running +--- Set the value of the bit in the SELECT_WHILE_RUNNING field to 1 +Feature.set_select_while_running = function(self) + if self.value ~= nil then + self.value = self.value | self.SELECT_WHILE_RUNNING + else + self.value = self.SELECT_WHILE_RUNNING + end +end + +--- @function Feature:unset_select_while_running +--- Set the value of the bits in the SELECT_WHILE_RUNNING field to 0 +Feature.unset_select_while_running = function(self) + self.value = self.value & (~self.SELECT_WHILE_RUNNING & self.BASE_MASK) +end +--- @function Feature:is_progress_reporting_set +--- @return boolean True if the value of PROGRESS_REPORTING is non-zero +Feature.is_progress_reporting_set = function(self) + return (self.value & self.PROGRESS_REPORTING) ~= 0 +end + +--- @function Feature:set_progress_reporting +--- Set the value of the bit in the PROGRESS_REPORTING field to 1 +Feature.set_progress_reporting = function(self) + if self.value ~= nil then + self.value = self.value | self.PROGRESS_REPORTING + else + self.value = self.PROGRESS_REPORTING + end +end + +--- @function Feature:unset_progress_reporting +--- Set the value of the bits in the PROGRESS_REPORTING field to 0 +Feature.unset_progress_reporting = function(self) + self.value = self.value & (~self.PROGRESS_REPORTING & self.BASE_MASK) +end +--- @function Feature:is_maps_set +--- @return boolean True if the value of MAPS is non-zero +Feature.is_maps_set = function(self) + return (self.value & self.MAPS) ~= 0 +end + +--- @function Feature:set_maps +--- Set the value of the bit in the MAPS field to 1 +Feature.set_maps = function(self) + if self.value ~= nil then + self.value = self.value | self.MAPS + else + self.value = self.MAPS + end +end + +--- @function Feature:unset_maps +--- Set the value of the bits in the MAPS field to 0 +Feature.unset_maps = function(self) + self.value = self.value & (~self.MAPS & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.SELECT_WHILE_RUNNING | + Feature.PROGRESS_REPORTING | + Feature.MAPS + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_select_while_running_set = Feature.is_select_while_running_set, + set_select_while_running = Feature.set_select_while_running, + unset_select_while_running = Feature.unset_select_while_running, + is_progress_reporting_set = Feature.is_progress_reporting_set, + set_progress_reporting = Feature.set_progress_reporting, + unset_progress_reporting = Feature.unset_progress_reporting, + is_maps_set = Feature.is_maps_set, + set_maps = Feature.set_maps, + unset_maps = Feature.unset_maps, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/types/LandmarkInfoStruct.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/LandmarkInfoStruct.lua new file mode 100644 index 0000000000..f23ca7e49b --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/LandmarkInfoStruct.lua @@ -0,0 +1,98 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +--- @alias LandmarkInfoStruct +--- @class st.matter.clusters.ServiceArea.types.LandmarkInfoStruct: st.matter.data_types.Structure +--- +--- @field public landmark_tag Global.types.LandmarkTag +--- @field public relative_position_tag Global.types.RelativePositionTag +local LandmarkInfoStruct = {} +local new_mt = StructureABC.new_mt({NAME = "LandmarkInfoStruct", ID = data_types.name_to_id_map["Structure"]}) + +LandmarkInfoStruct.field_defs = { + { + name = "landmark_tag", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "Global.types.LandmarkTag", + }, + { + name = "relative_position_tag", + field_id = 1, + is_nullable = true, + is_optional = false, + data_type = require "Global.types.RelativePositionTag", + }, +} + +LandmarkInfoStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do --Note: idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +LandmarkInfoStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = LandmarkInfoStruct.init +new_mt.__index.serialize = LandmarkInfoStruct.serialize + +LandmarkInfoStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(LandmarkInfoStruct, new_mt) + +return LandmarkInfoStruct + diff --git a/drivers/SmartThings/matter-rvc/src/ServiceArea/types/SelectAreasStatus.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/SelectAreasStatus.lua new file mode 100644 index 0000000000..bda76258ca --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/SelectAreasStatus.lua @@ -0,0 +1,59 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +--- @class st.matter.clusters.ServiceArea.types.SelectAreasStatus: st.matter.data_types.Uint8 +--- @alias SelectAreasStatus +--- +--- @field public byte_length number 1 +--- @field public SUCCESS number 0 +--- @field public UNSUPPORTED_AREA number 1 +--- @field public INVALID_IN_MODE number 2 +--- @field public INVALID_SET number 3 + +local SelectAreasStatus = {} +local new_mt = UintABC.new_mt({NAME = "SelectAreasStatus", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.SUCCESS] = "SUCCESS", + [self.UNSUPPORTED_AREA] = "UNSUPPORTED_AREA", + [self.INVALID_IN_MODE] = "INVALID_IN_MODE", + [self.INVALID_SET] = "INVALID_SET", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.SUCCESS = 0x00 +new_mt.__index.UNSUPPORTED_AREA = 0x01 +new_mt.__index.INVALID_IN_MODE = 0x02 +new_mt.__index.INVALID_SET = 0x03 + +SelectAreasStatus.SUCCESS = 0x00 +SelectAreasStatus.UNSUPPORTED_AREA = 0x01 +SelectAreasStatus.INVALID_IN_MODE = 0x02 +SelectAreasStatus.INVALID_SET = 0x03 + +SelectAreasStatus.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(SelectAreasStatus, new_mt) + +return SelectAreasStatus + diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/types/init.lua b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/init.lua similarity index 73% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/types/init.lua rename to drivers/SmartThings/matter-rvc/src/ServiceArea/types/init.lua index 682c6db041..66b930bdd0 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/types/init.lua +++ b/drivers/SmartThings/matter-rvc/src/ServiceArea/types/init.lua @@ -18,18 +18,19 @@ local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) if types_mt.__types_cache[key] == nil then - types_mt.__types_cache[key] = require("PressureMeasurement.types." .. key) + types_mt.__types_cache[key] = require("ServiceArea.types." .. key) end return types_mt.__types_cache[key] end ---- @class PressureMeasurementTypes +--- @class ServiceAreaTypes --- +--- @field public SelectAreasStatus ServiceArea.types.SelectAreasStatus ---- @field public Feature PressureMeasurement.types.Feature -local PressureMeasurementTypes = {} +--- @field public Feature ServiceArea.types.Feature +local ServiceAreaTypes = {} -setmetatable(PressureMeasurementTypes, types_mt) +setmetatable(ServiceAreaTypes, types_mt) -return PressureMeasurementTypes +return ServiceAreaTypes diff --git a/drivers/SmartThings/matter-rvc/src/embedded_cluster_utils.lua b/drivers/SmartThings/matter-rvc/src/embedded_cluster_utils.lua new file mode 100644 index 0000000000..3c1f1bd084 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/embedded_cluster_utils.lua @@ -0,0 +1,49 @@ +local clusters = require "st.matter.clusters" +local utils = require "st.utils" +local version = require "version" + +if version.api < 13 then + clusters.ServiceArea = require "ServiceArea" +end + +local embedded_cluster_utils = {} + +local embedded_clusters = { + [clusters.ServiceArea.ID] = clusters.ServiceArea, +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + if embedded_clusters[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + table.sort(eps) + return eps + else + local eps = device:get_endpoints(cluster_id, opts) + table.sort(eps) + return eps + end +end + +return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-rvc/src/init.lua b/drivers/SmartThings/matter-rvc/src/init.lua new file mode 100644 index 0000000000..2badac1413 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/init.lua @@ -0,0 +1,662 @@ +-- Copyright 2023 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local MatterDriver = require "st.matter.driver" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local embedded_cluster_utils = require "embedded_cluster_utils" + +-- Include driver-side definitions when lua libs api version is < 10 +local version = require "version" +if version.api < 10 then + clusters.RvcCleanMode = require "RvcCleanMode" + clusters.RvcOperationalState = require "RvcOperationalState" + clusters.RvcRunMode = require "RvcRunMode" + clusters.OperationalState = require "OperationalState" +end + +if version.api < 13 then + clusters.ServiceArea = require "ServiceArea" + clusters.Global = require "Global" +end + +local RUN_MODE_SUPPORTED_MODES = "__run_mode_supported_modes" +local CURRENT_RUN_MODE = "__current_run_mode" +local CLEAN_MODE_SUPPORTED_MODES = "__clean_mode_supported_modes" +local SERVICE_AREA_PROFILED = "__SERVICE_AREA_PROFILED" + +local clus_op_enum = clusters.OperationalState.types.OperationalStateEnum +local clus_rvc_op_enum = clusters.RvcOperationalState.types.OperationalStateEnum +local cap_op_enum = capabilities.robotCleanerOperatingState.operatingState +local cap_op_cmds = capabilities.robotCleanerOperatingState.commands +local OPERATING_STATE_MAP = { + [clus_op_enum.STOPPED] = cap_op_enum.stopped, + [clus_op_enum.RUNNING] = cap_op_enum.running, + [clus_op_enum.PAUSED] = cap_op_enum.paused, + [clus_rvc_op_enum.SEEKING_CHARGER] = cap_op_enum.seekingCharger, + [clus_rvc_op_enum.CHARGING] = cap_op_enum.charging, + [clus_rvc_op_enum.DOCKED] = cap_op_enum.docked +} + +local subscribed_attributes = { + [capabilities.mode.ID] = { + clusters.RvcRunMode.attributes.SupportedModes, + clusters.RvcRunMode.attributes.CurrentMode, + clusters.RvcCleanMode.attributes.SupportedModes, + clusters.RvcCleanMode.attributes.CurrentMode + }, + [capabilities.robotCleanerOperatingState.ID] = { + clusters.RvcOperationalState.attributes.OperationalStateList, + clusters.RvcOperationalState.attributes.OperationalState, + clusters.RvcOperationalState.attributes.OperationalError + }, + [capabilities.serviceArea.ID] = { + clusters.ServiceArea.attributes.SupportedAreas, + clusters.ServiceArea.attributes.SelectedAreas + } +} + +local function find_default_endpoint(device, cluster_id) + local eps = device:get_endpoints(cluster_id) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return device.MATTER_DEFAULT_ENDPOINT +end + +local function match_profile(driver, device) + local clean_mode_eps = device:get_endpoints(clusters.RvcCleanMode.ID) or {} + local service_area_eps = embedded_cluster_utils.get_endpoints(device, clusters.ServiceArea.ID) or {} + + local profile_name = "rvc" + if #clean_mode_eps > 0 then + profile_name = profile_name .. "-clean-mode" + end + if #service_area_eps > 0 then + profile_name = profile_name .. "-service-area" + end + + device.log.info_with({hub_logs = true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +local function device_init(driver, device) + device:subscribe() + + -- comp/ep map functionality removed 9/5/25. + device:set_field("__component_to_endpoint_map", nil) + + if not device:get_field(SERVICE_AREA_PROFILED) then + if #device:get_endpoints(clusters.ServiceArea.ID) > 0 then + match_profile(driver, device) + end + device:set_field(SERVICE_AREA_PROFILED, true, { persist = true }) + end +end + +local function do_configure(driver, device) + match_profile(driver, device) + device:set_field(SERVICE_AREA_PROFILED, true, { persist = true }) + device:send(clusters.RvcOperationalState.attributes.AcceptedCommandList:read()) +end + +local function driver_switched(driver, device) + match_profile(driver, device) + device:set_field(SERVICE_AREA_PROFILED, true, { persist = true }) + device:send(clusters.RvcOperationalState.attributes.AcceptedCommandList:read()) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() + end +end + +-- Helper functions -- +local function supports_rvc_operational_state(device, command_name) + local supported_op_commands = device:get_latest_state( + "main", + capabilities.robotCleanerOperatingState.ID, + capabilities.robotCleanerOperatingState.supportedCommands.NAME + ) or {} + for _, cmd in ipairs(supported_op_commands) do + if cmd == command_name then + return true + end + end + return false +end + +local function can_send_state_command(device, command_name, current_state, current_tag) + device.log.info(string.format("can_send_state_command: %s, %s, %s", command_name, current_state, current_tag)) + if current_state == "Error" then + return false + end + + local set_mode = capabilities.mode.commands.setMode.NAME + if command_name ~= set_mode and supports_rvc_operational_state(device, command_name) == false then + return false + end + + if command_name == cap_op_cmds.goHome.NAME then + if current_state ~= cap_op_enum.charging.NAME and current_state ~= cap_op_enum.docked.NAME then + return true + end + elseif command_name == cap_op_cmds.pause.NAME then + if current_tag == clusters.RvcRunMode.types.ModeTag.IDLE then + if current_state == cap_op_enum.seekingCharger.NAME then + return true + end + else + if current_state == cap_op_enum.running.NAME or current_state == cap_op_enum.seekingCharger.NAME then + return true + end + end + elseif command_name == cap_op_cmds.start.NAME then + if current_tag ~= clusters.RvcRunMode.types.ModeTag.IDLE then + if current_state == cap_op_enum.paused.NAME or + current_state == cap_op_enum.docked.NAME or + current_state == cap_op_enum.charging.NAME then + return true + end + end + elseif command_name == set_mode then + if current_tag == clusters.RvcRunMode.types.ModeTag.IDLE then + if current_state == cap_op_enum.stopped.NAME or current_state == cap_op_enum.paused.NAME or + current_state == cap_op_enum.docked.NAME or current_state == cap_op_enum.charging.NAME then + return true + end + end + end + return false +end + +local function update_supported_arguments(device, ep, current_run_mode, current_state) + device.log.info(string.format("update_supported_arguments: %s, %s", current_run_mode, current_state)) + if current_run_mode == nil or current_state == nil then + return + end + + if current_state == "Error" then + -- Set Supported Operating State Commands to empty + local event = capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {}, {visibility = {displayed = false}} + ) + device:emit_event_for_endpoint(ep, event) + return + end + + -- Get the tag of the current run mode + local current_tag = 0xFFFF + local supported_run_modes = device:get_field(RUN_MODE_SUPPORTED_MODES) or {} + for _, mode in ipairs(supported_run_modes) do + if mode.label == current_run_mode then + current_tag = mode.tag + break + end + end + if current_tag == 0xFFFF then + device.log.error(string.format("Unsupported mode: %s", current_run_mode)) + return + end + + -- Set Supported Operating State Commands + local supported_op_commands = {} + if can_send_state_command(device, cap_op_cmds.goHome.NAME, current_state, nil) == true then + table.insert(supported_op_commands, cap_op_cmds.goHome.NAME) + end + if can_send_state_command(device, cap_op_cmds.pause.NAME, current_state, current_tag) == true then + table.insert(supported_op_commands, cap_op_cmds.pause.NAME) + elseif can_send_state_command(device, cap_op_cmds.start.NAME, current_state, current_tag) == true or + can_send_state_command(device, capabilities.mode.commands.setMode.NAME, current_state, current_tag) == true then + table.insert(supported_op_commands, cap_op_cmds.start.NAME) + end + local event = capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + supported_op_commands, {visibility = {displayed = false}} + ) + device:emit_event_for_endpoint(ep, event) +end + +-- Matter Handlers -- +local function run_mode_supported_mode_handler(driver, device, ib, response) + local supported_modes = {} + local supported_modes_id_tag = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 10 then + clusters.RvcRunMode.types.ModeOptionStruct:augment_type(mode) + end + local tag = 0xFFFF + for _, t in ipairs(mode.elements.mode_tags.elements) do + if t.elements.value.value == clusters.RvcRunMode.types.ModeTag.IDLE then + tag = clusters.RvcRunMode.types.ModeTag.IDLE + break + elseif t.elements.value.value == clusters.RvcRunMode.types.ModeTag.CLEANING then + tag = clusters.RvcRunMode.types.ModeTag.CLEANING + break + elseif t.elements.value.value == clusters.RvcRunMode.types.ModeTag.MAPPING then + tag = clusters.RvcRunMode.types.ModeTag.MAPPING + break + end + end + if tag ~= 0xFFFF then + table.insert(supported_modes, mode.elements.label.value) + table.insert(supported_modes_id_tag, { label = mode.elements.label.value, id = mode.elements.mode.value, tag = tag }) + end + end + device:set_field(RUN_MODE_SUPPORTED_MODES, supported_modes_id_tag, { persist = true }) + + -- Update Supported Arguments + local current_run_mode = device:get_field(CURRENT_RUN_MODE) + local current_state = device:get_latest_state( + "main", + capabilities.robotCleanerOperatingState.ID, + capabilities.robotCleanerOperatingState.operatingState.NAME + ) + update_supported_arguments(device, ib.endpoint_id, current_run_mode, current_state) +end + +local function run_mode_current_mode_handler(driver, device, ib, response) + device.log.info(string.format("run_mode_current_mode_handler currentMode: %s", ib.data.value)) + + -- Get label of current mode + local mode_id = ib.data.value + local supported_run_mode = device:get_field(RUN_MODE_SUPPORTED_MODES) or {} + local current_run_mode = nil + for _, mode in ipairs(supported_run_mode) do + if mode.id == mode_id then + current_run_mode = mode.label + end + end + if current_run_mode == nil then + return + end + + -- Set current mode + device:set_field(CURRENT_RUN_MODE, current_run_mode, { persist = true }) + + -- Update supported mode + local current_state = device:get_latest_state( + "main", + capabilities.robotCleanerOperatingState.ID, + capabilities.robotCleanerOperatingState.operatingState.NAME + ) + update_supported_arguments(device, ib.endpoint_id, current_run_mode, current_state) +end + +local function clean_mode_supported_mode_handler(driver, device, ib, response) + local supported_modes = {} + local supported_modes_id = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 10 then + clusters.RvcRunMode.types.ModeOptionStruct:augment_type(mode) + end + table.insert(supported_modes, mode.elements.label.value) + table.insert(supported_modes_id, { label = mode.elements.label.value, id = mode.elements.mode.value }) + end + device:set_field(CLEAN_MODE_SUPPORTED_MODES, supported_modes_id, { persist = true }) + + local event = capabilities.mode.supportedModes(supported_modes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supported_modes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function clean_mode_current_mode_handler(driver, device, ib, response) + device.log.info(string.format("clean_mode_current_mode_handler currentMode: %s", ib.data.value)) + local mode_id = ib.data.value + local supported_clean_mode = device:get_field(CLEAN_MODE_SUPPORTED_MODES) or {} + for _, mode in ipairs(supported_clean_mode) do + if mode.id == mode_id then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode.label)) + break + end + end +end + +local function rvc_operational_state_attr_handler(driver, device, ib, response) + device.log.info(string.format("rvc_operational_state_attr_handler operationalState: %s", ib.data.value)) + if ib.data.value ~= clus_op_enum.ERROR then + device:emit_event_for_endpoint(ib.endpoint_id, OPERATING_STATE_MAP[ib.data.value]()) + end + + -- Supported Mode update + local current_run_mode = device:get_field(CURRENT_RUN_MODE) + if ib.data.value ~= clus_op_enum.ERROR then + update_supported_arguments(device, ib.endpoint_id, current_run_mode, OPERATING_STATE_MAP[ib.data.value].NAME) + else + update_supported_arguments(device, ib.endpoint_id, current_run_mode, "Error") + end +end + +local function rvc_operational_error_attr_handler(driver, device, ib, response) + if version.api < 10 then + clusters.OperationalState.types.ErrorStateStruct:augment_type(ib.data) + end + + device.log.info(string.format("rvc_operational_error_attr_handler errorStateID: %s", ib.data.elements.error_state_id.value)) + + local operationalError = ib.data.elements.error_state_id.value + if operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.unableToStartOrResume()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.unableToCompleteOperation()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.commandInvalidInState()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.FAILED_TO_FIND_CHARGING_DOCK then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.failedToFindChargingDock()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.STUCK then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.stuck()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.DUST_BIN_MISSING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.dustBinMissing()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.DUST_BIN_FULL then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.dustBinFull()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.WATER_TANK_EMPTY then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.waterTankEmpty()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.WATER_TANK_MISSING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.waterTankMissing()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.WATER_TANK_LID_OPEN then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.waterTankLidOpen()) + elseif operationalError == clusters.RvcOperationalState.types.ErrorStateEnum.MOP_CLEANING_PAD_MISSING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.operatingState.mopCleaningPadMissing()) + end +end + +local function rvc_operational_state_list_attr_handler(driver, device, ib, response) + local supportedOperatingState = {} + for _, state in ipairs(ib.data.elements) do + clusters.RvcOperationalState.types.OperationalStateStruct:augment_type(state) + if OPERATING_STATE_MAP[state.elements.operational_state_id.value] ~= nil then + table.insert(supportedOperatingState, OPERATING_STATE_MAP[state.elements.operational_state_id.value].NAME) + end + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.supportedOperatingStates( + supportedOperatingState, {visibility = {displayed = false}} + )) +end + +local function handle_rvc_operational_state_accepted_command_list(driver, device, ib, response) + device.log.info("handle_rvc_operational_state_accepted_command_list") + local OP_COMMAND_MAP = { + [clusters.RvcOperationalState.commands.Pause.ID] = cap_op_cmds.pause, + [clusters.RvcOperationalState.commands.Resume.ID] = cap_op_cmds.start, + [clusters.RvcOperationalState.commands.GoHome.ID] = cap_op_cmds.goHome + } + local supportedOperatingStateCommands = {} + for _, attr in ipairs(ib.data.elements) do + table.insert(supportedOperatingStateCommands, OP_COMMAND_MAP[attr.value].NAME) + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.robotCleanerOperatingState.supportedCommands( + supportedOperatingStateCommands, {visibility = {displayed = false}} + )) + + -- Get current run mode, current tag, current operating state + local current_run_mode = device:get_field(CURRENT_RUN_MODE) + local current_tag = 0xFFFF + local supported_run_modes = device:get_field(RUN_MODE_SUPPORTED_MODES) or {} + for _, mode in ipairs(supported_run_modes) do + if mode.label == current_run_mode then + current_tag = mode.tag + break + end + end + local current_state = device:get_latest_state( + "main", + capabilities.robotCleanerOperatingState.ID, + capabilities.robotCleanerOperatingState.operatingState.NAME + ) + if current_state ~= cap_op_enum.stopped.NAME and current_state ~= cap_op_enum.running.NAME and + current_state ~= cap_op_enum.paused.NAME and current_state ~= cap_op_enum.seekingCharger.NAME and + current_state ~= cap_op_enum.charging.NAME and current_state ~= cap_op_enum.docked.NAME then + current_state = "Error" + end + + -- Set Supported Operating State Commands + local supported_op_commands = {} + if can_send_state_command(device, cap_op_cmds.goHome.NAME, current_state, current_tag) == true then + table.insert(supported_op_commands, cap_op_cmds.goHome.NAME) + end + if can_send_state_command(device, cap_op_cmds.pause.NAME, current_state, current_tag) == true then + table.insert(supported_op_commands, cap_op_cmds.pause.NAME) + elseif can_send_state_command(device, cap_op_cmds.start.NAME, current_state, current_tag) == true or + can_send_state_command(device, capabilities.mode.commands.setMode.NAME, current_state, current_tag) == true then + table.insert(supported_op_commands, cap_op_cmds.start.NAME) + end + local event = capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + supported_op_commands, {visibility = {displayed = false}} + ) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function upper_to_camelcase(name) + local name_camelcase = (string.lower(name)):gsub("_"," ") + name_camelcase = name_camelcase:gsub("(%l)(%w*)", + function(a, b) + return string.upper(a) .. b + end) + return name_camelcase +end + +local function rvc_service_area_supported_areas_handler(driver, device, ib, response) + local supported_areas = {} + for i, area in ipairs(ib.data.elements) do + if version.api < 13 then + clusters.ServiceArea.types.AreaStruct:augment_type(area) + clusters.ServiceArea.types.AreaInfoStruct:augment_type(area.elements.area_info) + if area.elements.area_info.elements.location_info.elements ~= nil then + clusters.Global.types.LocationDescriptorStruct:augment_type(area.elements.area_info.elements.location_info) + end + end + local area_id = area.elements.area_id.value + local location_info = area.elements.area_info.elements.location_info.elements + local landmark_info = area.elements.area_info.elements.landmark_info.elements + local area_name = "" + -- Set the area name based on available location information + if location_info ~= nil then + if location_info.location_name.value ~= "" then + area_name = location_info.location_name.value + elseif location_info.floor_number.value ~= nil and location_info.area_type.value ~= nil then + area_name = location_info.floor_number.value .. "F " .. upper_to_camelcase(string.gsub(clusters.Global.types.AreaTypeTag.pretty_print(location_info.area_type),"AreaTypeTag: ","")) + elseif location_info.floor_number.value ~= nil then + area_name = location_info.floor_number.value .. "F" + elseif location_info.area_type.value ~= nil then + area_name = upper_to_camelcase(string.gsub(clusters.Global.types.AreaTypeTag.pretty_print(location_info.area_type),"AreaTypeTag: ","")) + end + end + if area_name == "" then + area_name = upper_to_camelcase(string.gsub(clusters.Global.types.LandmarkTag.pretty_print(landmark_info.landmark_tag),"LandmarkTag: ","")) + end + table.insert(supported_areas, {["areaId"] = area_id, ["areaName"] = area_name}) + end + + -- Update Supported Areas + local event = capabilities.serviceArea.supportedAreas(supported_areas, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +-- In case selected area is not in supportedarea then should i add to supported area or remove from selectedarea +local function rvc_service_area_selected_areas_handler(driver, device, ib, response) + local selected_areas = {} + for i, areaId in ipairs(ib.data.elements) do + table.insert(selected_areas, areaId.value) + end + + if next(selected_areas) == nil then + local supported_areas = device:get_latest_state( + "main", + capabilities.serviceArea.ID, + capabilities.serviceArea.supportedAreas.NAME + ) + for i, area in ipairs(supported_areas or {}) do + table.insert(selected_areas, area.areaId) + end + end + + local event = capabilities.serviceArea.selectedAreas(selected_areas, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +local function robot_cleaner_areas_selection_response_handler(driver, device, ib, response) + local select_areas_response = ib.info_block.data + if version.api < 13 then + clusters.ServiceArea.client.commands.SelectAreasResponse:augment_type(select_areas_response) + end + local status = select_areas_response.elements.status + local status_text = select_areas_response.elements.status_text + if status.value == clusters.ServiceArea.types.SelectAreasStatus.SUCCESS then + device.log.info(string.format("robot_cleaner_areas_selection_response_handler: %s, %s",status.pretty_print(status),status_text)) + else + device.log.error(string.format("robot_cleaner_areas_selection_response_handler: %s, %s",status.pretty_print(status),status_text)) + local selectedAreas = device:get_latest_state("main", capabilities.serviceArea.ID, capabilities.serviceArea.selectedAreas.NAME) + local event = capabilities.serviceArea.selectedAreas(selectedAreas, {state_change = true}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + end +end + +-- Capability Handlers -- +local function handle_robot_cleaner_operating_state_start(driver, device, cmd) + device.log.info("handle_robot_cleaner_operating_state_start") + local endpoint_id = find_default_endpoint(device, clusters.RvcOperationalState.ID) + + -- Get current run mode, current tag, current operating state + local current_run_mode = device:get_field(CURRENT_RUN_MODE) + local current_tag = 0xFFFF + local supported_run_modes = device:get_field(RUN_MODE_SUPPORTED_MODES) or {} + for _, mode in ipairs(supported_run_modes) do + if mode.label == current_run_mode then + current_tag = mode.tag + break + end + end + local current_state = device:get_latest_state( + "main", + capabilities.robotCleanerOperatingState.ID, + capabilities.robotCleanerOperatingState.operatingState.NAME + ) + if current_state ~= cap_op_enum.stopped.NAME and current_state ~= cap_op_enum.running.NAME and + current_state ~= cap_op_enum.paused.NAME and current_state ~= cap_op_enum.seekingCharger.NAME and + current_state ~= cap_op_enum.charging.NAME and current_state ~= cap_op_enum.docked.NAME then + current_state = "Error" + end + + if can_send_state_command(device, cap_op_cmds.start.NAME, current_state, current_tag) == true then + device:send(clusters.RvcOperationalState.commands.Resume(device, endpoint_id)) + elseif can_send_state_command(device, capabilities.mode.commands.setMode.NAME, current_state, current_tag) == true then + for _, mode in ipairs(supported_run_modes) do + endpoint_id = find_default_endpoint(device, clusters.RvcOperationalState.ID) + if mode.tag == clusters.RvcRunMode.types.ModeTag.CLEANING then + device:send(clusters.RvcRunMode.commands.ChangeToMode(device, endpoint_id, mode.id)) + return + end + end + end +end + +local function handle_robot_cleaner_operating_state_pause(driver, device, cmd) + device.log.info("handle_robot_cleaner_operating_state_pause") + local endpoint_id = find_default_endpoint(device, clusters.RvcOperationalState.ID) + device:send(clusters.RvcOperationalState.commands.Pause(device, endpoint_id)) +end + +local function handle_robot_cleaner_operating_state_go_home(driver, device, cmd) + device.log.info("handle_robot_cleaner_operating_state_go_home") + local endpoint_id = find_default_endpoint(device, clusters.RvcOperationalState.ID) + device:send(clusters.RvcOperationalState.commands.GoHome(device, endpoint_id)) +end + +local function handle_robot_cleaner_mode(driver, device, cmd) + device.log.info(string.format("handle_robot_cleaner_mode component: %s, mode: %s", cmd.component, cmd.args.mode)) + + local endpoint_id = find_default_endpoint(device, clusters.RvcOperationalState.ID) + local supported_modes = device:get_field(CLEAN_MODE_SUPPORTED_MODES) or {} + for _, mode in ipairs(supported_modes) do + if cmd.args.mode == mode.label then + device.log.info(string.format("mode.label: %s, mode.id: %s", mode.label, mode.id)) + device:send(clusters.RvcCleanMode.commands.ChangeToMode(device, endpoint_id, mode.id)) + return + end + end +end + +local uint32_dt = require "st.matter.data_types.Uint32" +local function handle_robot_cleaner_areas_selection(driver, device, cmd) + device.log.info(string.format("handle_robot_cleaner_areas_selection component: %s, serviceArea: %s", cmd.component, cmd.args.areas.value)) + + local selectAreas = clusters.ServiceArea.commands.SelectAreas(nil,nil,{}) + for i, areaId in ipairs(cmd.args.areas) do + table.insert(selectAreas, uint32_dt(areaId)) + end + local endpoint_id = find_default_endpoint(device, clusters.RvcOperationalState.ID) + if cmd.component == "main" then + device:send(clusters.ServiceArea.commands.SelectAreas(device, endpoint_id, selectAreas)) + end +end + +local matter_rvc_driver = { + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed, + driverSwitched = driver_switched + }, + matter_handlers = { + attr = { + [clusters.RvcRunMode.ID] = { + [clusters.RvcRunMode.attributes.SupportedModes.ID] = run_mode_supported_mode_handler, + [clusters.RvcRunMode.attributes.CurrentMode.ID] = run_mode_current_mode_handler, + }, + [clusters.RvcCleanMode.ID] = { + [clusters.RvcCleanMode.attributes.SupportedModes.ID] = clean_mode_supported_mode_handler, + [clusters.RvcCleanMode.attributes.CurrentMode.ID] = clean_mode_current_mode_handler, + }, + [clusters.RvcOperationalState.ID] = { + [clusters.RvcOperationalState.attributes.OperationalStateList.ID] = rvc_operational_state_list_attr_handler, + [clusters.RvcOperationalState.attributes.OperationalState.ID] = rvc_operational_state_attr_handler, + [clusters.RvcOperationalState.attributes.OperationalError.ID] = rvc_operational_error_attr_handler, + [clusters.RvcOperationalState.attributes.AcceptedCommandList.ID] = handle_rvc_operational_state_accepted_command_list, + }, + [clusters.ServiceArea.ID] = { + [clusters.ServiceArea.attributes.SupportedAreas.ID] = rvc_service_area_supported_areas_handler, + [clusters.ServiceArea.attributes.SelectedAreas.ID] = rvc_service_area_selected_areas_handler, + } + }, + cmd_response={ + [clusters.ServiceArea.ID] = { + [clusters.ServiceArea.client.commands.SelectAreasResponse.ID] = robot_cleaner_areas_selection_response_handler, + } + } + }, + subscribed_attributes = subscribed_attributes, + capability_handlers = { + [capabilities.robotCleanerOperatingState.ID] = { + [capabilities.robotCleanerOperatingState.commands.start.NAME] = handle_robot_cleaner_operating_state_start, + [capabilities.robotCleanerOperatingState.commands.pause.NAME] = handle_robot_cleaner_operating_state_pause, + [capabilities.robotCleanerOperatingState.commands.goHome.NAME] = handle_robot_cleaner_operating_state_go_home, + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = handle_robot_cleaner_mode, + }, + [capabilities.serviceArea.ID] = { + [capabilities.serviceArea.commands.selectAreas.NAME] = handle_robot_cleaner_areas_selection, + }, + }, +} + +local matter_driver = MatterDriver("matter-rvc", matter_rvc_driver) +matter_driver:run() diff --git a/drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua b/drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua new file mode 100644 index 0000000000..626f7fea60 --- /dev/null +++ b/drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua @@ -0,0 +1,1212 @@ +-- Copyright 2023 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local version = require "version" +test.add_package_capability("robotCleanerOperatingState.yml") + +if version.api < 10 then + clusters.RvcCleanMode = require "RvcCleanMode" + clusters.RvcOperationalState = require "RvcOperationalState" + clusters.RvcRunMode = require "RvcRunMode" + clusters.OperationalState = require "OperationalState" +end + +if version.api < 13 then + clusters.ServiceArea = require "ServiceArea" + clusters.Global = require "Global" +end + +local APPLICATION_ENDPOINT = 10 +local SERVICE_AREA_PROFILED = "__SERVICE_AREA_PROFILED" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("rvc-clean-mode-service-area.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = APPLICATION_ENDPOINT, + clusters = { + {cluster_id = clusters.RvcRunMode.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RvcCleanMode.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RvcOperationalState.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ServiceArea.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0074, device_type_revision = 1} -- Robot Vacuum Cleaner + } + } + } +}) + +local function test_init() + mock_device:set_field(SERVICE_AREA_PROFILED, true, { persist = true }) + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local subscribed_attributes = { + [capabilities.mode.ID] = { + clusters.RvcRunMode.attributes.SupportedModes, + clusters.RvcRunMode.attributes.CurrentMode, + clusters.RvcCleanMode.attributes.SupportedModes, + clusters.RvcCleanMode.attributes.CurrentMode, + }, + [capabilities.robotCleanerOperatingState.ID] = { + clusters.RvcOperationalState.attributes.OperationalStateList, + clusters.RvcOperationalState.attributes.OperationalState, + clusters.RvcOperationalState.attributes.OperationalError + }, + [capabilities.serviceArea.ID] = { + clusters.ServiceArea.attributes.SupportedAreas, + clusters.ServiceArea.attributes.SelectedAreas + } + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({ profile = "rvc-clean-mode-service-area" }) + test.socket.matter:__expect_send({mock_device.id, clusters.RvcOperationalState.attributes.AcceptedCommandList:read()}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +local uint32_dt = require "st.matter.data_types.Uint32" +local SUPPORTED_OPERATIONAL_STATE_COMMAND = { uint32_dt(0x0), uint32_dt(0x3), uint32_dt(0x80) } + +local modeTagStruct = require "RvcRunMode.types.ModeTagStruct" + +local IDLE_MODE = { label = "Idle Mode", mode = 0, mode_tags = { modeTagStruct({ mfg_code = 0x1E1E, value = 16384 }) } } +local CLEANING_MODE = { label = "Cleaning Mode", mode = 2, mode_tags = { modeTagStruct({ mfg_code = 0x1E1E, value = 16385 }) } } +local MAPPING_MODE = { label = "Mapping Mode", mode = 4, mode_tags = { modeTagStruct({ mfg_code = 0x1E1E, value = 16386 }) } } + +-- The `mode` fields for these are purposely out-of-order, so that we can make sure we aren't reliant on ordering. +local RUN_MODES = { + MAPPING_MODE, + IDLE_MODE, + CLEANING_MODE, +} + +local CLEAN_MODE_1 = { label = "Clean Mode 1", mode = 0, mode_tags = { modeTagStruct({ mfg_code = 0x1E1E, value = 1 }) } } +local CLEAN_MODE_2 = { label = "Clean Mode 2", mode = 1, mode_tags = { modeTagStruct({ mfg_code = 0x1E1E, value = 2 }) } } + +-- The `mode` fields for these are purposely out-of-order, so that we can make sure we aren't reliant on ordering. +local CLEAN_MODES = { + CLEAN_MODE_2, + CLEAN_MODE_1, +} + +local CLEAN_MODE_LABELS = { CLEAN_MODES[1].label, CLEAN_MODES[2].label } + +local function supported_run_mode_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.SupportedModes:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + { + clusters.RvcRunMode.types.ModeOptionStruct(RUN_MODES[1]), + clusters.RvcRunMode.types.ModeOptionStruct(RUN_MODES[2]), + clusters.RvcRunMode.types.ModeOptionStruct(RUN_MODES[3]), + } + ) + }) +end + +local function supported_clean_mode_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcCleanMode.attributes.SupportedModes:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + { + clusters.RvcCleanMode.types.ModeOptionStruct(CLEAN_MODES[1]), + clusters.RvcCleanMode.types.ModeOptionStruct(CLEAN_MODES[2]), + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedModes(CLEAN_MODE_LABELS, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedArguments(CLEAN_MODE_LABELS, { visibility = { displayed = false } }) + ) + ) +end + +local function operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.STOPPED + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.stopped() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.attributes.AcceptedCommandList:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + SUPPORTED_OPERATIONAL_STATE_COMMAND + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedCommands( + { + capabilities.robotCleanerOperatingState.commands.pause.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME, + capabilities.robotCleanerOperatingState.commands.goHome.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.goHome.NAME}, + {visibility = {displayed = false}} + ) + ) + ) +end + +test.register_coroutine_test( + "Handle driverSwitched event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + test.socket.matter:__expect_send({mock_device.id, clusters.RvcOperationalState.attributes.AcceptedCommandList:read()}) + mock_device:expect_metadata_update({ profile = "rvc-clean-mode-service-area" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Assert profile applied over doConfigure", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "rvc-clean-mode-service-area" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.RvcOperationalState.attributes.AcceptedCommandList:read() + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the run mode to a mode with an IDLE tag, supportedOperatingStateCommands must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the run mode to a mode with an CLEANING tag, supportedOperatingStateCommands must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + CLEANING_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.goHome.NAME}, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the run mode to a mode with an MAPPING tag, supportedOperatingStateCommands must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + MAPPING_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.goHome.NAME}, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the clean mode, mode must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + for _, cleanMode in ipairs(CLEAN_MODES) do + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcCleanMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + cleanMode.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.mode({value = cleanMode.label}) + ) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the rvc clean mode, appropriate RvcCleanMode command must be sent to the device", function() + supported_clean_mode_init() + operating_state_init() + test.wait_for_events() + for _, cleanMode in ipairs(CLEAN_MODES) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { cleanMode.label } } + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.RvcCleanMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, cleanMode.mode) + }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On receive the start Command of the capability, ChangeToMode command must be sent to the device", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "robotCleanerOperatingState", component = "main", command = "start", args = {} } + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.RvcRunMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, CLEANING_MODE.mode) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On receive the goHome Command of the capability, GoHome command must be sent to the device", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + CLEANING_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.goHome.NAME}, + {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "robotCleanerOperatingState", component = "main", command = "goHome", args = {} } + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.RvcOperationalState.commands.GoHome(mock_device, APPLICATION_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On receive the pause Command of the capability, Pause command must be sent to the device", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + CLEANING_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.goHome.NAME}, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.RUNNING + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.running() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.pause.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "robotCleanerOperatingState", component = "main", command = "pause", args = {} } + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.RvcOperationalState.commands.Pause(mock_device, APPLICATION_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the operatinalState to RUNNING, robotCleanerOperatingState must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.RUNNING + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.running() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.goHome.NAME}, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the operatinalState to PAUSED, robotCleanerOperatingState must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.PAUSED + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.paused() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the operatinalState to SEEKING_CHARGER, robotCleanerOperatingState must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.RvcOperationalState.types.OperationalStateEnum.SEEKING_CHARGER + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.seekingCharger() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.pause.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the operatinalState to CHARGING, robotCleanerOperatingState must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.RvcOperationalState.types.OperationalStateEnum.CHARGING + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.charging() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.start.NAME}, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the operatinalState to DOCKED, robotCleanerOperatingState must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.RvcOperationalState.types.OperationalStateEnum.DOCKED + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.docked() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {capabilities.robotCleanerOperatingState.commands.start.NAME}, + {visibility = {displayed = false}} + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On changing the OperationalError, robotCleanerOperatingState must be set to the appropriate value", function() + supported_run_mode_init() + supported_clean_mode_init() + operating_state_init() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcRunMode.attributes.CurrentMode:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + IDLE_MODE.mode + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + { + capabilities.robotCleanerOperatingState.commands.goHome.NAME, + capabilities.robotCleanerOperatingState.commands.start.NAME + }, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalState:build_test_report_data( + mock_device, + APPLICATION_ENDPOINT, + clusters.OperationalState.types.OperationalStateEnum.ERROR + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.supportedOperatingStateCommands( + {}, + {visibility = {displayed = false}} + ) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.unableToStartOrResume() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.unableToCompleteOperation() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.commandInvalidInState() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.FAILED_TO_FIND_CHARGING_DOCK, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.failedToFindChargingDock() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.STUCK, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.stuck() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.DUST_BIN_MISSING, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.dustBinMissing() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.DUST_BIN_FULL, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.dustBinFull() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.WATER_TANK_EMPTY, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.waterTankEmpty() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.WATER_TANK_MISSING, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.waterTankMissing() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.WATER_TANK_LID_OPEN, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.waterTankLidOpen() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RvcOperationalState.server.attributes.OperationalError:build_test_report_data( + mock_device, APPLICATION_ENDPOINT, + { + error_state_id = clusters.RvcOperationalState.types.ErrorStateEnum.MOP_CLEANING_PAD_MISSING, + error_state_label = "", + error_state_details = "" + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.robotCleanerOperatingState.operatingState.mopCleaningPadMissing() + ) + ) + end, + { + min_api_version = 17 + } +) + +local locationDescriptorStruct = require "Global.types.LocationDescriptorStruct" +local areaInfoStruct = require "ServiceArea.types.AreaInfoStruct" +local areaStruct = require "ServiceArea.types.AreaStruct" +local landmarkInfoStruct = require "ServiceArea.types.LandmarkInfoStruct" +test.register_message_test( + "Supported ServiceAreas must be registered", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ServiceArea.attributes.SupportedAreas:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + { + -- how to pass nil values here + areaStruct({ ["area_id"] = 0, ["map_id"] = 0, ["area_info"] = areaInfoStruct({ ["location_info"] = locationDescriptorStruct( {["location_name"] = "Location1", ["floor_number"] = 0, ["area_type"] = 0x04} ), ["landmark_info"] = landmarkInfoStruct({ ["landmark_tag"] = 0x1C, ["relative_position_tag"] = 0x02 })})}), + areaStruct({ ["area_id"] = 1, ["map_id"] = 0, ["area_info"] = areaInfoStruct({ ["location_info"] = locationDescriptorStruct( {["location_name"] = "", ["floor_number"] = 0, ["area_type"] = 0x04} ), ["landmark_info"] = landmarkInfoStruct({ ["landmark_tag"] = 0x1C, ["relative_position_tag"] = 0x02 })})}) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.serviceArea.supportedAreas({ + {["areaId"] = 0, ["areaName"] = "Location1" }, + {["areaId"] = 1, ["areaName"] = "0F Balcony" }, + }, { visibility = { displayed = false } })) + }, + }, + { + min_api_version = 17 + } +) + +local selectAreasStatus = require "ServiceArea.types.SelectAreasStatus" +test.register_message_test( + "ServiceArea attribute report must emit appropriate capability event", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ServiceArea.attributes.SelectedAreas:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.ServiceArea.attributes.SelectedAreas({uint32_dt(1),uint32_dt(2),uint32_dt(5)}) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.serviceArea.selectedAreas({ 1,2,5 }, { visibility = { displayed = false } })) + }, + }, + { + min_api_version = 17 + } +) + +local uint32_dt = require "st.matter.data_types.Uint32" +test.register_message_test( + "Select ServiceAreas command must trigger appropriate matter cluster", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "serviceArea", component = "main", command = "selectAreas", args = { {1, 2} } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ServiceArea.server.commands.SelectAreas(mock_device, APPLICATION_ENDPOINT, {uint32_dt(1),uint32_dt(2)}) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Selected ServiceAreasResponse must log Success status or emit last valid selectedAreas capability on non-Success status", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "serviceArea", component = "main", command = "selectAreas", args = { {1, 2, 5} } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ServiceArea.server.commands.SelectAreas(mock_device, APPLICATION_ENDPOINT, {uint32_dt(1),uint32_dt(2),uint32_dt(5)}) --0 is the index where Clean Mode 1 is stored. + } + }, + -- success response + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ServiceArea.client.commands.SelectAreasResponse:build_test_command_response(mock_device, APPLICATION_ENDPOINT, + selectAreasStatus.SUCCESS, "Success Response") + } + }, + -- Attribute report for last successful selectArea command + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ServiceArea.attributes.SelectedAreas:build_test_report_data(mock_device, APPLICATION_ENDPOINT, + clusters.ServiceArea.attributes.SelectedAreas({uint32_dt(1),uint32_dt(2),uint32_dt(5)}) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.serviceArea.selectedAreas({ 1,2,5 }, { visibility = { displayed = false } })) + }, + -- invalid command assuming area id 6 is not supported + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "serviceArea", component = "main", command = "selectAreas", args = { {1, 2, 6} } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ServiceArea.server.commands.SelectAreas(mock_device, APPLICATION_ENDPOINT, {uint32_dt(1),uint32_dt(2),uint32_dt(6)}) + } + }, + -- error response logs error and re-emits last valid 1,2,5 + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ServiceArea.client.commands.SelectAreasResponse:build_test_command_response(mock_device, APPLICATION_ENDPOINT, + selectAreasStatus.UNSUPPORTED_AREA, "AreaId 6 is not supported") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.serviceArea.selectedAreas({ 1,2,5 },{ state_change=true})) + }, + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index adb3b06f10..45b21dd386 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -5,6 +5,33 @@ matterManufacturer: vendorId: 0x115F productId: 0x2002 deviceProfileName: contact-battery + - id: "4447/8195" + deviceLabel: Aqara Motion and Light Sensor P2 + vendorId: 0x115F + productId: 0x2003 + deviceProfileName: motion-illuminance-battery + - id: "4447/8197" + deviceLabel: Presence Multi-Sensor FP300 + 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]" + vendorId: 0x1209 + productId: 0x3015 + deviceProfileName: contact-button-battery + #Elko + - id: "5170/4098" + deviceLabel: RFWD-100/MT + vendorId: 0x1432 + productId: 0x1002 + deviceProfileName: contact-battery #Eve - id: "Eve Motion" deviceLabel: Eve Motion @@ -26,18 +53,134 @@ matterManufacturer: deviceLabel: Heiman Motion Sensor vendorId: 0x120B productId: 0x1001 - deviceProfileName: matter-motion-battery-illuminance + deviceProfileName: motion-illuminance-battery - id: "4619/4145" deviceLabel: Heiman Door and Window D1-M vendorId: 0x120B productId: 0x1031 deviceProfileName: contact-battery + - id: "4619/4102" + deviceLabel: Heiman Smoke Sensor + vendorId: 0x120B + productId: 0x1006 + deviceProfileName: smoke + - id: "4619/4098" + deviceLabel: Heiman CO Sensor + vendorId: 0x120B + productId: 0x1002 + deviceProfileName: co-comeas + - id: "4619/4099" + deviceLabel: Heiman Smoke Sensor + vendorId: 0x120B + productId: 0x1003 + deviceProfileName: smoke + - id: "4619/4103" + deviceLabel: Smart co sensor + vendorId: 0x120B + productId: 0x1007 + deviceProfileName: co + - id: "4619/4104" + deviceLabel: Smart water leakage sensor + vendorId: 0x120B + productId: 0x1008 + deviceProfileName: leak-battery + - id: "4619/4192" + deviceLabel: Smart Humidity&Temperature Sensor + vendorId: 0x120B + productId: 0x1060 + deviceProfileName: temperature-humidity-battery + # Ikea + - id: "4476/32773" + deviceLabel: TIMMERFLOTTE Temperature/Humidity Sensor + vendorId: 0x117C + productId: 0x8005 + deviceProfileName: temperature-humidity-battery + - id: "4476/32774" + deviceLabel: KLIPPBOK Water Leak Sensor + vendorId: 0x117C + productId: 0x8006 + deviceProfileName: leak-battery + - id: "4476/12288" + deviceLabel: MYGGSPRAY Motion Sensor + vendorId: 0x117C + productId: 0x3000 + deviceProfileName: motion-illuminance-battery + - id: "4476/12289" + deviceLabel: ALPSTUGA Air Quality Sensor + vendorId: 0x117C + productId: 0x3001 + deviceProfileName: aqs-modular-temp-humidity + - id: "4476/32775" + deviceLabel: MYGGBETT Door/Window Sensor + vendorId: 0x117C + productId: 0x8007 + deviceProfileName: contact-battery # Legrand - id: "Legrand/Netatmo/Smart-2-in-1-Sensor" deviceLabel: Netatmo Smart 2-in-1 Sensor vendorId: 0x1021 productId: 0x0001 deviceProfileName: motion-contact-battery + # LG + - id: "4142/8208" + deviceLabel: LG Air Quality Sensor + vendorId: 0x102E + productId: 0x2010 + deviceProfileName: aqs-temp-humidity-co2-pm1-pm25-pm10-meas + # Maco + - id: "5447/1001" + deviceLabel: Sense by MACO | Window Pro T&T + vendorId: 0x1547 + productId: 0x03E9 + deviceProfileName: contact-battery + - id: "5447/1003" + deviceLabel: Sense by MACO | Casement + vendorId: 0x1547 + productId: 0x03EB + deviceProfileName: contact-battery + - id: "5447/1004" + deviceLabel: Sense by MACO | Door + vendorId: 0x1547 + productId: 0x03EC + deviceProfileName: contact-battery + - id: "5447/1005" + deviceLabel: Sense by MACO | Universal + vendorId: 0x1547 + productId: 0x03ED + deviceProfileName: contact-battery + - id: "5447/1002" + deviceLabel: Sense by MACO | Window TT + vendorId: 0x1547 + productId: 0x03EA + deviceProfileName: contact-battery + # Meross + - id: "4933/16897" + deviceLabel: Smart Presence Sensor + vendorId: 0x1345 + productId: 0x4201 + deviceProfileName: motion-illuminance + - id: "4933/16898" + deviceLabel: Smart Presence Sensor (Thread) + vendorId: 0x1345 + productId: 0x4202 + deviceProfileName: motion-illuminance-battery + # Neo + - id: "4991/1122" + deviceLabel: Door Sensor + vendorId: 0x137F + productId: 0x0462 + deviceProfileName: contact-battery + - id: "4991/1123" + deviceLabel: Motion Sensor + vendorId: 0x137F + productId: 0x0463 + deviceProfileName: motion-battery + #Tado + - id: "4942/6" + deviceLabel: Wireless Temperature Sensor X + vendorId: 0x134E + productId: 0x0006 + deviceProfileName: temperature-humidity #Tuo - id: "5150/3" deviceLabel: "TUO Temperature Sensor" @@ -49,6 +192,61 @@ matterManufacturer: vendorId: 0x141E productId: 0x0002 deviceProfileName: contact-battery + # LeTianpai + - id: "LeTianPai/Motion" + deviceLabel: LeTianPai Presence Sensor Box + vendorId: 0x142B + productId: 0x0002 + deviceProfileName: motion-illuminance-temperature-humidity + #LG + - id: "4142/8192" + deviceLabel: LG Temp and Humidity Sensor + vendorId: 0x102E + productId: 0x2000 + deviceProfileName: temperature-humidity-battery + - id: "4142/8224" + deviceLabel: LG Door and Window Sensor + vendorId: 0x102E + productId: 0x2020 + deviceProfileName: contact-battery + - id: "4142/8240" + deviceLabel: LG Motion Sensor + vendorId: 0x102E + productId: 0x2030 + deviceProfileName: motion-illuminance-battery + # Sensereo + - id: "5526/1" + deviceLabel: Sensereo Matter Smoke Alarm MS1 + vendorId: 0x1596 + productId: 0x0001 + deviceProfileName: smoke-battery + - id: "5526/2" + deviceLabel: MSC-1 + vendorId: 0x1596 + productId: 0x0002 + deviceProfileName: smoke-co-comeas-battery + # Siterwell + - id: "4736/847" + deviceLabel: Siterwell Door Window Sensor + vendorId: 0x1280 + productId: 0x034F + deviceProfileName: contact-battery + - id: "4736/32788" + deviceLabel: Siterwell Smart Smoke Alarm + vendorId: 0x1280 + productId: 0x8014 + deviceProfileName: smoke + - id: "4736/32789" + deviceLabel: Siterwell Smart Carbon Monoxide Alarm + vendorId: 0x1280 + productId: 0x8015 + deviceProfileName: co + - id: "4736/848" + deviceLabel: Siterwell Motion Sensor + vendorId: 0x1280 + productId: 0x0350 + deviceProfileName: motion-battery + matterGeneric: - id: "matter/contact/sensor" deviceLabel: Matter Contact Sensor @@ -101,6 +299,14 @@ matterGeneric: - id: 0x0106 # Light Sensor - id: 0x0302 # Temperature Sensor deviceProfileName: motion-illuminance-temperature-battery + - id: "matter/motion-illum-temp-humidity" + deviceLabel: Matter Motion/Illuminance Sensor + deviceTypes: + - id: 0x0107 # Occupancy Sensor + - id: 0x0106 # Light Sensor + - id: 0x0302 # Temperature Sensor + - id: 0x0307 # Humidity Sensor + deviceProfileName: motion-illuminance-temperature-humidity-battery - id: "matter/motion-sensor" deviceLabel: Matter Motion Sensor deviceTypes: @@ -112,3 +318,45 @@ matterGeneric: - id: 0x0107 # Occupancy Sensor - id: 0x0015 # Contact Sensor deviceProfileName: motion-contact-battery + - id: "matter/air-quality-sensor" + deviceLabel: Matter Air Quality Sensor + deviceTypes: + - id: 0x002C # Air Quality Sensor + deviceProfileName: aqs + - id: "matter/smoke-co-alarm" + deviceLabel: Matter Smoke/CO Alarm + deviceTypes: + - id: 0x0076 # Smoke CO Alarm + deviceProfileName: smoke-co + - id: "matter/smoke-temp-humidity/sensor" + deviceLabel: Matter Smoke/Temp/Humidity Sensor + deviceTypes: + - id: 0x0076 # Smoke CO Alarm + - id: 0x0307 # Humidity Sensor + - id: 0x0302 # Temperature Sensor + deviceProfileName: smoke-temp-humidity-battery + - id: "matter/rain/sensor" + deviceLabel: Matter Rain Sensor + deviceTypes: + - id: 0x0044 # Rain Sensor + deviceProfileName: rain-battery + - id: "matter/freeze/detector" + deviceLabel: Matter Water Freeze Detector + deviceTypes: + - id: 0x0041 # Freeze Detector + deviceProfileName: freeze-battery + - id: "matter/leak/detector" + deviceLabel: Matter Water Leak Detector + deviceTypes: + - id: 0x0043 # Leak Sensor + deviceProfileName: leak-battery + - id: "matter/flow/sensor" + deviceLabel: Matter Flow Sensor + deviceTypes: + - id: 0x0306 # Flow Sensor + deviceProfileName: flow-battery + - id: "matter/soil/sensor" + deviceLabel: Matter Soil Sensor + deviceTypes: + - id: 0x0045 # Soil Sensor + deviceProfileName: soil-sensor-battery 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/profiles/aqs-level.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-level.yml new file mode 100644 index 0000000000..447b9661a2 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-level.yml @@ -0,0 +1,51 @@ +name: aqs-level +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: carbonMonoxideHealthConcern + version: 1 + - id: carbonDioxideMeasurement + version: 1 + - id: carbonDioxideHealthConcern + version: 1 + - id: nitrogenDioxideMeasurement + version: 1 + - id: ozoneMeasurement + version: 1 + - id: formaldehydeMeasurement + version: 1 + - id: formaldehydeHealthConcern + version: 1 + - id: veryFineDustSensor + version: 1 + - id: veryFineDustHealthConcern + version: 1 + - id: fineDustHealthConcern + version: 1 + - id: dustSensor + version: 1 + - id: dustHealthConcern + version: 1 + - id: radonMeasurement + version: 1 + - id: tvocMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-modular-humidity.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-modular-humidity.yml new file mode 100644 index 0000000000..ad34c342dd --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-modular-humidity.yml @@ -0,0 +1,81 @@ +name: aqs-modular-humidity +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: carbonMonoxideMeasurement + version: 1 + optional: true + - id: carbonMonoxideHealthConcern + version: 1 + optional: true + - id: carbonDioxideMeasurement + version: 1 + optional: true + - id: carbonDioxideHealthConcern + version: 1 + optional: true + - id: nitrogenDioxideMeasurement + version: 1 + optional: true + - id: nitrogenDioxideHealthConcern + version: 1 + optional: true + - id: ozoneMeasurement + version: 1 + optional: true + - id: ozoneHealthConcern + version: 1 + optional: true + - id: formaldehydeMeasurement + version: 1 + optional: true + - id: formaldehydeHealthConcern + version: 1 + optional: true + - id: veryFineDustSensor + version: 1 + optional: true + - id: veryFineDustHealthConcern + version: 1 + optional: true + - id: fineDustSensor + version: 1 + optional: true + - id: fineDustHealthConcern + version: 1 + optional: true + - id: dustSensor + version: 1 + optional: true + - id: dustHealthConcern + version: 1 + optional: true + - id: radonMeasurement + version: 1 + optional: true + - id: radonHealthConcern + version: 1 + optional: true + - id: tvocMeasurement + version: 1 + optional: true + - id: tvocHealthConcern + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp-humidity.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp-humidity.yml new file mode 100644 index 0000000000..1f116c5a26 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp-humidity.yml @@ -0,0 +1,83 @@ +name: aqs-modular-temp-humidity +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: carbonMonoxideMeasurement + version: 1 + optional: true + - id: carbonMonoxideHealthConcern + version: 1 + optional: true + - id: carbonDioxideMeasurement + version: 1 + optional: true + - id: carbonDioxideHealthConcern + version: 1 + optional: true + - id: nitrogenDioxideMeasurement + version: 1 + optional: true + - id: nitrogenDioxideHealthConcern + version: 1 + optional: true + - id: ozoneMeasurement + version: 1 + optional: true + - id: ozoneHealthConcern + version: 1 + optional: true + - id: formaldehydeMeasurement + version: 1 + optional: true + - id: formaldehydeHealthConcern + version: 1 + optional: true + - id: veryFineDustSensor + version: 1 + optional: true + - id: veryFineDustHealthConcern + version: 1 + optional: true + - id: fineDustSensor + version: 1 + optional: true + - id: fineDustHealthConcern + version: 1 + optional: true + - id: dustSensor + version: 1 + optional: true + - id: dustHealthConcern + version: 1 + optional: true + - id: radonMeasurement + version: 1 + optional: true + - id: radonHealthConcern + version: 1 + optional: true + - id: tvocMeasurement + version: 1 + optional: true + - id: tvocHealthConcern + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp.yml new file mode 100644 index 0000000000..3f53e88e17 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-modular-temp.yml @@ -0,0 +1,81 @@ +name: aqs-modular-temp +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: carbonMonoxideMeasurement + version: 1 + optional: true + - id: carbonMonoxideHealthConcern + version: 1 + optional: true + - id: carbonDioxideMeasurement + version: 1 + optional: true + - id: carbonDioxideHealthConcern + version: 1 + optional: true + - id: nitrogenDioxideMeasurement + version: 1 + optional: true + - id: nitrogenDioxideHealthConcern + version: 1 + optional: true + - id: ozoneMeasurement + version: 1 + optional: true + - id: ozoneHealthConcern + version: 1 + optional: true + - id: formaldehydeMeasurement + version: 1 + optional: true + - id: formaldehydeHealthConcern + version: 1 + optional: true + - id: veryFineDustSensor + version: 1 + optional: true + - id: veryFineDustHealthConcern + version: 1 + optional: true + - id: fineDustSensor + version: 1 + optional: true + - id: fineDustHealthConcern + version: 1 + optional: true + - id: dustSensor + version: 1 + optional: true + - id: dustHealthConcern + version: 1 + optional: true + - id: radonMeasurement + version: 1 + optional: true + - id: radonHealthConcern + version: 1 + optional: true + - id: tvocMeasurement + version: 1 + optional: true + - id: tvocHealthConcern + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-modular.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-modular.yml new file mode 100644 index 0000000000..44466fe9ff --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-modular.yml @@ -0,0 +1,78 @@ +name: aqs-modular +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: carbonMonoxideMeasurement + version: 1 + optional: true + - id: carbonMonoxideHealthConcern + version: 1 + optional: true + - id: carbonDioxideMeasurement + version: 1 + optional: true + - id: carbonDioxideHealthConcern + version: 1 + optional: true + - id: nitrogenDioxideMeasurement + version: 1 + optional: true + - id: nitrogenDioxideHealthConcern + version: 1 + optional: true + - id: ozoneMeasurement + version: 1 + optional: true + - id: ozoneHealthConcern + version: 1 + optional: true + - id: formaldehydeMeasurement + version: 1 + optional: true + - id: formaldehydeHealthConcern + version: 1 + optional: true + - id: veryFineDustSensor + version: 1 + optional: true + - id: veryFineDustHealthConcern + version: 1 + optional: true + - id: fineDustSensor + version: 1 + optional: true + - id: fineDustHealthConcern + version: 1 + optional: true + - id: dustSensor + version: 1 + optional: true + - id: dustHealthConcern + version: 1 + optional: true + - id: radonMeasurement + version: 1 + optional: true + - id: radonHealthConcern + version: 1 + optional: true + - id: tvocMeasurement + version: 1 + optional: true + - id: tvocHealthConcern + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level-all-meas.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level-all-meas.yml new file mode 100644 index 0000000000..4b7ff49a7f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level-all-meas.yml @@ -0,0 +1,59 @@ +name: aqs-temp-humidity-all-level-all-meas +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: carbonMonoxideHealthConcern + version: 1 + - id: carbonDioxideMeasurement + version: 1 + - id: carbonDioxideHealthConcern + version: 1 + - id: nitrogenDioxideMeasurement + version: 1 + - id: nitrogenDioxideHealthConcern + version: 1 + - id: ozoneMeasurement + version: 1 + - id: ozoneHealthConcern + version: 1 + - id: formaldehydeMeasurement + version: 1 + - id: formaldehydeHealthConcern + version: 1 + - id: veryFineDustSensor + version: 1 + - id: veryFineDustHealthConcern + version: 1 + - id: fineDustHealthConcern + version: 1 + - id: dustSensor + version: 1 + - id: dustHealthConcern + version: 1 + - id: radonMeasurement + version: 1 + - id: radonHealthConcern + version: 1 + - id: tvocMeasurement + version: 1 + - id: tvocHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level.yml new file mode 100644 index 0000000000..f0d6e4ffb3 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-level.yml @@ -0,0 +1,41 @@ +name: aqs-temp-humidity-all-level +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: nitrogenDioxideHealthConcern + version: 1 + - id: ozoneHealthConcern + version: 1 + - id: carbonMonoxideHealthConcern + version: 1 + - id: carbonDioxideHealthConcern + version: 1 + - id: formaldehydeHealthConcern + version: 1 + - id: veryFineDustHealthConcern + version: 1 + - id: fineDustHealthConcern + version: 1 + - id: dustHealthConcern + version: 1 + - id: radonHealthConcern + version: 1 + - id: tvocHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-meas.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-meas.yml new file mode 100644 index 0000000000..fd9fd01335 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-all-meas.yml @@ -0,0 +1,39 @@ +name: aqs-temp-humidity-all-meas +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: nitrogenDioxideMeasurement + version: 1 + - id: ozoneMeasurement + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: carbonDioxideMeasurement + version: 1 + - id: formaldehydeMeasurement + version: 1 + - id: veryFineDustSensor + version: 1 + - id: dustSensor + version: 1 + - id: radonMeasurement + version: 1 + - id: tvocMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm1-pm25-pm10-meas.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm1-pm25-pm10-meas.yml new file mode 100644 index 0000000000..92c8564e0f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm1-pm25-pm10-meas.yml @@ -0,0 +1,29 @@ +name: aqs-temp-humidity-co2-pm1-pm25-pm10-meas +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: carbonDioxideMeasurement + version: 1 + - id: veryFineDustSensor + version: 1 + - id: fineDustSensor + version: 1 + - id: dustSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm25-tvoc-meas.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm25-tvoc-meas.yml new file mode 100644 index 0000000000..390308a2fc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-co2-pm25-tvoc-meas.yml @@ -0,0 +1,27 @@ +name: aqs-temp-humidity-co2-pm25-tvoc-meas +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: carbonDioxideMeasurement + version: 1 + - id: fineDustSensor + version: 1 + - id: tvocMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-level-pm25-meas.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-level-pm25-meas.yml new file mode 100644 index 0000000000..0d5e6dec80 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-level-pm25-meas.yml @@ -0,0 +1,25 @@ +name: aqs-temp-humidity-tvoc-level-pm25-meas +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: fineDustSensor + version: 1 + - id: tvocHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-meas.yml b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-meas.yml new file mode 100644 index 0000000000..8195eb159c --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs-temp-humidity-tvoc-meas.yml @@ -0,0 +1,18 @@ +name: aqs-temp-humidity-tvoc-meas +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: tvocMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector diff --git a/drivers/SmartThings/matter-sensor/profiles/aqs.yml b/drivers/SmartThings/matter-sensor/profiles/aqs.yml new file mode 100644 index 0000000000..64626d9a46 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqs.yml @@ -0,0 +1,12 @@ +name: aqs +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector diff --git a/drivers/SmartThings/matter-sensor/profiles/co-battery.yml b/drivers/SmartThings/matter-sensor/profiles/co-battery.yml new file mode 100644 index 0000000000..25d1bb7f03 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/co-battery.yml @@ -0,0 +1,16 @@ +name: co-battery +components: +- id: main + capabilities: + - id: carbonMonoxideDetector + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/co-comeas-battery.yml b/drivers/SmartThings/matter-sensor/profiles/co-comeas-battery.yml new file mode 100644 index 0000000000..4c8f554572 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/co-comeas-battery.yml @@ -0,0 +1,18 @@ +name: co-comeas-battery +components: +- id: main + capabilities: + - id: carbonMonoxideDetector + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/co-comeas-colevel-battery.yml b/drivers/SmartThings/matter-sensor/profiles/co-comeas-colevel-battery.yml new file mode 100644 index 0000000000..78aa66613f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/co-comeas-colevel-battery.yml @@ -0,0 +1,20 @@ +name: co-comeas-colevel-battery +components: +- id: main + capabilities: + - id: carbonMonoxideDetector + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: carbonMonoxideHealthConcern + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/co-comeas.yml b/drivers/SmartThings/matter-sensor/profiles/co-comeas.yml new file mode 100644 index 0000000000..a9a67aa7b5 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/co-comeas.yml @@ -0,0 +1,18 @@ +name: co-comeas +components: +- id: main + capabilities: + - id: carbonMonoxideDetector + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/co.yml b/drivers/SmartThings/matter-sensor/profiles/co.yml new file mode 100644 index 0000000000..c949c5e868 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/co.yml @@ -0,0 +1,16 @@ +name: co +components: +- id: main + capabilities: + - id: carbonMonoxideDetector + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/contact-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/contact-batteryLevel.yml new file mode 100644 index 0000000000..0b316d733e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/contact-batteryLevel.yml @@ -0,0 +1,14 @@ +name: contact-batteryLevel +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/contact-button-battery.yml b/drivers/SmartThings/matter-sensor/profiles/contact-button-battery.yml new file mode 100644 index 0000000000..f4e1d1ddae --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/contact-button-battery.yml @@ -0,0 +1,16 @@ +name: contact-button-battery +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/flow-battery.yml b/drivers/SmartThings/matter-sensor/profiles/flow-battery.yml new file mode 100644 index 0000000000..79e37550cd --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/flow-battery.yml @@ -0,0 +1,14 @@ +name: flow-battery +components: +- id: main + capabilities: + - id: flowMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: FlowSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/flow-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/flow-batteryLevel.yml new file mode 100644 index 0000000000..769064313b --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/flow-batteryLevel.yml @@ -0,0 +1,14 @@ +name: flow-batteryLevel +components: +- id: main + capabilities: + - id: flowMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: FlowSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/flow.yml b/drivers/SmartThings/matter-sensor/profiles/flow.yml new file mode 100644 index 0000000000..cd3bcff694 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/flow.yml @@ -0,0 +1,12 @@ +name: flow +components: +- id: main + capabilities: + - id: flowMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: FlowSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault-freezeSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault-freezeSensitivity.yml new file mode 100644 index 0000000000..d01dac215c --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault-freezeSensitivity.yml @@ -0,0 +1,25 @@ +name: freeze-battery-fault-freezeSensitivity +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector +preferences: + - preferenceId: freezeSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault.yml new file mode 100644 index 0000000000..4c14b5e4d7 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-battery-fault.yml @@ -0,0 +1,23 @@ +name: freeze-battery-fault +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector + diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-battery-freezeSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-battery-freezeSensitivity.yml new file mode 100644 index 0000000000..5fb72e6cf7 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-battery-freezeSensitivity.yml @@ -0,0 +1,23 @@ +name: freeze-battery-freezeSensitivity +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector +preferences: + - preferenceId: freezeSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-battery.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-battery.yml new file mode 100644 index 0000000000..c0cea79d51 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-battery.yml @@ -0,0 +1,20 @@ +name: freeze-battery +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault-freezeSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault-freezeSensitivity.yml new file mode 100644 index 0000000000..833d4bb843 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault-freezeSensitivity.yml @@ -0,0 +1,25 @@ +name: freeze-batteryLevel-fault-freezeSensitivity +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector +preferences: + - preferenceId: freezeSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault.yml new file mode 100644 index 0000000000..302ab27b91 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-fault.yml @@ -0,0 +1,23 @@ +name: freeze-batteryLevel-fault +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector + diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-freezeSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-freezeSensitivity.yml new file mode 100644 index 0000000000..84370e805d --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel-freezeSensitivity.yml @@ -0,0 +1,23 @@ +name: freeze-batteryLevel-freezeSensitivity +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector +preferences: + - preferenceId: freezeSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel.yml new file mode 100644 index 0000000000..00c6568b17 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-batteryLevel.yml @@ -0,0 +1,20 @@ +name: freeze-batteryLevel +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-fault-freezeSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-fault-freezeSensitivity.yml new file mode 100644 index 0000000000..2d270fc29e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-fault-freezeSensitivity.yml @@ -0,0 +1,23 @@ +name: freeze-fault-freezeSensitivity +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector +preferences: + - preferenceId: freezeSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-fault.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-fault.yml new file mode 100644 index 0000000000..de91fc5034 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-fault.yml @@ -0,0 +1,20 @@ +name: freeze-fault +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze-leak-fault-freezeSensitivity-leakSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/freeze-leak-fault-freezeSensitivity-leakSensitivity.yml new file mode 100644 index 0000000000..7211714c1e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze-leak-fault-freezeSensitivity-leakSensitivity.yml @@ -0,0 +1,27 @@ +name: freeze-leak-fault-freezeSensitivity-leakSensitivity +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: waterSensor + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor +preferences: + - preferenceId: freezeSensitivity + explicit: true + - preferenceId: leakSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/freeze.yml b/drivers/SmartThings/matter-sensor/profiles/freeze.yml new file mode 100644 index 0000000000..111fc4c71a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/freeze.yml @@ -0,0 +1,18 @@ +name: freeze +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - cleared + - freeze + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterFreezeDetector diff --git a/drivers/SmartThings/matter-sensor/profiles/humidity-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/humidity-batteryLevel.yml new file mode 100644 index 0000000000..69a791abeb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/humidity-batteryLevel.yml @@ -0,0 +1,17 @@ +name: humidity-batteryLevel +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/illuminance-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/illuminance-batteryLevel.yml new file mode 100644 index 0000000000..c5b5fed1b4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/illuminance-batteryLevel.yml @@ -0,0 +1,14 @@ +name: illuminance-batteryLevel +components: +- id: main + capabilities: + - id: illuminanceMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LightSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-battery-fault-leakSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/leak-battery-fault-leakSensitivity.yml new file mode 100644 index 0000000000..0071ff9687 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-battery-fault-leakSensitivity.yml @@ -0,0 +1,19 @@ +name: leak-battery-fault-leakSensitivity +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor +preferences: + - preferenceId: leakSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-battery-fault.yml b/drivers/SmartThings/matter-sensor/profiles/leak-battery-fault.yml new file mode 100644 index 0000000000..6fd911c331 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-battery-fault.yml @@ -0,0 +1,16 @@ +name: leak-battery-fault +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-battery-leakSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/leak-battery-leakSensitivity.yml new file mode 100644 index 0000000000..d7eec73cc7 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-battery-leakSensitivity.yml @@ -0,0 +1,17 @@ +name: leak-battery-leakSensitivity +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor +preferences: + - preferenceId: leakSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-battery.yml b/drivers/SmartThings/matter-sensor/profiles/leak-battery.yml new file mode 100644 index 0000000000..6b90147bea --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-battery.yml @@ -0,0 +1,14 @@ +name: leak-battery +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault-leakSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault-leakSensitivity.yml new file mode 100644 index 0000000000..0010516e33 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault-leakSensitivity.yml @@ -0,0 +1,19 @@ +name: leak-batteryLevel-fault-leakSensitivity +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor +preferences: + - preferenceId: leakSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault.yml b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault.yml new file mode 100644 index 0000000000..5a191eedbc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-fault.yml @@ -0,0 +1,16 @@ +name: leak-batteryLevel-fault +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-leakSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-leakSensitivity.yml new file mode 100644 index 0000000000..b1369947a6 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel-leakSensitivity.yml @@ -0,0 +1,17 @@ +name: leak-batteryLevel-leakSensitivity +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor +preferences: + - preferenceId: leakSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel.yml new file mode 100644 index 0000000000..813873b88c --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-batteryLevel.yml @@ -0,0 +1,14 @@ +name: leak-batteryLevel +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-fault-leakSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/leak-fault-leakSensitivity.yml new file mode 100644 index 0000000000..51697afe81 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-fault-leakSensitivity.yml @@ -0,0 +1,17 @@ +name: leak-fault-leakSensitivity +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor +preferences: + - preferenceId: leakSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/leak-fault.yml b/drivers/SmartThings/matter-sensor/profiles/leak-fault.yml new file mode 100644 index 0000000000..3b79709f3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak-fault.yml @@ -0,0 +1,14 @@ +name: leak-fault +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/leak.yml b/drivers/SmartThings/matter-sensor/profiles/leak.yml new file mode 100644 index 0000000000..a2f6d07727 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/leak.yml @@ -0,0 +1,12 @@ +name: leak +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery-illuminance.yml b/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery-illuminance.yml index b6c30d3421..3bac085178 100644 --- a/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery-illuminance.yml +++ b/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery-illuminance.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: matter-motion-battery-illuminance components: - id: main diff --git a/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery.yml b/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery.yml index 0e2b57c71e..66ea49c033 100644 --- a/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery.yml +++ b/drivers/SmartThings/matter-sensor/profiles/matter-motion-battery.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: matter-motion-battery components: - id: main diff --git a/drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel-illuminance.yml b/drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel-illuminance.yml new file mode 100644 index 0000000000..f9f97b3360 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel-illuminance.yml @@ -0,0 +1,17 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: matter-motion-batteryLevel-illuminance +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel.yml new file mode 100644 index 0000000000..fa497ba042 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/matter-motion-batteryLevel.yml @@ -0,0 +1,15 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: matter-motion-batteryLevel +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/motion-batteryLevel.yml new file mode 100644 index 0000000000..f03ac736f4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/motion-batteryLevel.yml @@ -0,0 +1,14 @@ +name: motion-batteryLevel +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-contact-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/motion-contact-batteryLevel.yml new file mode 100644 index 0000000000..126d90b2f7 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/motion-contact-batteryLevel.yml @@ -0,0 +1,16 @@ +name: motion-contact-batteryLevel +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: contactSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-batteryLevel.yml new file mode 100644 index 0000000000..2eb8c5697f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-batteryLevel.yml @@ -0,0 +1,16 @@ +name: motion-illuminance-batteryLevel +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-battery.yml b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-battery.yml index c68c026deb..128094102b 100644 --- a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-battery.yml +++ b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-battery.yml @@ -15,4 +15,7 @@ components: - id: refresh version: 1 categories: - - name: MotionSensor \ No newline at end of file + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-batteryLevel.yml new file mode 100644 index 0000000000..a7312ca0b5 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-batteryLevel.yml @@ -0,0 +1,18 @@ +name: motion-illuminance-temperature-batteryLevel +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-battery.yml b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-battery.yml new file mode 100644 index 0000000000..689f0c1eac --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-battery.yml @@ -0,0 +1,25 @@ +name: motion-illuminance-temperature-humidity-battery +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-batteryLevel.yml new file mode 100644 index 0000000000..00708e6430 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity-batteryLevel.yml @@ -0,0 +1,25 @@ +name: motion-illuminance-temperature-humidity-batteryLevel +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity.yml b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity.yml new file mode 100644 index 0000000000..8ec14888a0 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature-humidity.yml @@ -0,0 +1,23 @@ +name: motion-illuminance-temperature-humidity +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature.yml b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature.yml index 1e8db346ec..41bd2e90b3 100644 --- a/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature.yml +++ b/drivers/SmartThings/matter-sensor/profiles/motion-illuminance-temperature.yml @@ -13,4 +13,7 @@ components: - id: refresh version: 1 categories: - - name: MotionSensor \ No newline at end of file + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/motion.yml b/drivers/SmartThings/matter-sensor/profiles/motion.yml index 533941ced0..150f7e12d1 100644 --- a/drivers/SmartThings/matter-sensor/profiles/motion.yml +++ b/drivers/SmartThings/matter-sensor/profiles/motion.yml @@ -1,4 +1,4 @@ -name: matter-motion +name: motion components: - id: main capabilities: diff --git a/drivers/SmartThings/matter-sensor/profiles/presence-illuminance-temperature-humidity-battery.yml b/drivers/SmartThings/matter-sensor/profiles/presence-illuminance-temperature-humidity-battery.yml new file mode 100644 index 0000000000..44bea493f4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/presence-illuminance-temperature-humidity-battery.yml @@ -0,0 +1,25 @@ +name: presence-illuminance-temperature-humidity-battery +components: +- id: main + capabilities: + - id: presenceSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/pressure-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/pressure-batteryLevel.yml new file mode 100644 index 0000000000..c0101134a5 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/pressure-batteryLevel.yml @@ -0,0 +1,14 @@ +name: pressure-batteryLevel +components: +- id: main + capabilities: + - id: atmosphericPressureMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: GenericSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-battery-fault-rainSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/rain-battery-fault-rainSensitivity.yml new file mode 100644 index 0000000000..9418abc845 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-battery-fault-rainSensitivity.yml @@ -0,0 +1,19 @@ +name: rain-battery-fault-rainSensitivity +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor +preferences: + - preferenceId: rainSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-battery-fault.yml b/drivers/SmartThings/matter-sensor/profiles/rain-battery-fault.yml new file mode 100644 index 0000000000..f1db9cafb8 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-battery-fault.yml @@ -0,0 +1,16 @@ +name: rain-battery-fault +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-battery-rainSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/rain-battery-rainSensitivity.yml new file mode 100644 index 0000000000..ee3c7c1a65 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-battery-rainSensitivity.yml @@ -0,0 +1,17 @@ +name: rain-battery-rainSensitivity +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor +preferences: + - preferenceId: rainSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-battery.yml b/drivers/SmartThings/matter-sensor/profiles/rain-battery.yml new file mode 100644 index 0000000000..ca794aeaaa --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-battery.yml @@ -0,0 +1,14 @@ +name: rain-battery +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault-rainSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault-rainSensitivity.yml new file mode 100644 index 0000000000..a5db69edf6 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault-rainSensitivity.yml @@ -0,0 +1,19 @@ +name: rain-batteryLevel-fault-rainSensitivity +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor +preferences: + - preferenceId: rainSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault.yml b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault.yml new file mode 100644 index 0000000000..45db688cc4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-fault.yml @@ -0,0 +1,16 @@ +name: rain-batteryLevel-fault +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-rainSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-rainSensitivity.yml new file mode 100644 index 0000000000..6dfb406af7 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel-rainSensitivity.yml @@ -0,0 +1,17 @@ +name: rain-batteryLevel-rainSensitivity +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor +preferences: + - preferenceId: rainSensitivity + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel.yml new file mode 100644 index 0000000000..75f8afa8fb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-batteryLevel.yml @@ -0,0 +1,14 @@ +name: rain-batteryLevel +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-fault-rainSensitivity.yml b/drivers/SmartThings/matter-sensor/profiles/rain-fault-rainSensitivity.yml new file mode 100644 index 0000000000..9f080c12b6 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-fault-rainSensitivity.yml @@ -0,0 +1,17 @@ +name: rain-fault-rainSensitivity +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor +preferences: + - preferenceId: rainSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/rain-fault.yml b/drivers/SmartThings/matter-sensor/profiles/rain-fault.yml new file mode 100644 index 0000000000..c5a3a8866a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain-fault.yml @@ -0,0 +1,14 @@ +name: rain-fault +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/rain.yml b/drivers/SmartThings/matter-sensor/profiles/rain.yml new file mode 100644 index 0000000000..c55b5d489f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/rain.yml @@ -0,0 +1,12 @@ +name: rain +components: +- id: main + capabilities: + - id: rainSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RainSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/sensor-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/sensor-batteryLevel.yml new file mode 100644 index 0000000000..e1765f0a20 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/sensor-batteryLevel.yml @@ -0,0 +1,22 @@ +name: sensor-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: contactSensor + version: 1 + - id: motionSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: GenericSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/sensor.yml b/drivers/SmartThings/matter-sensor/profiles/sensor.yml index 37d331c345..7a05bf5fd2 100644 --- a/drivers/SmartThings/matter-sensor/profiles/sensor.yml +++ b/drivers/SmartThings/matter-sensor/profiles/sensor.yml @@ -20,3 +20,8 @@ components: version: 1 categories: - name: GenericSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-battery.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-battery.yml new file mode 100644 index 0000000000..8abff2f38e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-battery.yml @@ -0,0 +1,19 @@ +name: smoke-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-co-battery.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-co-battery.yml new file mode 100644 index 0000000000..96d036d131 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-co-battery.yml @@ -0,0 +1,21 @@ +name: smoke-co-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas-battery.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas-battery.yml new file mode 100644 index 0000000000..795594d8a4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas-battery.yml @@ -0,0 +1,23 @@ +name: smoke-co-comeas-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas.yml new file mode 100644 index 0000000000..0520774eab --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-co-comeas.yml @@ -0,0 +1,23 @@ +name: smoke-co-comeas +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas-battery.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas-battery.yml new file mode 100644 index 0000000000..c7c3b3c00b --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas-battery.yml @@ -0,0 +1,31 @@ +name: smoke-co-temp-humidity-comeas-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas.yml new file mode 100644 index 0000000000..0db8318c93 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-co-temp-humidity-comeas.yml @@ -0,0 +1,31 @@ +name: smoke-co-temp-humidity-comeas +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-co.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-co.yml new file mode 100644 index 0000000000..1fc5e15c37 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-co.yml @@ -0,0 +1,21 @@ +name: smoke-co +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: batteryLevel + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke-temp-humidity-battery.yml b/drivers/SmartThings/matter-sensor/profiles/smoke-temp-humidity-battery.yml new file mode 100644 index 0000000000..cadbe9eb8a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke-temp-humidity-battery.yml @@ -0,0 +1,25 @@ +name: smoke-temp-humidity-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/smoke.yml b/drivers/SmartThings/matter-sensor/profiles/smoke.yml new file mode 100644 index 0000000000..57785cc58a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/smoke.yml @@ -0,0 +1,19 @@ +name: smoke +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: hardwareFault + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector +preferences: + - preferenceId: certifiedpreferences.smokeSensorSensitivity + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/profiles/soil-sensor-battery.yml b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-battery.yml new file mode 100644 index 0000000000..07c9162e4d --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-battery.yml @@ -0,0 +1,17 @@ +name: soil-sensor-battery +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/soil-sensor-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-batteryLevel.yml new file mode 100644 index 0000000000..cfda9b9e95 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-batteryLevel.yml @@ -0,0 +1,17 @@ +name: soil-sensor-batteryLevel +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/soil-sensor.yml b/drivers/SmartThings/matter-sensor/profiles/soil-sensor.yml new file mode 100644 index 0000000000..280edc80c4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/soil-sensor.yml @@ -0,0 +1,15 @@ +name: soil-sensor +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-batteryLevel.yml new file mode 100644 index 0000000000..c2134f7194 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-batteryLevel.yml @@ -0,0 +1,17 @@ +name: temperature-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: + - preferenceId: tempOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-battery.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-battery.yml index 075da58388..01a7b7e1e4 100644 --- a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-battery.yml +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-battery.yml @@ -13,9 +13,51 @@ components: - id: refresh version: 1 categories: - - name: Thermostat + - name: TempHumiditySensor preferences: - preferenceId: tempOffset explicit: true - preferenceId: humidityOffset explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-batteryLevel.yml new file mode 100644 index 0000000000..0383f3de5d --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-batteryLevel.yml @@ -0,0 +1,63 @@ +name: temperature-humidity-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: TempHumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-battery.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-battery.yml index ecb432fb88..ada14f948c 100644 --- a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-battery.yml +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-battery.yml @@ -15,9 +15,57 @@ components: - id: refresh version: 1 categories: - - name: HumiditySensor + - name: TempHumiditySensor preferences: - preferenceId: tempOffset explicit: true - preferenceId: humidityOffset explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: atmosphericPressureMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: atmosphericPressureMeasurement + version: 1 + - component: main + capability: battery + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-batteryLevel.yml new file mode 100644 index 0000000000..dbfa2498f5 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure-batteryLevel.yml @@ -0,0 +1,71 @@ +name: temperature-humidity-pressure-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: atmosphericPressureMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: TempHumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: atmosphericPressureMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: atmosphericPressureMeasurement + version: 1 + - component: main + capability: battery + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure.yml index 424ded6795..4f6a0988af 100644 --- a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure.yml +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity-pressure.yml @@ -13,9 +13,51 @@ components: - id: refresh version: 1 categories: - - name: HumiditySensor + - name: TempHumiditySensor preferences: - preferenceId: tempOffset explicit: true - preferenceId: humidityOffset explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: atmosphericPressureMeasurement + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: atmosphericPressureMeasurement + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity.yml index 620037b1da..25ee0bb9c4 100644 --- a/drivers/SmartThings/matter-sensor/profiles/temperature-humidity.yml +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-humidity.yml @@ -11,9 +11,45 @@ components: - id: refresh version: 1 categories: - - name: Thermostat + - name: TempHumiditySensor preferences: - preferenceId: tempOffset explicit: true - preferenceId: humidityOffset explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-battery.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-battery.yml new file mode 100644 index 0000000000..73db53e7c1 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-battery.yml @@ -0,0 +1,21 @@ +name: temperature-soil-sensor-battery +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-batteryLevel.yml new file mode 100644 index 0000000000..55ccc8fb25 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-batteryLevel.yml @@ -0,0 +1,21 @@ +name: temperature-soil-sensor-batteryLevel +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor.yml new file mode 100644 index 0000000000..bc327f01b0 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor.yml @@ -0,0 +1,19 @@ +name: temperature-soil-sensor +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/init.lua new file mode 100644 index 0000000000..4f6d19cc9f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/init.lua @@ -0,0 +1,62 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local AirQualityServerAttributes = require "embedded_clusters.AirQuality.server.attributes" +local AirQualityTypes = require "embedded_clusters.AirQuality.types" + +local AirQuality = {} + +AirQuality.ID = 0x005B +AirQuality.NAME = "AirQuality" +AirQuality.server = {} +AirQuality.client = {} +AirQuality.server.attributes = AirQualityServerAttributes:set_parent_cluster(AirQuality) +AirQuality.types = AirQualityTypes + +function AirQuality:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "AirQuality", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +-- Attribute Mapping +AirQuality.attribute_direction_map = { + ["AirQuality"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +AirQuality.FeatureMap = AirQuality.types.Feature + +function AirQuality.are_features_supported(feature, feature_map) + if (AirQuality.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = AirQuality.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, AirQuality.NAME)) + end + return AirQuality[direction].attributes[key] +end +AirQuality.attributes = {} +setmetatable(AirQuality.attributes, attribute_helper_mt) + +setmetatable(AirQuality, {__index = cluster_base}) + +return AirQuality + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..a1e56c6597 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua new file mode 100644 index 0000000000..1beb6218f9 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AirQuality = { + ID = 0x0000, + NAME = "AirQuality", + base_type = require "embedded_clusters.AirQuality.types.AirQualityEnum", +} + +function AirQuality:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AirQuality:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AirQuality:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AirQuality:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AirQuality:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AirQuality:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AirQuality, {__call = AirQuality.new_value, __index = AirQuality.base_type}) +return AirQuality + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..238b50ade3 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AttributeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AttributeList.element_type) + end +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) +return AttributeList + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/EventList.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/EventList.lua new file mode 100644 index 0000000000..719f17a231 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/EventList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local EventList = { + ID = 0xFFFA, + NAME = "EventList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function EventList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, EventList.element_type) + end +end + +function EventList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function EventList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EventList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EventList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EventList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EventList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(EventList, {__call = EventList.new_value, __index = EventList.base_type}) +return EventList + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/init.lua new file mode 100644 index 0000000000..50295b081a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.AirQuality.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local AirQualityServerAttributes = {} + +function AirQualityServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(AirQualityServerAttributes, attr_mt) + +return AirQualityServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua new file mode 100644 index 0000000000..c2c255614a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local AirQualityEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN] = "UNKNOWN", + [self.GOOD] = "GOOD", + [self.FAIR] = "FAIR", + [self.MODERATE] = "MODERATE", + [self.POOR] = "POOR", + [self.VERY_POOR] = "VERY_POOR", + [self.EXTREMELY_POOR] = "EXTREMELY_POOR", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN = 0x00 +new_mt.__index.GOOD = 0x01 +new_mt.__index.FAIR = 0x02 +new_mt.__index.MODERATE = 0x03 +new_mt.__index.POOR = 0x04 +new_mt.__index.VERY_POOR = 0x05 +new_mt.__index.EXTREMELY_POOR = 0x06 + +AirQualityEnum.UNKNOWN = 0x00 +AirQualityEnum.GOOD = 0x01 +AirQualityEnum.FAIR = 0x02 +AirQualityEnum.MODERATE = 0x03 +AirQualityEnum.POOR = 0x04 +AirQualityEnum.VERY_POOR = 0x05 +AirQualityEnum.EXTREMELY_POOR = 0x06 + +AirQualityEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(AirQualityEnum, new_mt) + +return AirQualityEnum + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/Feature.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/Feature.lua new file mode 100644 index 0000000000..906a09a2bb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/Feature.lua @@ -0,0 +1,123 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.FAIR = 0x0001 +Feature.MODERATE = 0x0002 +Feature.VERY_POOR = 0x0004 +Feature.EXTREMELY_POOR = 0x0008 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + FAIR = 0x0001, + MODERATE = 0x0002, + VERY_POOR = 0x0004, + EXTREMELY_POOR = 0x0008, +} + +Feature.is_fair_set = function(self) + return (self.value & self.FAIR) ~= 0 +end + +Feature.set_fair = function(self) + if self.value ~= nil then + self.value = self.value | self.FAIR + else + self.value = self.FAIR + end +end + +Feature.unset_fair = function(self) + self.value = self.value & (~self.FAIR & self.BASE_MASK) +end + +Feature.is_moderate_set = function(self) + return (self.value & self.MODERATE) ~= 0 +end + +Feature.set_moderate = function(self) + if self.value ~= nil then + self.value = self.value | self.MODERATE + else + self.value = self.MODERATE + end +end + +Feature.unset_moderate = function(self) + self.value = self.value & (~self.MODERATE & self.BASE_MASK) +end + +Feature.is_very_poor_set = function(self) + return (self.value & self.VERY_POOR) ~= 0 +end + +Feature.set_very_poor = function(self) + if self.value ~= nil then + self.value = self.value | self.VERY_POOR + else + self.value = self.VERY_POOR + end +end + +Feature.unset_very_poor = function(self) + self.value = self.value & (~self.VERY_POOR & self.BASE_MASK) +end + +Feature.is_extremely_poor_set = function(self) + return (self.value & self.EXTREMELY_POOR) ~= 0 +end + +Feature.set_extremely_poor = function(self) + if self.value ~= nil then + self.value = self.value | self.EXTREMELY_POOR + else + self.value = self.EXTREMELY_POOR + end +end + +Feature.unset_extremely_poor = function(self) + self.value = self.value & (~self.EXTREMELY_POOR & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.FAIR | + Feature.MODERATE | + Feature.VERY_POOR | + Feature.EXTREMELY_POOR + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_fair_set = Feature.is_fair_set, + set_fair = Feature.set_fair, + unset_fair = Feature.unset_fair, + is_moderate_set = Feature.is_moderate_set, + set_moderate = Feature.set_moderate, + unset_moderate = Feature.unset_moderate, + is_very_poor_set = Feature.is_very_poor_set, + set_very_poor = Feature.set_very_poor, + unset_very_poor = Feature.unset_very_poor, + is_extremely_poor_set = Feature.is_extremely_poor_set, + set_extremely_poor = Feature.set_extremely_poor, + unset_extremely_poor = Feature.unset_extremely_poor, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/init.lua new file mode 100644 index 0000000000..b77d67de82 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/AirQuality/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.AirQuality.types." .. key) + end + return types_mt.__types_cache[key] +end + +local AirQualityTypes = {} + +setmetatable(AirQualityTypes, types_mt) + +return AirQualityTypes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/init.lua new file mode 100644 index 0000000000..c8e754419b --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/init.lua @@ -0,0 +1,81 @@ +local cluster_base = require "st.matter.cluster_base" +local BooleanStateConfigurationServerAttributes = require "embedded_clusters.BooleanStateConfiguration.server.attributes" +local BooleanStateConfigurationTypes = require "embedded_clusters.BooleanStateConfiguration.types" + +local BooleanStateConfiguration = {} + +BooleanStateConfiguration.ID = 0x0080 +BooleanStateConfiguration.NAME = "BooleanStateConfiguration" +BooleanStateConfiguration.server = {} +BooleanStateConfiguration.client = {} +BooleanStateConfiguration.server.attributes = BooleanStateConfigurationServerAttributes:set_parent_cluster(BooleanStateConfiguration) +BooleanStateConfiguration.types = BooleanStateConfigurationTypes + +function BooleanStateConfiguration:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "CurrentSensitivityLevel", + [0x0001] = "SupportedSensitivityLevels", + [0x0002] = "DefaultSensitivityLevel", + [0x0003] = "AlarmsActive", + [0x0004] = "AlarmsSuppressed", + [0x0005] = "AlarmsEnabled", + [0x0006] = "AlarmsSupported", + [0x0007] = "SensorFault", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +BooleanStateConfiguration.attribute_direction_map = { + ["CurrentSensitivityLevel"] = "server", + ["SupportedSensitivityLevels"] = "server", + ["DefaultSensitivityLevel"] = "server", + ["AlarmsActive"] = "server", + ["AlarmsSuppressed"] = "server", + ["AlarmsEnabled"] = "server", + ["AlarmsSupported"] = "server", + ["SensorFault"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +do + local has_aliases, aliases = pcall(require, "BooleanStateConfiguration.server.attributes") + if has_aliases then + for alias, _ in pairs(aliases) do + BooleanStateConfiguration.attribute_direction_map[alias] = "server" + end + end +end + +BooleanStateConfiguration.FeatureMap = BooleanStateConfiguration.types.Feature + +function BooleanStateConfiguration.are_features_supported(feature, feature_map) + if (BooleanStateConfiguration.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = BooleanStateConfiguration.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, BooleanStateConfiguration.NAME)) + end + return BooleanStateConfiguration[direction].attributes[key] +end +BooleanStateConfiguration.attributes = {} +setmetatable(BooleanStateConfiguration.attributes, attribute_helper_mt) + +setmetatable(BooleanStateConfiguration, {__index = cluster_base}) + +return BooleanStateConfiguration + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/CurrentSensitivityLevel.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/CurrentSensitivityLevel.lua new file mode 100644 index 0000000000..b2c9b67fbb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/CurrentSensitivityLevel.lua @@ -0,0 +1,83 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentSensitivityLevel = { + ID = 0x0000, + NAME = "CurrentSensitivityLevel", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentSensitivityLevel:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentSensitivityLevel:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CurrentSensitivityLevel:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, --event_id + data + ) +end + +function CurrentSensitivityLevel:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function CurrentSensitivityLevel:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentSensitivityLevel:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentSensitivityLevel:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentSensitivityLevel, {__call = CurrentSensitivityLevel.new_value, __index = CurrentSensitivityLevel.base_type}) +return CurrentSensitivityLevel diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/DefaultSensitivityLevel.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/DefaultSensitivityLevel.lua new file mode 100644 index 0000000000..760d7f397c --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/DefaultSensitivityLevel.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local DefaultSensitivityLevel = { + ID = 0x0002, + NAME = "DefaultSensitivityLevel", + base_type = require "st.matter.data_types.Uint8", +} + +function DefaultSensitivityLevel:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function DefaultSensitivityLevel:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function DefaultSensitivityLevel:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function DefaultSensitivityLevel:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function DefaultSensitivityLevel:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function DefaultSensitivityLevel:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(DefaultSensitivityLevel, {__call = DefaultSensitivityLevel.new_value, __index = DefaultSensitivityLevel.base_type}) +return DefaultSensitivityLevel diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SensorFault.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SensorFault.lua new file mode 100644 index 0000000000..3786904abf --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SensorFault.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SensorFault = { + ID = 0x0007, + NAME = "SensorFault", + base_type = require "embedded_clusters.BooleanStateConfiguration.types.SensorFaultBitmap", +} + +function SensorFault:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SensorFault:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SensorFault:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SensorFault:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SensorFault:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SensorFault:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SensorFault, {__call = SensorFault.new_value, __index = SensorFault.base_type}) +return SensorFault \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SupportedSensitivityLevels.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SupportedSensitivityLevels.lua new file mode 100644 index 0000000000..12bff3e4a9 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/SupportedSensitivityLevels.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedSensitivityLevels = { + ID = 0x0001, + NAME = "SupportedSensitivityLevels", + base_type = require "st.matter.data_types.Uint8", +} + +function SupportedSensitivityLevels:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SupportedSensitivityLevels:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SupportedSensitivityLevels:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function SupportedSensitivityLevels:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedSensitivityLevels:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedSensitivityLevels:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SupportedSensitivityLevels, {__call = SupportedSensitivityLevels.new_value, __index = SupportedSensitivityLevels.base_type}) +return SupportedSensitivityLevels diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/init.lua new file mode 100644 index 0000000000..daca1b7707 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/server/attributes/init.lua @@ -0,0 +1,28 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.BooleanStateConfiguration.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local BooleanStateConfigurationServerAttributes = {} + +function BooleanStateConfigurationServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(BooleanStateConfigurationServerAttributes, attr_mt) + +return BooleanStateConfigurationServerAttributes + + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/Feature.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/Feature.lua new file mode 100644 index 0000000000..dbc08cc0ed --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/Feature.lua @@ -0,0 +1,122 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.VISUAL = 0x0001 +Feature.AUDIBLE = 0x0002 +Feature.ALARM_SUPPRESS = 0x0004 +Feature.SENSITIVITY_LEVEL = 0x0008 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + VISUAL = 0x0001, + AUDIBLE = 0x0002, + ALARM_SUPPRESS = 0x0004, + SENSITIVITY_LEVEL = 0x0008, +} + +Feature.is_visual_set = function(self) + return (self.value & self.VISUAL) ~= 0 +end + +Feature.set_visual = function(self) + if self.value ~= nil then + self.value = self.value | self.VISUAL + else + self.value = self.VISUAL + end +end + +Feature.unset_visual = function(self) + self.value = self.value & (~self.VISUAL & self.BASE_MASK) +end +Feature.is_audible_set = function(self) + return (self.value & self.AUDIBLE) ~= 0 +end + +Feature.set_audible = function(self) + if self.value ~= nil then + self.value = self.value | self.AUDIBLE + else + self.value = self.AUDIBLE + end +end + +Feature.unset_audible = function(self) + self.value = self.value & (~self.AUDIBLE & self.BASE_MASK) +end + +Feature.is_alarm_suppress_set = function(self) + return (self.value & self.ALARM_SUPPRESS) ~= 0 +end + +Feature.set_alarm_suppress = function(self) + if self.value ~= nil then + self.value = self.value | self.ALARM_SUPPRESS + else + self.value = self.ALARM_SUPPRESS + end +end + +Feature.unset_alarm_suppress = function(self) + self.value = self.value & (~self.ALARM_SUPPRESS & self.BASE_MASK) +end + +Feature.is_sensitivity_level_set = function(self) + return (self.value & self.SENSITIVITY_LEVEL) ~= 0 +end + +Feature.set_sensitivity_level = function(self) + if self.value ~= nil then + self.value = self.value | self.SENSITIVITY_LEVEL + else + self.value = self.SENSITIVITY_LEVEL + end +end + +Feature.unset_sensitivity_level = function(self) + self.value = self.value & (~self.SENSITIVITY_LEVEL & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.VISUAL | + Feature.AUDIBLE | + Feature.ALARM_SUPPRESS | + Feature.SENSITIVITY_LEVEL + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_visual_set = Feature.is_visual_set, + set_visual = Feature.set_visual, + unset_visual = Feature.unset_visual, + is_audible_set = Feature.is_audible_set, + set_audible = Feature.set_audible, + unset_audible = Feature.unset_audible, + is_alarm_suppress_set = Feature.is_alarm_suppress_set, + set_alarm_suppress = Feature.set_alarm_suppress, + unset_alarm_suppress = Feature.unset_alarm_suppress, + is_sensitivity_level_set = Feature.is_sensitivity_level_set, + set_sensitivity_level = Feature.set_sensitivity_level, + unset_sensitivity_level = Feature.unset_sensitivity_level, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/SensorFaultBitmap.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/SensorFaultBitmap.lua new file mode 100644 index 0000000000..9661734152 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/SensorFaultBitmap.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local SensorFaultBitmap = {} +local new_mt = UintABC.new_mt({NAME = "SensorFaultBitmap", ID = data_types.name_to_id_map["Uint16"]}, 2) + +SensorFaultBitmap.BASE_MASK = 0xFFFF +SensorFaultBitmap.GENERAL_FAULT = 0x0001 + +SensorFaultBitmap.mask_fields = { + BASE_MASK = 0xFFFF, + GENERAL_FAULT = 0x0001, +} + +SensorFaultBitmap.is_general_fault_set = function(self) + return (self.value & self.GENERAL_FAULT) ~= 0 +end + +SensorFaultBitmap.set_general_fault = function(self) + if self.value ~= nil then + self.value = self.value | self.GENERAL_FAULT + else + self.value = self.GENERAL_FAULT + end +end + +SensorFaultBitmap.unset_general_fault = function(self) + self.value = self.value & (~self.GENERAL_FAULT & self.BASE_MASK) +end + + +SensorFaultBitmap.mask_methods = { + is_general_fault_set = SensorFaultBitmap.is_general_fault_set, + set_general_fault = SensorFaultBitmap.set_general_fault, + unset_general_fault = SensorFaultBitmap.unset_general_fault, +} + +SensorFaultBitmap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(SensorFaultBitmap, new_mt) + +return SensorFaultBitmap diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/init.lua new file mode 100644 index 0000000000..bc6835b4ac --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/BooleanStateConfiguration/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.BooleanStateConfiguration.types." .. key) + end + return types_mt.__types_cache[key] +end + +local BooleanStateConfigurationTypes = {} + +setmetatable(BooleanStateConfigurationTypes, types_mt) + +return BooleanStateConfigurationTypes diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..4de97147e4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local CarbonDioxideConcentrationMeasurementServerAttributes = require "embedded_clusters.CarbonDioxideConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local CarbonDioxideConcentrationMeasurement = {} + +CarbonDioxideConcentrationMeasurement.ID = 0x040D +CarbonDioxideConcentrationMeasurement.NAME = "CarbonDioxideConcentrationMeasurement" +CarbonDioxideConcentrationMeasurement.server = {} +CarbonDioxideConcentrationMeasurement.client = {} +CarbonDioxideConcentrationMeasurement.server.attributes = CarbonDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(CarbonDioxideConcentrationMeasurement) +CarbonDioxideConcentrationMeasurement.types = ConcentrationMeasurement.types + +function CarbonDioxideConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function CarbonDioxideConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +CarbonDioxideConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +CarbonDioxideConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function CarbonDioxideConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = CarbonDioxideConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, CarbonDioxideConcentrationMeasurement.NAME)) + end + return CarbonDioxideConcentrationMeasurement[direction].attributes[key] +end +CarbonDioxideConcentrationMeasurement.attributes = {} +setmetatable(CarbonDioxideConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(CarbonDioxideConcentrationMeasurement, {__index = cluster_base}) + +return CarbonDioxideConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..0206213e6f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.CarbonDioxideConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local CarbonDioxideConcentrationMeasurementServerAttributes = {} + +function CarbonDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(CarbonDioxideConcentrationMeasurementServerAttributes, attr_mt) + +return CarbonDioxideConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..a6e1f24d1d --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local CarbonMonoxideConcentrationMeasurementServerAttributes = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local CarbonMonoxideConcentrationMeasurement = {} + +CarbonMonoxideConcentrationMeasurement.ID = 0x040C +CarbonMonoxideConcentrationMeasurement.NAME = "CarbonMonoxideConcentrationMeasurement" +CarbonMonoxideConcentrationMeasurement.server = {} +CarbonMonoxideConcentrationMeasurement.client = {} +CarbonMonoxideConcentrationMeasurement.server.attributes = CarbonMonoxideConcentrationMeasurementServerAttributes:set_parent_cluster(CarbonMonoxideConcentrationMeasurement) +CarbonMonoxideConcentrationMeasurement.types = ConcentrationMeasurement.types + +function CarbonMonoxideConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function CarbonMonoxideConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +CarbonMonoxideConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +CarbonMonoxideConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function CarbonMonoxideConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = CarbonMonoxideConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, CarbonMonoxideConcentrationMeasurement.NAME)) + end + return CarbonMonoxideConcentrationMeasurement[direction].attributes[key] +end +CarbonMonoxideConcentrationMeasurement.attributes = {} +setmetatable(CarbonMonoxideConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(CarbonMonoxideConcentrationMeasurement, {__index = cluster_base}) + +return CarbonMonoxideConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..1a7e7b508c --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local CarbonMonoxideConcentrationMeasurementServerAttributes = {} + +function CarbonMonoxideConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(CarbonMonoxideConcentrationMeasurementServerAttributes, attr_mt) + +return CarbonMonoxideConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..cb4cffa2d2 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/init.lua @@ -0,0 +1,111 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ConcentrationMeasurementServerAttributes = require "embedded_clusters.ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurementTypes = require "embedded_clusters.ConcentrationMeasurement.types" + +local ConcentrationMeasurement = {} + +ConcentrationMeasurement.ID = 0x040C +ConcentrationMeasurement.NAME = "CarbonMonoxideConcentrationMeasurement" +ConcentrationMeasurement.server = {} +ConcentrationMeasurement.client = {} +ConcentrationMeasurement.server.attributes = ConcentrationMeasurementServerAttributes:set_parent_cluster(ConcentrationMeasurement) +ConcentrationMeasurement.types = ConcentrationMeasurementTypes + +function ConcentrationMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "MeasuredValue", + [0x0001] = "MinMeasuredValue", + [0x0002] = "MaxMeasuredValue", + [0x0003] = "PeakMeasuredValue", + [0x0004] = "PeakMeasuredValueWindow", + [0x0005] = "AverageMeasuredValue", + [0x0006] = "AverageMeasuredValueWindow", + [0x0007] = "Uncertainty", + [0x0008] = "MeasurementUnit", + [0x0009] = "MeasurementMedium", + [0x000A] = "LevelValue", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ConcentrationMeasurement:get_server_command_by_id(command_id) + local server_id_map = { + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ConcentrationMeasurement.attribute_direction_map = { + ["MeasuredValue"] = "server", + ["MinMeasuredValue"] = "server", + ["MaxMeasuredValue"] = "server", + ["PeakMeasuredValue"] = "server", + ["PeakMeasuredValueWindow"] = "server", + ["AverageMeasuredValue"] = "server", + ["AverageMeasuredValueWindow"] = "server", + ["Uncertainty"] = "server", + ["MeasurementUnit"] = "server", + ["MeasurementMedium"] = "server", + ["LevelValue"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +ConcentrationMeasurement.command_direction_map = { +} + +ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function ConcentrationMeasurement.are_features_supported(feature, feature_map) + if (ConcentrationMeasurement.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ConcentrationMeasurement.NAME)) + end + return ConcentrationMeasurement[direction].attributes[key] +end +ConcentrationMeasurement.attributes = {} +setmetatable(ConcentrationMeasurement.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ConcentrationMeasurement.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ConcentrationMeasurement.NAME)) + end + return ConcentrationMeasurement[direction].commands[key] +end +ConcentrationMeasurement.commands = {} +setmetatable(ConcentrationMeasurement.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return ConcentrationMeasurement.server.events[key] +end +ConcentrationMeasurement.events = {} +setmetatable(ConcentrationMeasurement.events, event_helper_mt) + +setmetatable(ConcentrationMeasurement, {__index = cluster_base}) + +return ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..e4f88c8491 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,73 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function LevelValue:read(device, endpoint_id, cluster_id) + return cluster_base.read( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + + +function LevelValue:subscribe(device, endpoint_id, cluster_id) + return cluster_base.subscribe( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status, + cluster_id +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + cluster_id, + self.ID, + data, + status + ) +end + +function LevelValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..2ab739841f --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,73 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function MeasuredValue:read(device, endpoint_id, cluster_id) + return cluster_base.read( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + + +function MeasuredValue:subscribe(device, endpoint_id, cluster_id) + return cluster_base.subscribe( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status, + cluster_id +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + cluster_id, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..0fa14745c0 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function MeasurementUnit:read(device, endpoint_id, cluster_id) + return cluster_base.read( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function MeasurementUnit:subscribe(device, endpoint_id, cluster_id) + return cluster_base.subscribe( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status, + cluster_id +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + cluster_id, + self.ID, + data, + status + ) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..19cde9aa55 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ConcentrationMeasurementServerAttributes = {} + +function ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ConcentrationMeasurementServerAttributes, attr_mt) + +return ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua new file mode 100644 index 0000000000..0bb19bec62 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua @@ -0,0 +1,167 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.NUMERIC_MEASUREMENT = 0x0001 +Feature.LEVEL_INDICATION = 0x0002 +Feature.MEDIUM_LEVEL = 0x0004 +Feature.CRITICAL_LEVEL = 0x0008 +Feature.PEAK_MEASUREMENT = 0x0010 +Feature.AVERAGE_MEASUREMENT = 0x0020 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + NUMERIC_MEASUREMENT = 0x0001, + LEVEL_INDICATION = 0x0002, + MEDIUM_LEVEL = 0x0004, + CRITICAL_LEVEL = 0x0008, + PEAK_MEASUREMENT = 0x0010, + AVERAGE_MEASUREMENT = 0x0020, +} + +Feature.is_numeric_measurement_set = function(self) + return (self.value & self.NUMERIC_MEASUREMENT) ~= 0 +end + +Feature.set_numeric_measurement = function(self) + if self.value ~= nil then + self.value = self.value | self.NUMERIC_MEASUREMENT + else + self.value = self.NUMERIC_MEASUREMENT + end +end + +Feature.unset_numeric_measurement = function(self) + self.value = self.value & (~self.NUMERIC_MEASUREMENT & self.BASE_MASK) +end + +Feature.is_level_indication_set = function(self) + return (self.value & self.LEVEL_INDICATION) ~= 0 +end + +Feature.set_level_indication = function(self) + if self.value ~= nil then + self.value = self.value | self.LEVEL_INDICATION + else + self.value = self.LEVEL_INDICATION + end +end + +Feature.unset_level_indication = function(self) + self.value = self.value & (~self.LEVEL_INDICATION & self.BASE_MASK) +end + +Feature.is_medium_level_set = function(self) + return (self.value & self.MEDIUM_LEVEL) ~= 0 +end + +Feature.set_medium_level = function(self) + if self.value ~= nil then + self.value = self.value | self.MEDIUM_LEVEL + else + self.value = self.MEDIUM_LEVEL + end +end + +Feature.unset_medium_level = function(self) + self.value = self.value & (~self.MEDIUM_LEVEL & self.BASE_MASK) +end + +Feature.is_critical_level_set = function(self) + return (self.value & self.CRITICAL_LEVEL) ~= 0 +end + +Feature.set_critical_level = function(self) + if self.value ~= nil then + self.value = self.value | self.CRITICAL_LEVEL + else + self.value = self.CRITICAL_LEVEL + end +end + +Feature.unset_critical_level = function(self) + self.value = self.value & (~self.CRITICAL_LEVEL & self.BASE_MASK) +end + +Feature.is_peak_measurement_set = function(self) + return (self.value & self.PEAK_MEASUREMENT) ~= 0 +end + +Feature.set_peak_measurement = function(self) + if self.value ~= nil then + self.value = self.value | self.PEAK_MEASUREMENT + else + self.value = self.PEAK_MEASUREMENT + end +end + +Feature.unset_peak_measurement = function(self) + self.value = self.value & (~self.PEAK_MEASUREMENT & self.BASE_MASK) +end + +Feature.is_average_measurement_set = function(self) + return (self.value & self.AVERAGE_MEASUREMENT) ~= 0 +end + +Feature.set_average_measurement = function(self) + if self.value ~= nil then + self.value = self.value | self.AVERAGE_MEASUREMENT + else + self.value = self.AVERAGE_MEASUREMENT + end +end + +Feature.unset_average_measurement = function(self) + self.value = self.value & (~self.AVERAGE_MEASUREMENT & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.NUMERIC_MEASUREMENT | + Feature.LEVEL_INDICATION | + Feature.MEDIUM_LEVEL | + Feature.CRITICAL_LEVEL | + Feature.PEAK_MEASUREMENT | + Feature.AVERAGE_MEASUREMENT + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_numeric_measurement_set = Feature.is_numeric_measurement_set, + set_numeric_measurement = Feature.set_numeric_measurement, + unset_numeric_measurement = Feature.unset_numeric_measurement, + is_level_indication_set = Feature.is_level_indication_set, + set_level_indication = Feature.set_level_indication, + unset_level_indication = Feature.unset_level_indication, + is_medium_level_set = Feature.is_medium_level_set, + set_medium_level = Feature.set_medium_level, + unset_medium_level = Feature.unset_medium_level, + is_critical_level_set = Feature.is_critical_level_set, + set_critical_level = Feature.set_critical_level, + unset_critical_level = Feature.unset_critical_level, + is_peak_measurement_set = Feature.is_peak_measurement_set, + set_peak_measurement = Feature.set_peak_measurement, + unset_peak_measurement = Feature.unset_peak_measurement, + is_average_measurement_set = Feature.is_average_measurement_set, + set_average_measurement = Feature.set_average_measurement, + unset_average_measurement = Feature.unset_average_measurement, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua new file mode 100644 index 0000000000..02b4f727df --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua @@ -0,0 +1,42 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LevelValueEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN] = "UNKNOWN", + [self.LOW] = "LOW", + [self.MEDIUM] = "MEDIUM", + [self.HIGH] = "HIGH", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN = 0x00 +new_mt.__index.LOW = 0x01 +new_mt.__index.MEDIUM = 0x02 +new_mt.__index.HIGH = 0x03 +new_mt.__index.CRITICAL = 0x04 + +LevelValueEnum.UNKNOWN = 0x00 +LevelValueEnum.LOW = 0x01 +LevelValueEnum.MEDIUM = 0x02 +LevelValueEnum.HIGH = 0x03 +LevelValueEnum.CRITICAL = 0x04 + +LevelValueEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LevelValueEnum, new_mt) + +return LevelValueEnum + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua new file mode 100644 index 0000000000..6efd90901a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua @@ -0,0 +1,51 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local MeasurementUnitEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.PPM] = "PPM", + [self.PPB] = "PPB", + [self.PPT] = "PPT", + [self.MGM3] = "MGM3", + [self.UGM3] = "UGM3", + [self.NGM3] = "NGM3", + [self.PM3] = "PM3", + [self.BQM3] = "BQM3", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.PPM = 0x00 +new_mt.__index.PPB = 0x01 +new_mt.__index.PPT = 0x02 +new_mt.__index.MGM3 = 0x03 +new_mt.__index.UGM3 = 0x04 +new_mt.__index.NGM3 = 0x05 +new_mt.__index.PM3 = 0x06 +new_mt.__index.BQM3 = 0x07 + +MeasurementUnitEnum.PPM = 0x00 +MeasurementUnitEnum.PPB = 0x01 +MeasurementUnitEnum.PPT = 0x02 +MeasurementUnitEnum.MGM3 = 0x03 +MeasurementUnitEnum.UGM3 = 0x04 +MeasurementUnitEnum.NGM3 = 0x05 +MeasurementUnitEnum.PM3 = 0x06 +MeasurementUnitEnum.BQM3 = 0x07 + +MeasurementUnitEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(MeasurementUnitEnum, new_mt) + +return MeasurementUnitEnum + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/init.lua new file mode 100644 index 0000000000..c339f17414 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/ConcentrationMeasurement/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.ConcentrationMeasurement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ConcentrationMeasurementTypes = {} + +setmetatable(ConcentrationMeasurementTypes, types_mt) + +return ConcentrationMeasurementTypes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..cdfd1d597e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local FormaldehydeConcentrationMeasurementServerAttributes = require "embedded_clusters.FormaldehydeConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local FormaldehydeConcentrationMeasurement = {} + +FormaldehydeConcentrationMeasurement.ID = 0x042B +FormaldehydeConcentrationMeasurement.NAME = "FormaldehydeConcentrationMeasurement" +FormaldehydeConcentrationMeasurement.server = {} +FormaldehydeConcentrationMeasurement.client = {} +FormaldehydeConcentrationMeasurement.server.attributes = FormaldehydeConcentrationMeasurementServerAttributes:set_parent_cluster(FormaldehydeConcentrationMeasurement) +FormaldehydeConcentrationMeasurement.types = ConcentrationMeasurement.types + +function FormaldehydeConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function FormaldehydeConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +FormaldehydeConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +FormaldehydeConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function FormaldehydeConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = FormaldehydeConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, FormaldehydeConcentrationMeasurement.NAME)) + end + return FormaldehydeConcentrationMeasurement[direction].attributes[key] +end +FormaldehydeConcentrationMeasurement.attributes = {} +setmetatable(FormaldehydeConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(FormaldehydeConcentrationMeasurement, {__index = cluster_base}) + +return FormaldehydeConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..3dee13abe3 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.FormaldehydeConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local FormaldehydeConcentrationMeasurementServerAttributes = {} + +function FormaldehydeConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(FormaldehydeConcentrationMeasurementServerAttributes, attr_mt) + +return FormaldehydeConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/init.lua new file mode 100644 index 0000000000..98c23abef4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/init.lua @@ -0,0 +1,5 @@ +local GlobalTypes = require "embedded_clusters.Global.types" + +local Global = {} +Global.types = GlobalTypes +return Global diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/LevelValueEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/LevelValueEnum.lua new file mode 100644 index 0000000000..ed8b969b49 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/LevelValueEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LevelValueEnum = {} +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN] = "UNKNOWN", + [self.LOW] = "LOW", + [self.MEDIUM] = "MEDIUM", + [self.HIGH] = "HIGH", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN = 0x00 +new_mt.__index.LOW = 0x01 +new_mt.__index.MEDIUM = 0x02 +new_mt.__index.HIGH = 0x03 +new_mt.__index.CRITICAL = 0x04 + +LevelValueEnum.UNKNOWN = 0x00 +LevelValueEnum.LOW = 0x01 +LevelValueEnum.MEDIUM = 0x02 +LevelValueEnum.HIGH = 0x03 +LevelValueEnum.CRITICAL = 0x04 + +LevelValueEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LevelValueEnum, new_mt) + +return LevelValueEnum diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyRangeStruct.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyRangeStruct.lua new file mode 100644 index 0000000000..b298a66961 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyRangeStruct.lua @@ -0,0 +1,119 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local MeasurementAccuracyRangeStruct = {} +local new_mt = StructureABC.new_mt({NAME = "MeasurementAccuracyRangeStruct", ID = data_types.name_to_id_map["Structure"]}) + +MeasurementAccuracyRangeStruct.field_defs = { + { + name = "range_min", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "range_max", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "percent_max", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "percent_min", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "percent_typical", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "fixed_max", + field_id = 5, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "fixed_min", + field_id = 6, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "fixed_typical", + field_id = 7, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, +} + +MeasurementAccuracyRangeStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +MeasurementAccuracyRangeStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = MeasurementAccuracyRangeStruct.init +new_mt.__index.serialize = MeasurementAccuracyRangeStruct.serialize + +MeasurementAccuracyRangeStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(MeasurementAccuracyRangeStruct, new_mt) + +return MeasurementAccuracyRangeStruct diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyStruct.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyStruct.lua new file mode 100644 index 0000000000..4da8857164 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyStruct.lua @@ -0,0 +1,99 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local MeasurementAccuracyStruct = {} +local new_mt = StructureABC.new_mt({NAME = "MeasurementAccuracyStruct", ID = data_types.name_to_id_map["Structure"]}) + +MeasurementAccuracyStruct.field_defs = { + { + name = "measurement_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "embedded_clusters.Global.types.MeasurementTypeEnum", + }, + { + name = "measured", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "min_measured_value", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "max_measured_value", + field_id = 3, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "accuracy_ranges", + field_id = 4, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.Global.types.MeasurementAccuracyRangeStruct", + }, +} + +MeasurementAccuracyStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +MeasurementAccuracyStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = MeasurementAccuracyStruct.init +new_mt.__index.serialize = MeasurementAccuracyStruct.serialize + +MeasurementAccuracyStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(MeasurementAccuracyStruct, new_mt) + +return MeasurementAccuracyStruct diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua new file mode 100644 index 0000000000..334ad78331 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua @@ -0,0 +1,75 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local MeasurementTypeEnum = {} +local new_mt = UintABC.new_mt({NAME = "MeasurementTypeEnum", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNSPECIFIED] = "UNSPECIFIED", + [self.VOLTAGE] = "VOLTAGE", + [self.ACTIVE_CURRENT] = "ACTIVE_CURRENT", + [self.REACTIVE_CURRENT] = "REACTIVE_CURRENT", + [self.APPARENT_CURRENT] = "APPARENT_CURRENT", + [self.ACTIVE_POWER] = "ACTIVE_POWER", + [self.REACTIVE_POWER] = "REACTIVE_POWER", + [self.APPARENT_POWER] = "APPARENT_POWER", + [self.RMS_VOLTAGE] = "RMS_VOLTAGE", + [self.RMS_CURRENT] = "RMS_CURRENT", + [self.RMS_POWER] = "RMS_POWER", + [self.FREQUENCY] = "FREQUENCY", + [self.POWER_FACTOR] = "POWER_FACTOR", + [self.NEUTRAL_CURRENT] = "NEUTRAL_CURRENT", + [self.ELECTRICAL_ENERGY] = "ELECTRICAL_ENERGY", + [self.REACTIVE_ENERGY] = "REACTIVE_ENERGY", + [self.APPARENT_ENERGY] = "APPARENT_ENERGY", + [self.SOIL_MOISTURE] = "SOIL_MOISTURE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNSPECIFIED = 0x00 +new_mt.__index.VOLTAGE = 0x01 +new_mt.__index.ACTIVE_CURRENT = 0x02 +new_mt.__index.REACTIVE_CURRENT = 0x03 +new_mt.__index.APPARENT_CURRENT = 0x04 +new_mt.__index.ACTIVE_POWER = 0x05 +new_mt.__index.REACTIVE_POWER = 0x06 +new_mt.__index.APPARENT_POWER = 0x07 +new_mt.__index.RMS_VOLTAGE = 0x08 +new_mt.__index.RMS_CURRENT = 0x09 +new_mt.__index.RMS_POWER = 0x0A +new_mt.__index.FREQUENCY = 0x0B +new_mt.__index.POWER_FACTOR = 0x0C +new_mt.__index.NEUTRAL_CURRENT = 0x0D +new_mt.__index.ELECTRICAL_ENERGY = 0x0E +new_mt.__index.REACTIVE_ENERGY = 0x0F +new_mt.__index.APPARENT_ENERGY = 0x10 +new_mt.__index.SOIL_MOISTURE = 0x11 + +MeasurementTypeEnum.UNSPECIFIED = 0x00 +MeasurementTypeEnum.VOLTAGE = 0x01 +MeasurementTypeEnum.ACTIVE_CURRENT = 0x02 +MeasurementTypeEnum.REACTIVE_CURRENT = 0x03 +MeasurementTypeEnum.APPARENT_CURRENT = 0x04 +MeasurementTypeEnum.ACTIVE_POWER = 0x05 +MeasurementTypeEnum.REACTIVE_POWER = 0x06 +MeasurementTypeEnum.APPARENT_POWER = 0x07 +MeasurementTypeEnum.RMS_VOLTAGE = 0x08 +MeasurementTypeEnum.RMS_CURRENT = 0x09 +MeasurementTypeEnum.RMS_POWER = 0x0A +MeasurementTypeEnum.FREQUENCY = 0x0B +MeasurementTypeEnum.POWER_FACTOR = 0x0C +MeasurementTypeEnum.NEUTRAL_CURRENT = 0x0D +MeasurementTypeEnum.ELECTRICAL_ENERGY = 0x0E +MeasurementTypeEnum.REACTIVE_ENERGY = 0x0F +MeasurementTypeEnum.APPARENT_ENERGY = 0x10 +MeasurementTypeEnum.SOIL_MOISTURE = 0x11 + +MeasurementTypeEnum.augment_type = function(_cls, val) + setmetatable(val, new_mt) +end + +setmetatable(MeasurementTypeEnum, new_mt) + +return MeasurementTypeEnum diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/init.lua new file mode 100644 index 0000000000..984c5e02d8 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.Global.types." .. key) + end + return types_mt.__types_cache[key] +end + +local GlobalTypes = {} + +setmetatable(GlobalTypes, types_mt) + +return GlobalTypes diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..eae9be65f0 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local NitrogenDioxideConcentrationMeasurementServerAttributes = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local NitrogenDioxideConcentrationMeasurement = {} + +NitrogenDioxideConcentrationMeasurement.ID = 0x0413 +NitrogenDioxideConcentrationMeasurement.NAME = "NitrogenDioxideConcentrationMeasurement" +NitrogenDioxideConcentrationMeasurement.server = {} +NitrogenDioxideConcentrationMeasurement.client = {} +NitrogenDioxideConcentrationMeasurement.server.attributes = NitrogenDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(NitrogenDioxideConcentrationMeasurement) +NitrogenDioxideConcentrationMeasurement.types = ConcentrationMeasurement.types + +function NitrogenDioxideConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function NitrogenDioxideConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +NitrogenDioxideConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +NitrogenDioxideConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function NitrogenDioxideConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = NitrogenDioxideConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, NitrogenDioxideConcentrationMeasurement.NAME)) + end + return NitrogenDioxideConcentrationMeasurement[direction].attributes[key] +end +NitrogenDioxideConcentrationMeasurement.attributes = {} +setmetatable(NitrogenDioxideConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(NitrogenDioxideConcentrationMeasurement, {__index = cluster_base}) + +return NitrogenDioxideConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..c82517d362 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.NitrogenDioxideConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local NitrogenDioxideConcentrationMeasurementServerAttributes = {} + +function NitrogenDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(NitrogenDioxideConcentrationMeasurementServerAttributes, attr_mt) + +return NitrogenDioxideConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..c49ea94b39 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local OzoneConcentrationMeasurementServerAttributes = require "embedded_clusters.OzoneConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local OzoneConcentrationMeasurement = {} + +OzoneConcentrationMeasurement.ID = 0x0415 +OzoneConcentrationMeasurement.NAME = "OzoneConcentrationMeasurement" +OzoneConcentrationMeasurement.server = {} +OzoneConcentrationMeasurement.client = {} +OzoneConcentrationMeasurement.server.attributes = OzoneConcentrationMeasurementServerAttributes:set_parent_cluster(OzoneConcentrationMeasurement) +OzoneConcentrationMeasurement.types = ConcentrationMeasurement.types + +function OzoneConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function OzoneConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +OzoneConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +OzoneConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function OzoneConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = OzoneConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, OzoneConcentrationMeasurement.NAME)) + end + return OzoneConcentrationMeasurement[direction].attributes[key] +end +OzoneConcentrationMeasurement.attributes = {} +setmetatable(OzoneConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(OzoneConcentrationMeasurement, {__index = cluster_base}) + +return OzoneConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..918b680495 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.OzoneConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local OzoneConcentrationMeasurementServerAttributes = {} + +function OzoneConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(OzoneConcentrationMeasurementServerAttributes, attr_mt) + +return OzoneConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..3b333b5417 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local Pm10ConcentrationMeasurementServerAttributes = require "embedded_clusters.Pm10ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local Pm10ConcentrationMeasurement = {} + +Pm10ConcentrationMeasurement.ID = 0x042D +Pm10ConcentrationMeasurement.NAME = "Pm10ConcentrationMeasurement" +Pm10ConcentrationMeasurement.server = {} +Pm10ConcentrationMeasurement.client = {} +Pm10ConcentrationMeasurement.server.attributes = Pm10ConcentrationMeasurementServerAttributes:set_parent_cluster(Pm10ConcentrationMeasurement) +Pm10ConcentrationMeasurement.types = ConcentrationMeasurement.types + +function Pm10ConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function Pm10ConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +Pm10ConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +Pm10ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function Pm10ConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Pm10ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Pm10ConcentrationMeasurement.NAME)) + end + return Pm10ConcentrationMeasurement[direction].attributes[key] +end +Pm10ConcentrationMeasurement.attributes = {} +setmetatable(Pm10ConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(Pm10ConcentrationMeasurement, {__index = cluster_base}) + +return Pm10ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..3b1e6617a4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Pm10ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local Pm10ConcentrationMeasurementServerAttributes = {} + +function Pm10ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(Pm10ConcentrationMeasurementServerAttributes, attr_mt) + +return Pm10ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..b2e6656a9a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local Pm1ConcentrationMeasurementServerAttributes = require "embedded_clusters.Pm1ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local Pm1ConcentrationMeasurement = {} + +Pm1ConcentrationMeasurement.ID = 0x042C +Pm1ConcentrationMeasurement.NAME = "Pm1ConcentrationMeasurement" +Pm1ConcentrationMeasurement.server = {} +Pm1ConcentrationMeasurement.client = {} +Pm1ConcentrationMeasurement.server.attributes = Pm1ConcentrationMeasurementServerAttributes:set_parent_cluster(Pm1ConcentrationMeasurement) +Pm1ConcentrationMeasurement.types = ConcentrationMeasurement.types + +function Pm1ConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function Pm1ConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +Pm1ConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +Pm1ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function Pm1ConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Pm1ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Pm1ConcentrationMeasurement.NAME)) + end + return Pm1ConcentrationMeasurement[direction].attributes[key] +end +Pm1ConcentrationMeasurement.attributes = {} +setmetatable(Pm1ConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(Pm1ConcentrationMeasurement, {__index = cluster_base}) + +return Pm1ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..2635da32a6 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Pm1ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local Pm1ConcentrationMeasurementServerAttributes = {} + +function Pm1ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(Pm1ConcentrationMeasurementServerAttributes, attr_mt) + +return Pm1ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..e6e6144f94 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local Pm25ConcentrationMeasurementServerAttributes = require "embedded_clusters.Pm25ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local Pm25ConcentrationMeasurement = {} + +Pm25ConcentrationMeasurement.ID = 0x042A +Pm25ConcentrationMeasurement.NAME = "Pm25ConcentrationMeasurement" +Pm25ConcentrationMeasurement.server = {} +Pm25ConcentrationMeasurement.client = {} +Pm25ConcentrationMeasurement.server.attributes = Pm25ConcentrationMeasurementServerAttributes:set_parent_cluster(Pm25ConcentrationMeasurement) +Pm25ConcentrationMeasurement.types = ConcentrationMeasurement.types + +function Pm25ConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function Pm25ConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +Pm25ConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +Pm25ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function Pm25ConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Pm25ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Pm25ConcentrationMeasurement.NAME)) + end + return Pm25ConcentrationMeasurement[direction].attributes[key] +end +Pm25ConcentrationMeasurement.attributes = {} +setmetatable(Pm25ConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(Pm25ConcentrationMeasurement, {__index = cluster_base}) + +return Pm25ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..5c432da0ec --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Pm25ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local Pm25ConcentrationMeasurementServerAttributes = {} + +function Pm25ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(Pm25ConcentrationMeasurementServerAttributes, attr_mt) + +return Pm25ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/init.lua similarity index 82% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/init.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/init.lua index d47c239242..42b058eb02 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/init.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/init.lua @@ -1,23 +1,13 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. local cluster_base = require "st.matter.cluster_base" -local PressureMeasurementServerAttributes = require "PressureMeasurement.server.attributes" -local PressureMeasurementServerCommands = require "PressureMeasurement.server.commands" -local PressureMeasurementTypes = require "PressureMeasurement.types" +local PressureMeasurementServerAttributes = require "embedded_clusters.PressureMeasurement.server.attributes" +local PressureMeasurementServerCommands = require "embedded_clusters.PressureMeasurement.server.commands" +local PressureMeasurementTypes = require "embedded_clusters.PressureMeasurement.types" --- @class PressureMeasurement --- @alias PressureMeasurement @@ -141,4 +131,3 @@ setmetatable(PressureMeasurement.events, event_helper_mt) setmetatable(PressureMeasurement, {__index = cluster_base}) return PressureMeasurement - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/AcceptedCommandList.lua similarity index 85% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/AcceptedCommandList.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/AcceptedCommandList.lua index ee9fad2526..dc301eba35 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/AcceptedCommandList.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/AcceptedCommandList.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -123,4 +113,3 @@ end setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) return AcceptedCommandList - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/AttributeList.lua similarity index 84% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/AttributeList.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/AttributeList.lua index d4d85b00a0..deba9d030f 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/AttributeList.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/AttributeList.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -123,4 +113,3 @@ end setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) return AttributeList - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/EventList.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/EventList.lua similarity index 84% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/EventList.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/EventList.lua index b3dc7bf94c..f6040a17a0 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/EventList.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/EventList.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -123,4 +113,3 @@ end setmetatable(EventList, {__call = EventList.new_value, __index = EventList.base_type}) return EventList - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MaxMeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MaxMeasuredValue.lua similarity index 83% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MaxMeasuredValue.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MaxMeasuredValue.lua index 2484d99ef3..7bbf34d9e5 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MaxMeasuredValue.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MaxMeasuredValue.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(MaxMeasuredValue, {__call = MaxMeasuredValue.new_value, __index = MaxMeasuredValue.base_type}) return MaxMeasuredValue - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MaxScaledValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MaxScaledValue.lua similarity index 83% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MaxScaledValue.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MaxScaledValue.lua index 56fe3132f9..91e5f4edc4 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MaxScaledValue.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MaxScaledValue.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(MaxScaledValue, {__call = MaxScaledValue.new_value, __index = MaxScaledValue.base_type}) return MaxScaledValue - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MeasuredValue.lua similarity index 82% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MeasuredValue.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MeasuredValue.lua index 7d850f1a7c..b7c0484722 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MeasuredValue.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MeasuredValue.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) return MeasuredValue - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MinMeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MinMeasuredValue.lua similarity index 83% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MinMeasuredValue.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MinMeasuredValue.lua index 35ec62854f..ed4e421c77 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MinMeasuredValue.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MinMeasuredValue.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(MinMeasuredValue, {__call = MinMeasuredValue.new_value, __index = MinMeasuredValue.base_type}) return MinMeasuredValue - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MinScaledValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MinScaledValue.lua similarity index 83% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MinScaledValue.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MinScaledValue.lua index 97bb0d44df..552ae6160a 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/MinScaledValue.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/MinScaledValue.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(MinScaledValue, {__call = MinScaledValue.new_value, __index = MinScaledValue.base_type}) return MinScaledValue - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/Scale.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/Scale.lua similarity index 82% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/Scale.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/Scale.lua index 5533e4e486..085153bd7a 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/Scale.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/Scale.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(Scale, {__call = Scale.new_value, __index = Scale.base_type}) return Scale - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/ScaledTolerance.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/ScaledTolerance.lua similarity index 83% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/ScaledTolerance.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/ScaledTolerance.lua index d00d2a4195..4c14d72ea3 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/ScaledTolerance.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/ScaledTolerance.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(ScaledTolerance, {__call = ScaledTolerance.new_value, __index = ScaledTolerance.base_type}) return ScaledTolerance - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/ScaledValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/ScaledValue.lua similarity index 82% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/ScaledValue.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/ScaledValue.lua index f2e513477b..28cfe27a7a 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/ScaledValue.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/ScaledValue.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(ScaledValue, {__call = ScaledValue.new_value, __index = ScaledValue.base_type}) return ScaledValue - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/Tolerance.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/Tolerance.lua similarity index 82% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/Tolerance.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/Tolerance.lua index 55a39514a3..4d1b45ee48 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/Tolerance.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/Tolerance.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(Tolerance, {__call = Tolerance.new_value, __index = Tolerance.base_type}) return Tolerance - diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/init.lua similarity index 66% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/init.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/init.lua index 64ecbdf709..ebd4480609 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/server/attributes/init.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/attributes/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -18,7 +8,7 @@ local attr_mt = {} attr_mt.__attr_cache = {} attr_mt.__index = function(self, key) if attr_mt.__attr_cache[key] == nil then - local req_loc = string.format("PressureMeasurement.server.attributes.%s", key) + local req_loc = string.format("embedded_clusters.PressureMeasurement.server.attributes.%s", key) local raw_def = require(req_loc) local cluster = rawget(self, "_cluster") raw_def:set_parent_cluster(cluster) @@ -50,10 +40,4 @@ end setmetatable(PressureMeasurementServerAttributes, attr_mt) -local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.PressureMeasurement.server.attributes") -if has_aliases then - aliases:add_to_class(PressureMeasurementServerAttributes) -end - return PressureMeasurementServerAttributes - diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/commands/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/commands/init.lua new file mode 100644 index 0000000000..db5c3d9d45 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/commands/init.lua @@ -0,0 +1,30 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("embedded_clusters.PressureMeasurement.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +--- @class PressureMeasurementServerCommands +--- +local PressureMeasurementServerCommands = {} + +function PressureMeasurementServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(PressureMeasurementServerCommands, command_mt) + +return PressureMeasurementServerCommands diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/events/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/events/init.lua new file mode 100644 index 0000000000..d96e0d7438 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/server/events/init.lua @@ -0,0 +1,31 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local event_mt = {} +event_mt.__event_cache = {} +event_mt.__index = function(self, key) + if event_mt.__event_cache[key] == nil then + local req_loc = string.format("embedded_clusters.PressureMeasurement.server.events.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + event_mt.__event_cache[key] = raw_def + end + return event_mt.__event_cache[key] +end + +--- @class PressureMeasurementEvents +--- +local PressureMeasurementEvents = {} + +function PressureMeasurementEvents:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(PressureMeasurementEvents, event_mt) + +return PressureMeasurementEvents diff --git a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/types/Feature.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/types/Feature.lua similarity index 73% rename from drivers/SmartThings/matter-sensor/src/PressureMeasurement/types/Feature.lua rename to drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/types/Feature.lua index 5f5f070b7d..814c5ef226 100644 --- a/drivers/SmartThings/matter-sensor/src/PressureMeasurement/types/Feature.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/types/Feature.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -78,4 +68,3 @@ end setmetatable(Feature, new_mt) return Feature - diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/types/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/types/init.lua new file mode 100644 index 0000000000..4f0c8b6bdc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/PressureMeasurement/types/init.lua @@ -0,0 +1,24 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.PressureMeasurement.types." .. key) + end + return types_mt.__types_cache[key] +end + +--- @class PressureMeasurementTypes +--- + +--- @field public Feature PressureMeasurement.types.Feature +local PressureMeasurementTypes = {} + +setmetatable(PressureMeasurementTypes, types_mt) + +return PressureMeasurementTypes diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..3d4b21d602 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local RadonConcentrationMeasurementServerAttributes = require "embedded_clusters.RadonConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local RadonConcentrationMeasurement = {} + +RadonConcentrationMeasurement.ID = 0x042F +RadonConcentrationMeasurement.NAME = "RadonConcentrationMeasurement" +RadonConcentrationMeasurement.server = {} +RadonConcentrationMeasurement.client = {} +RadonConcentrationMeasurement.server.attributes = RadonConcentrationMeasurementServerAttributes:set_parent_cluster(RadonConcentrationMeasurement) +RadonConcentrationMeasurement.types = ConcentrationMeasurement.types + +function RadonConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function RadonConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +RadonConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +RadonConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function RadonConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = RadonConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, RadonConcentrationMeasurement.NAME)) + end + return RadonConcentrationMeasurement[direction].attributes[key] +end +RadonConcentrationMeasurement.attributes = {} +setmetatable(RadonConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(RadonConcentrationMeasurement, {__index = cluster_base}) + +return RadonConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..8aa225e510 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.RadonConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local RadonConcentrationMeasurementServerAttributes = {} + +function RadonConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RadonConcentrationMeasurementServerAttributes, attr_mt) + +return RadonConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/init.lua new file mode 100644 index 0000000000..b492c28f18 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/init.lua @@ -0,0 +1,112 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local SmokeCoAlarmServerAttributes = require "embedded_clusters.SmokeCoAlarm.server.attributes" +local SmokeCoAlarmServerCommands = require "embedded_clusters.SmokeCoAlarm.server.commands" +local SmokeCoAlarmTypes = require "embedded_clusters.SmokeCoAlarm.types" + +local SmokeCoAlarm = {} + +SmokeCoAlarm.ID = 0x005C +SmokeCoAlarm.NAME = "SmokeCoAlarm" +SmokeCoAlarm.server = {} +SmokeCoAlarm.client = {} +SmokeCoAlarm.server.attributes = SmokeCoAlarmServerAttributes:set_parent_cluster(SmokeCoAlarm) +SmokeCoAlarm.server.commands = SmokeCoAlarmServerCommands:set_parent_cluster(SmokeCoAlarm) +SmokeCoAlarm.types = SmokeCoAlarmTypes + +function SmokeCoAlarm:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "ExpressedState", + [0x0001] = "SmokeState", + [0x0002] = "COState", + [0x0003] = "BatteryAlert", + [0x0004] = "DeviceMuted", + [0x0005] = "TestInProgress", + [0x0006] = "HardwareFaultAlert", + [0x0007] = "EndOfServiceAlert", + [0x0008] = "InterconnectSmokeAlarm", + [0x0009] = "InterconnectCOAlarm", + [0x000A] = "ContaminationState", + [0x000B] = "SmokeSensitivityLevel", + [0x000C] = "ExpiryDate", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function SmokeCoAlarm:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "SelfTestRequest", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +SmokeCoAlarm.attribute_direction_map = { + ["ExpressedState"] = "server", + ["SmokeState"] = "server", + ["COState"] = "server", + ["BatteryAlert"] = "server", + ["DeviceMuted"] = "server", + ["TestInProgress"] = "server", + ["HardwareFaultAlert"] = "server", + ["EndOfServiceAlert"] = "server", + ["InterconnectSmokeAlarm"] = "server", + ["InterconnectCOAlarm"] = "server", + ["ContaminationState"] = "server", + ["SmokeSensitivityLevel"] = "server", + ["ExpiryDate"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +SmokeCoAlarm.command_direction_map = { + ["SelfTestRequest"] = "server", +} + +SmokeCoAlarm.FeatureMap = SmokeCoAlarm.types.Feature + +function SmokeCoAlarm.are_features_supported(feature, feature_map) + if (SmokeCoAlarm.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = SmokeCoAlarm.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, SmokeCoAlarm.NAME)) + end + return SmokeCoAlarm[direction].attributes[key] +end +SmokeCoAlarm.attributes = {} +setmetatable(SmokeCoAlarm.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = SmokeCoAlarm.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, SmokeCoAlarm.NAME)) + end + return SmokeCoAlarm[direction].commands[key] +end +SmokeCoAlarm.commands = {} +setmetatable(SmokeCoAlarm.commands, command_helper_mt) + +setmetatable(SmokeCoAlarm, {__index = cluster_base}) + +return SmokeCoAlarm + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/BatteryAlert.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/BatteryAlert.lua new file mode 100644 index 0000000000..4ba713dcc4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/BatteryAlert.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local BatteryAlert = { + ID = 0x0003, + NAME = "BatteryAlert", + base_type = require "embedded_clusters.SmokeCoAlarm.types.AlarmStateEnum", +} + +function BatteryAlert:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function BatteryAlert:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + + +function BatteryAlert:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function BatteryAlert:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function BatteryAlert:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function BatteryAlert:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(BatteryAlert, {__call = BatteryAlert.new_value, __index = BatteryAlert.base_type}) +return BatteryAlert + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/COState.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/COState.lua new file mode 100644 index 0000000000..db21d19922 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/COState.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local COState = { + ID = 0x0002, + NAME = "COState", + base_type = require "embedded_clusters.SmokeCoAlarm.types.AlarmStateEnum", +} + +function COState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function COState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function COState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function COState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function COState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function COState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(COState, {__call = COState.new_value, __index = COState.base_type}) +return COState + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/HardwareFaultAlert.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/HardwareFaultAlert.lua new file mode 100644 index 0000000000..2c7cfdde88 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/HardwareFaultAlert.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local HardwareFaultAlert = { + ID = 0x0006, + NAME = "HardwareFaultAlert", + base_type = require "st.matter.data_types.Boolean", +} + +function HardwareFaultAlert:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function HardwareFaultAlert:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function HardwareFaultAlert:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function HardwareFaultAlert:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function HardwareFaultAlert:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function HardwareFaultAlert:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(HardwareFaultAlert, {__call = HardwareFaultAlert.new_value, __index = HardwareFaultAlert.base_type}) +return HardwareFaultAlert + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeSensitivityLevel.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeSensitivityLevel.lua new file mode 100644 index 0000000000..e97da91b05 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeSensitivityLevel.lua @@ -0,0 +1,84 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SmokeSensitivityLevel = { + ID = 0x000B, + NAME = "SmokeSensitivityLevel", + base_type = require "embedded_clusters.SmokeCoAlarm.types.SensitivityEnum", +} + +function SmokeSensitivityLevel:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SmokeSensitivityLevel:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SmokeSensitivityLevel:write(device, endpoint_id, value) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.write( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + data + ) +end + +function SmokeSensitivityLevel:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SmokeSensitivityLevel:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SmokeSensitivityLevel:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SmokeSensitivityLevel:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SmokeSensitivityLevel, {__call = SmokeSensitivityLevel.new_value, __index = SmokeSensitivityLevel.base_type}) +return SmokeSensitivityLevel + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeState.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeState.lua new file mode 100644 index 0000000000..4879810c61 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/SmokeState.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SmokeState = { + ID = 0x0001, + NAME = "SmokeState", + base_type = require "embedded_clusters.SmokeCoAlarm.types.AlarmStateEnum", +} + +function SmokeState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SmokeState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function SmokeState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SmokeState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SmokeState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SmokeState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SmokeState, {__call = SmokeState.new_value, __index = SmokeState.base_type}) +return SmokeState + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/TestInProgress.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/TestInProgress.lua new file mode 100644 index 0000000000..42af52c87b --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/TestInProgress.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local TestInProgress = { + ID = 0x0005, + NAME = "TestInProgress", + base_type = require "st.matter.data_types.Boolean", +} + +function TestInProgress:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function TestInProgress:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function TestInProgress:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TestInProgress:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function TestInProgress:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function TestInProgress:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(TestInProgress, {__call = TestInProgress.new_value, __index = TestInProgress.base_type}) +return TestInProgress + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/init.lua new file mode 100644 index 0000000000..f08cf54fd0 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.SmokeCoAlarm.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local SmokeCoAlarmServerAttributes = {} + +function SmokeCoAlarmServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(SmokeCoAlarmServerAttributes, attr_mt) + +return SmokeCoAlarmServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/SelfTestRequest.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/SelfTestRequest.lua new file mode 100644 index 0000000000..e8d892d1c3 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/SelfTestRequest.lua @@ -0,0 +1,94 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SelfTestRequest = {} + +SelfTestRequest.NAME = "SelfTestRequest" +SelfTestRequest.ID = 0x0000 +SelfTestRequest.field_defs = { +} + +function SelfTestRequest:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SelfTestRequest:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SelfTestRequest, + __tostring = SelfTestRequest.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function SelfTestRequest:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SelfTestRequest:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SelfTestRequest:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SelfTestRequest, {__call = SelfTestRequest.init}) + +return SelfTestRequest + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/init.lua new file mode 100644 index 0000000000..f1ff1ab72a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/server/commands/init.lua @@ -0,0 +1,26 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("embedded_clusters.SmokeCoAlarm.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local SmokeCoAlarmServerCommands = {} + +function SmokeCoAlarmServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(SmokeCoAlarmServerCommands, command_mt) + +return SmokeCoAlarmServerCommands + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/AlarmStateEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/AlarmStateEnum.lua new file mode 100644 index 0000000000..ac20cac525 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/AlarmStateEnum.lua @@ -0,0 +1,36 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local AlarmStateEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NORMAL] = "NORMAL", + [self.WARNING] = "WARNING", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NORMAL = 0x00 +new_mt.__index.WARNING = 0x01 +new_mt.__index.CRITICAL = 0x02 + +AlarmStateEnum.NORMAL = 0x00 +AlarmStateEnum.WARNING = 0x01 +AlarmStateEnum.CRITICAL = 0x02 + +AlarmStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(AlarmStateEnum, new_mt) + +return AlarmStateEnum + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/Feature.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/Feature.lua new file mode 100644 index 0000000000..1918a3be00 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/Feature.lua @@ -0,0 +1,79 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.SMOKE_ALARM = 0x0001 +Feature.CO_ALARM = 0x0002 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + SMOKE_ALARM = 0x0001, + CO_ALARM = 0x0002, +} + +Feature.is_smoke_alarm_set = function(self) + return (self.value & self.SMOKE_ALARM) ~= 0 +end + +Feature.set_smoke_alarm = function(self) + if self.value ~= nil then + self.value = self.value | self.SMOKE_ALARM + else + self.value = self.SMOKE_ALARM + end +end + +Feature.unset_smoke_alarm = function(self) + self.value = self.value & (~self.SMOKE_ALARM & self.BASE_MASK) +end + +Feature.is_co_alarm_set = function(self) + return (self.value & self.CO_ALARM) ~= 0 +end + +Feature.set_co_alarm = function(self) + if self.value ~= nil then + self.value = self.value | self.CO_ALARM + else + self.value = self.CO_ALARM + end +end + +Feature.unset_co_alarm = function(self) + self.value = self.value & (~self.CO_ALARM & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.SMOKE_ALARM | + Feature.CO_ALARM + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_smoke_alarm_set = Feature.is_smoke_alarm_set, + set_smoke_alarm = Feature.set_smoke_alarm, + unset_smoke_alarm = Feature.unset_smoke_alarm, + is_co_alarm_set = Feature.is_co_alarm_set, + set_co_alarm = Feature.set_co_alarm, + unset_co_alarm = Feature.unset_co_alarm, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/SensitivityEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/SensitivityEnum.lua new file mode 100644 index 0000000000..5728278657 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/SensitivityEnum.lua @@ -0,0 +1,36 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local SensitivityEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.HIGH] = "HIGH", + [self.STANDARD] = "STANDARD", + [self.LOW] = "LOW", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.HIGH = 0x00 +new_mt.__index.STANDARD = 0x01 +new_mt.__index.LOW = 0x02 + +SensitivityEnum.HIGH = 0x00 +SensitivityEnum.STANDARD = 0x01 +SensitivityEnum.LOW = 0x02 + +SensitivityEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(SensitivityEnum, new_mt) + +return SensitivityEnum + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/init.lua new file mode 100644 index 0000000000..027aaaf344 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SmokeCoAlarm/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.SmokeCoAlarm.types." .. key) + end + return types_mt.__types_cache[key] +end + +local SmokeCoAlarmTypes = {} + +setmetatable(SmokeCoAlarmTypes, types_mt) + +return SmokeCoAlarmTypes + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/init.lua new file mode 100644 index 0000000000..a7db91daea --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/init.lua @@ -0,0 +1,46 @@ +local cluster_base = require "st.matter.cluster_base" +local SoilMeasurementServerAttributes = require "embedded_clusters.SoilMeasurement.server.attributes" + +local SoilMeasurement = {} + +SoilMeasurement.ID = 0x0430 +SoilMeasurement.NAME = "SoilMeasurement" +SoilMeasurement.server = {} +SoilMeasurement.client = {} +SoilMeasurement.server.attributes = SoilMeasurementServerAttributes:set_parent_cluster(SoilMeasurement) + +function SoilMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SoilMoistureMeasurementLimits", + [0x0001] = "SoilMoistureMeasuredValue", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +SoilMeasurement.attribute_direction_map = { + ["SoilMoistureMeasurementLimits"] = "server", + ["SoilMoistureMeasuredValue"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = SoilMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, SoilMeasurement.NAME)) + end + return SoilMeasurement[direction].attributes[key] +end +SoilMeasurement.attributes = {} +setmetatable(SoilMeasurement.attributes, attribute_helper_mt) + +setmetatable(SoilMeasurement, {__index = cluster_base}) + +return SoilMeasurement diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasuredValue.lua new file mode 100644 index 0000000000..5df92ec62a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasuredValue.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SoilMoistureMeasuredValue = { + ID = 0x0001, + NAME = "SoilMoistureMeasuredValue", + base_type = require "st.matter.data_types.Uint8", +} + +function SoilMoistureMeasuredValue:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SoilMoistureMeasuredValue:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasuredValue:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SoilMoistureMeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SoilMoistureMeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SoilMoistureMeasuredValue, {__call = SoilMoistureMeasuredValue.new_value, __index = SoilMoistureMeasuredValue.base_type}) +return SoilMoistureMeasuredValue diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasurementLimits.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasurementLimits.lua new file mode 100644 index 0000000000..6fbf1a8c8a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasurementLimits.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SoilMoistureMeasurementLimits = { + ID = 0x0000, + NAME = "SoilMoistureMeasurementLimits", + base_type = require "embedded_clusters.Global.types.MeasurementAccuracyStruct", +} + +function SoilMoistureMeasurementLimits:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SoilMoistureMeasurementLimits:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasurementLimits:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasurementLimits:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SoilMoistureMeasurementLimits:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SoilMoistureMeasurementLimits:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SoilMoistureMeasurementLimits, {__call = SoilMoistureMeasurementLimits.new_value, __index = SoilMoistureMeasurementLimits.base_type}) +return SoilMoistureMeasurementLimits diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..3741efd72a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/init.lua @@ -0,0 +1,19 @@ +local attr_mt = {} +attr_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.SoilMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local SoilMeasurementServerAttributes = {} + +function SoilMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(SoilMeasurementServerAttributes, attr_mt) + +return SoilMeasurementServerAttributes diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..cad49a14e1 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local TotalVolatileOrganicCompoundsConcentrationMeasurement = {} + +TotalVolatileOrganicCompoundsConcentrationMeasurement.ID = 0x042E +TotalVolatileOrganicCompoundsConcentrationMeasurement.NAME = "TotalVolatileOrganicCompoundsConcentrationMeasurement" +TotalVolatileOrganicCompoundsConcentrationMeasurement.server = {} +TotalVolatileOrganicCompoundsConcentrationMeasurement.client = {} +TotalVolatileOrganicCompoundsConcentrationMeasurement.server.attributes = TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes:set_parent_cluster(TotalVolatileOrganicCompoundsConcentrationMeasurement) +TotalVolatileOrganicCompoundsConcentrationMeasurement.types = ConcentrationMeasurement.types + +function TotalVolatileOrganicCompoundsConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function TotalVolatileOrganicCompoundsConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +TotalVolatileOrganicCompoundsConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +TotalVolatileOrganicCompoundsConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function TotalVolatileOrganicCompoundsConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = TotalVolatileOrganicCompoundsConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, TotalVolatileOrganicCompoundsConcentrationMeasurement.NAME)) + end + return TotalVolatileOrganicCompoundsConcentrationMeasurement[direction].attributes[key] +end +TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes = {} +setmetatable(TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(TotalVolatileOrganicCompoundsConcentrationMeasurement, {__index = cluster_base}) + +return TotalVolatileOrganicCompoundsConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..b67cbcd7b6 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes = {} + +function TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes, attr_mt) + +return TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-sensor/src/init.lua b/drivers/SmartThings/matter-sensor/src/init.lua index bafffdf578..6dbd6d9811 100644 --- a/drivers/SmartThings/matter-sensor/src/init.lua +++ b/drivers/SmartThings/matter-sensor/src/init.lua @@ -1,182 +1,165 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" -local log = require "log" local clusters = require "st.matter.clusters" local MatterDriver = require "st.matter.driver" -local utils = require "st.utils" +local version = require "version" + +local fields = require "sensor_utils.fields" +local device_cfg = require "sensor_utils.device_configuration" +local attribute_handlers = require "sensor_handlers.attribute_handlers" -- This can be removed once LuaLibs supports the PressureMeasurement cluster if not pcall(function(cluster) return clusters[cluster] end, "PressureMeasurement") then - clusters.PressureMeasurement = require "PressureMeasurement" + clusters.PressureMeasurement = require "embedded_clusters.PressureMeasurement" end -local BATTERY_CHECKED = "__battery_checked" - -local HUE_MANUFACTURER_ID = 0x100B - -local function supports_battery_percentage_remaining(device) - local battery_eps = device:get_endpoints(clusters.PowerSource.ID, - {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) - -- Hue devices support the PowerSource cluster but don't support reporting battery percentage remaining - if #battery_eps > 0 and device.manufacturer_info.vendor_id ~= HUE_MANUFACTURER_ID then - return true - end - return false +-- Include driver-side definitions when lua libs api version is < 10 +if version.api < 10 then + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.SmokeCoAlarm = require "embedded_clusters.SmokeCoAlarm" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" end -local function check_for_battery(device) - local profile_name = "" - - if device:supports_capability(capabilities.motionSensor) then - profile_name = profile_name .. "-motion" - end - - if device:supports_capability(capabilities.contactSensor) then - profile_name = profile_name .. "-contact" - end - - if device:supports_capability(capabilities.illuminanceMeasurement) then - profile_name = profile_name .. "-illuminance" - end - - if device:supports_capability(capabilities.temperatureMeasurement) then - profile_name = profile_name .. "-temperature" - end +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" +end - if device:supports_capability(capabilities.relativeHumidityMeasurement) then - profile_name = profile_name .. "-humidity" - end +-- Include driver-side definitions when lua libs api version is < 21 +-- TODO: change this to < 20 once the lua libs have been updated for hub-core 61 +if version.api < 21 then + clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" +end - if device:supports_capability(capabilities.atmosphericPressureMeasurement) then - profile_name = profile_name .. "-pressure" - end +local SensorLifecycleHandlers = {} - if supports_battery_percentage_remaining(device) then - profile_name = profile_name .. "-battery" +function SensorLifecycleHandlers.do_configure(driver, device) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps > 0 then + device:send(clusters.PowerSource.attributes.AttributeList:read()) + else + device_cfg.match_profile(driver, device, fields.battery_support.NO_BATTERY) end - - -- remove leading "-" - profile_name = string.sub(profile_name, 2) - - device:try_update_metadata({profile = profile_name}) - device:set_field(BATTERY_CHECKED, 1, {persist = true}) end -local function device_init(driver, device) - log.info("device init") - if not device:get_field(BATTERY_CHECKED) then - check_for_battery(device) - end +function SensorLifecycleHandlers.device_init(driver, device) + device.log.info("device init") + device_cfg.set_boolean_device_type_per_endpoint(driver, device) device:subscribe() end -local function info_changed(driver, device, event, args) +function SensorLifecycleHandlers.info_changed(driver, device, event, args) if device.profile.id ~= args.old_st_store.profile.id then + device_cfg.set_boolean_device_type_per_endpoint(driver, device) device:subscribe() end -end - -local function illuminance_attr_handler(driver, device, ib, response) - local lux = math.floor(10 ^ ((ib.data.value - 1) / 10000)) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.illuminanceMeasurement.illuminance(lux)) -end - -local function temperature_attr_handler(driver, device, ib, response) - local measured_value = ib.data.value - if measured_value ~= nil then - local temp = measured_value / 100.0 - local unit = "C" - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperature({value = temp, unit = unit})) - end -end - -local function humidity_attr_handler(driver, device, ib, response) - local measured_value = ib.data.value - if measured_value ~= nil then - local humidity = utils.round(measured_value / 100.0) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) - end -end - -local function boolean_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.contactSensor.contact.closed()) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.contactSensor.contact.open()) - end -end - -local function battery_percent_remaining_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + if not device.preferences then + return end -end - -local function occupancy_attr_handler(driver, device, ib, response) - device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) -end - -local function pressure_attr_handler(driver, device, ib, response) - local measured_value = ib.data.value - if measured_value ~= nil then - local kPa = utils.round(measured_value / 10.0) - local unit = "kPa" - device:emit_event(capabilities.atmosphericPressureMeasurement.atmosphericPressure({value = kPa, unit = unit})) + for dt_name, info in pairs(fields.BOOLEAN_DEVICE_TYPE_INFO) do + local dt_ep = device:get_field(dt_name) + if dt_ep and info.sensitivity_preference and (device.preferences[info.sensitivity_preference] ~= args.old_st_store.preferences[info.sensitivity_preference]) then + local sensitivity_preference = device.preferences[info.sensitivity_preference] + if sensitivity_preference == "2" then -- high + local max_sensitivity_level = device:get_field(info.sensitivity_max) - 1 + device:send(clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(device, dt_ep, max_sensitivity_level)) + elseif sensitivity_preference == "1" then -- medium + local medium_sensitivity_level = math.floor((device:get_field(info.sensitivity_max) + 1) / 2) + device:send(clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(device, dt_ep, medium_sensitivity_level)) + elseif sensitivity_preference == "0" then -- low + local min_sensitivity_level = 0 + device:send(clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(device, dt_ep, min_sensitivity_level)) + end + end end end local matter_driver_template = { lifecycle_handlers = { - init = device_init, - infoChanged = info_changed + doConfigure = SensorLifecycleHandlers.do_configure, + init = SensorLifecycleHandlers.device_init, + infoChanged = SensorLifecycleHandlers.info_changed, }, matter_handlers = { attr = { - [clusters.RelativeHumidityMeasurement.ID] = { - [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = humidity_attr_handler + [clusters.BooleanState.ID] = { + [clusters.BooleanState.attributes.StateValue.ID] = attribute_handlers.boolean_state_value_handler }, - [clusters.TemperatureMeasurement.ID] = { - [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = temperature_attr_handler + [clusters.BooleanStateConfiguration.ID] = { + [clusters.BooleanStateConfiguration.attributes.SensorFault.ID] = attribute_handlers.sensor_fault_handler, + [clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels.ID] = attribute_handlers.supported_sensitivity_levels_handler, + }, + [clusters.FlowMeasurement.ID] = { + [clusters.FlowMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.flow_measured_value_handler, + [clusters.FlowMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.flow_measured_value_bounds_factory(fields.FLOW_MIN), + [clusters.FlowMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.flow_measured_value_bounds_factory(fields.FLOW_MAX) }, [clusters.IlluminanceMeasurement.ID] = { - [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = illuminance_attr_handler + [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.illuminance_measured_value_handler }, - [clusters.BooleanState.ID] = { - [clusters.BooleanState.attributes.StateValue.ID] = boolean_attr_handler + [clusters.OccupancySensing.ID] = { + [clusters.OccupancySensing.attributes.Occupancy.ID] = attribute_handlers.occupancy_measured_value_handler, }, [clusters.PowerSource.ID] = { - [clusters.PowerSource.attributes.BatPercentRemaining.ID] = battery_percent_remaining_attr_handler, - }, - [clusters.OccupancySensing.ID] = { - [clusters.OccupancySensing.attributes.Occupancy.ID] = occupancy_attr_handler, + [clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler, + [clusters.PowerSource.attributes.BatChargeLevel.ID] = attribute_handlers.bat_charge_level_handler, + [clusters.PowerSource.attributes.BatPercentRemaining.ID] = attribute_handlers.bat_percent_remaining_handler, }, [clusters.PressureMeasurement.ID] = { - [clusters.PressureMeasurement.attributes.MeasuredValue.ID] = pressure_attr_handler, + [clusters.PressureMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.pressure_measured_value_handler, + }, + [clusters.RelativeHumidityMeasurement.ID] = { + [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.humidity_measured_value_handler + }, + [clusters.SoilMeasurement.ID] = { + [clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue.ID] = attribute_handlers.soil_moisture_measured_value_handler, + [clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits.ID] = attribute_handlers.soil_moisture_measurement_limits_handler + }, + [clusters.TemperatureMeasurement.ID] = { + [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.temperature_measured_value_handler, + [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.TEMP_MIN), + [clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.TEMP_MAX), + }, + [clusters.Thermostat.ID] = { + [clusters.Thermostat.attributes.LocalTemperature.ID] = attribute_handlers.temperature_measured_value_handler -- TemperatureMeasurement.MeasuredValue handler can support this attibute }, } }, - -- TODO Once capabilities all have default handlers move this info there, and - -- use `supported_capabilities` subscribed_attributes = { - [capabilities.relativeHumidityMeasurement.ID] = { - clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining }, - [capabilities.temperatureMeasurement.ID] = { - clusters.TemperatureMeasurement.attributes.MeasuredValue + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel, + clusters.SmokeCoAlarm.attributes.BatteryAlert, + }, + [capabilities.contactSensor.ID] = { + clusters.BooleanState.attributes.StateValue + }, + [capabilities.flowMeasurement.ID] = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.hardwareFault.ID] = { + clusters.BooleanStateConfiguration.attributes.SensorFault, + -- THESE ARE USED IN THE CASE OF THE SMOKE CO ALARM. + -- TODO: move this unique subscription logic into the subdriver + clusters.SmokeCoAlarm.attributes.HardwareFaultAlert, + clusters.SmokeCoAlarm.attributes.BatteryAlert, + clusters.PowerSource.attributes.BatChargeLevel, }, [capabilities.illuminanceMeasurement.ID] = { clusters.IlluminanceMeasurement.attributes.MeasuredValue @@ -184,27 +167,145 @@ local matter_driver_template = { [capabilities.motionSensor.ID] = { clusters.OccupancySensing.attributes.Occupancy }, - [capabilities.contactSensor.ID] = { - clusters.BooleanState.attributes.StateValue + [capabilities.presenceSensor.ID] = { + clusters.OccupancySensing.attributes.Occupancy }, - [capabilities.battery.ID] = { - clusters.PowerSource.attributes.BatPercentRemaining + [capabilities.rainSensor.ID] = { + clusters.BooleanState.attributes.StateValue, + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits + }, + [capabilities.temperatureAlarm.ID] = { + clusters.BooleanState.attributes.StateValue, + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.Thermostat.attributes.LocalTemperature + }, + [capabilities.waterSensor.ID] = { + clusters.BooleanState.attributes.StateValue, + }, + -- AIR QUALITY SENSOR SPECIFIC CAPABILITIES -- + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality }, [capabilities.atmosphericPressureMeasurement.ID] = { clusters.PressureMeasurement.attributes.MeasuredValue }, + [capabilities.carbonDioxideHealthConcern.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.carbonDioxideMeasurement.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.carbonMonoxideHealthConcern.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.carbonMonoxideMeasurement.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.nitrogenDioxideMeasurement.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.ozoneHealthConcern.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.ozoneMeasurement.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.MeasuredValue, + clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.radonHealthConcern.ID] = { + clusters.RadonConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.radonMeasurement.ID] = { + clusters.RadonConcentrationMeasurement.attributes.MeasuredValue, + clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.veryFineDustHealthConcern.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.veryFineDustSensor.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit, + }, + -- SMOKE CO ALARM SPECIFIC CAPABILITIES -- + [capabilities.carbonMonoxideDetector.ID] = { + clusters.SmokeCoAlarm.attributes.COState, + clusters.SmokeCoAlarm.attributes.TestInProgress, + }, + [capabilities.smokeDetector.ID] = { + clusters.SmokeCoAlarm.attributes.SmokeState, + clusters.SmokeCoAlarm.attributes.TestInProgress, + }, }, - capability_handlers = { + subscribed_events = { + [capabilities.button.ID] = { + clusters.Switch.events.InitialPress, + clusters.Switch.events.LongPress, + clusters.Switch.events.MultiPressComplete, + } }, + capability_handlers = {}, supported_capabilities = { capabilities.temperatureMeasurement, capabilities.contactSensor, capabilities.motionSensor, + capabilities.presenceSensor, + capabilities.button, capabilities.battery, + capabilities.batteryLevel, capabilities.relativeHumidityMeasurement, capabilities.illuminanceMeasurement, capabilities.atmosphericPressureMeasurement, + capabilities.waterSensor, + capabilities.temperatureAlarm, + capabilities.rainSensor, + capabilities.hardwareFault, + capabilities.flowMeasurement, }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } local matter_driver = MatterDriver("matter-sensor", matter_driver_template) diff --git a/drivers/SmartThings/matter-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/matter-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..a04740d267 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + local MatterDriver = require "st.matter.driver" + local version = require "version" + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..1867d65e0b --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua @@ -0,0 +1,240 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local st_utils = require "st.utils" +local sensor_utils = require "sensor_utils.utils" +local fields = require "sensor_utils.fields" +local device_cfg = require "sensor_utils.device_configuration" +local version = require "version" + +if version.api < 13 then + clusters.Global = require "embedded_clusters.Global" +end + +-- The SOIL_MOISTURE MeasurementTypeEnum variant was added to the Global MeasurementTypeEnum +-- def in lua libs in api version 21 as a part of the fix for Shared and Global types. +if version.api < 21 then + clusters.Global.types.MeasurementTypeEnum = require "embedded_clusters.Global.types.MeasurementTypeEnum" +end + +local AttributeHandlers = {} + + +-- [[ ILLUMINANCE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.illuminance_measured_value_handler(driver, device, ib, response) + local lux = math.floor(10 ^ ((ib.data.value - 1) / 10000)) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.illuminanceMeasurement.illuminance(lux)) +end + + +-- [[ TEMPERATURE MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.temperature_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local temp = measured_value / 100.0 + local unit = "C" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperature({value = temp, unit = unit})) + end +end + +function AttributeHandlers.temperature_measured_value_bounds_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local temp = ib.data.value / 100.0 + local unit = "C" + sensor_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..minOrMax, ib.endpoint_id, temp) + local min = sensor_utils.get_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MIN, ib.endpoint_id) + local max = sensor_utils.get_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + -- Only emit the capability for RPC version >= 5 (unit conversion for + -- temperature range capability is only supported for RPC >= 5) + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit })) + end + sensor_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MIN, ib.endpoint_id, nil) + sensor_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + + +-- [[ RELATIVE HUMIDITY MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.humidity_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local humidity = st_utils.round(measured_value / 100.0) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) + end +end + + +-- [[ SOIL MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.soil_moisture_measured_value_handler(driver, device, ib, response) + if ib.data.value == nil then return end + local min = sensor_utils.get_field_for_endpoint(device, fields.SOIL_LIMIT_MIN, ib.endpoint_id) or sensor_utils.SOIL_MOISTURE_MIN + local max = sensor_utils.get_field_for_endpoint(device, fields.SOIL_LIMIT_MAX, ib.endpoint_id) or sensor_utils.SOIL_MOISTURE_MAX + local soil_moisture = st_utils.clamp_value(ib.data.value, min, max) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(soil_moisture)) +end + +function AttributeHandlers.soil_moisture_measurement_limits_handler(driver, device, ib, response) + if version.api < 13 then + local MeasurementAccuracyStruct = require "embedded_clusters.Global.types.MeasurementAccuracyStruct" + MeasurementAccuracyStruct:augment_type(ib.data) + end + local min_val = ib.data.elements and ib.data.elements.min_measured_value and ib.data.elements.min_measured_value.value + local max_val = ib.data.elements and ib.data.elements.max_measured_value and ib.data.elements.max_measured_value.value + if not (min_val and max_val) or (min_val >= max_val) or (min_val < sensor_utils.SOIL_MOISTURE_MIN) or (max_val > sensor_utils.SOIL_MOISTURE_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported invalid soil moisture limits: min=%s, max=%s", min_val, max_val)) + return + end + sensor_utils.set_field_for_endpoint(device, fields.SOIL_LIMIT_MIN, ib.endpoint_id, min_val) + sensor_utils.set_field_for_endpoint(device, fields.SOIL_LIMIT_MAX, ib.endpoint_id, max_val) +end + + +-- [[ BOOLEAN STATE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.boolean_state_value_handler(driver, device, ib, response) + local name + for dt_name, _ in pairs(fields.BOOLEAN_DEVICE_TYPE_INFO) do + local dt_ep_id = device:get_field(dt_name) + if ib.endpoint_id == dt_ep_id then + name = dt_name + break + end + end + if name then + device:emit_event_for_endpoint(ib.endpoint_id, fields.BOOLEAN_CAP_EVENT_MAP[ib.data.value][name]) + elseif device:supports_capability(capabilities.contactSensor) then + -- The generic case where no device type has been specified but the profile uses this capability. + device:emit_event_for_endpoint(ib.endpoint_id, fields.BOOLEAN_CAP_EVENT_MAP[ib.data.value]["CONTACT_SENSOR"]) + else + device.log.error("No Boolean device type found on an endpoint, BooleanState handler aborted") + end +end + + +-- [[ BOOLEAN STATE CONFIGURATION CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.sensor_fault_handler(driver, device, ib, response) + if ib.data.value > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.hardwareFault.hardwareFault.detected()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.hardwareFault.hardwareFault.clear()) + end +end + +function AttributeHandlers.supported_sensitivity_levels_handler(driver, device, ib, response) + if ib.data.value then + for dt_name, info in pairs(fields.BOOLEAN_DEVICE_TYPE_INFO) do + if device:get_field(dt_name) == ib.endpoint_id then + device:set_field(info.sensitivity_max, ib.data.value, {persist = true}) + end + end + end +end + + +-- [[ POWER SOURCE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.bat_percent_remaining_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response) + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) + for _, attr in ipairs(ib.data.elements) do + -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or + -- BatChargeLevel (Attribute ID 0x0E) is present. + if attr.value == 0x0C then + device_cfg.match_profile(driver, device, fields.battery_support.BATTERY_PERCENTAGE) + return + elseif attr.value == 0x0E then + device_cfg.match_profile(driver, device, fields.battery_support.BATTERY_LEVEL) + return + end + end +end + + +-- [[ OCCUPANCY SENSING CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.occupancy_measured_value_handler(driver, device, ib, response) + if device:supports_capability(capabilities.motionSensor) then + device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) + else + device:emit_event(ib.data.value == 0x01 and capabilities.presenceSensor.presence("present") or capabilities.presenceSensor.presence("not present")) + end +end + + +-- [[ PRESSURE MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.pressure_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local kPa = st_utils.round(measured_value / 10.0) + local unit = "kPa" + device:emit_event(capabilities.atmosphericPressureMeasurement.atmosphericPressure({value = kPa, unit = unit})) + end +end + + +-- [[ FLOW MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.flow_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local flow = measured_value / 10.0 + local unit = "m^3/h" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flow({value = flow, unit = unit})) + end +end + +function AttributeHandlers.flow_measured_value_bounds_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local flow_bound = ib.data.value / 10.0 + local unit = "m^3/h" + sensor_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..minOrMax, ib.endpoint_id, flow_bound) + local min = sensor_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id) + local max = sensor_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flowRange({ value = { minimum = min, maximum = max }, unit = unit })) + sensor_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id, nil) + sensor_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min flow measurement %d that is not lower than the reported max flow measurement %d", min, max)) + end + end + end +end + +return AttributeHandlers diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/device_configuration.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/device_configuration.lua new file mode 100644 index 0000000000..4690a3e356 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/device_configuration.lua @@ -0,0 +1,133 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils" +local fields = require "sensor_utils.fields" +local version = require "version" + +if version.api < 11 then + clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" +end + +if version.api < 21 then + clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" +end + +local DeviceConfiguration = {} + +function DeviceConfiguration.set_boolean_device_type_per_endpoint(driver, device) + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + for dt_name, info in pairs(fields.BOOLEAN_DEVICE_TYPE_INFO) do + if dt.device_type_id == info.id then + device:set_field(dt_name, ep.endpoint_id, { persist = true }) + device:send(clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:read(device, ep.endpoint_id)) + end + end + end + end +end + +local function supports_sensitivity_preferences(device) + local preference_names = "" + local sensitivity_eps = embedded_cluster_utils.get_endpoints(device, clusters.BooleanStateConfiguration.ID, + {feature_bitmap = clusters.BooleanStateConfiguration.types.Feature.SENSITIVITY_LEVEL}) + if sensitivity_eps and #sensitivity_eps > 0 then + for _, dt_name in ipairs(fields.ORDERED_DEVICE_TYPE_INFO) do + for _, sensitivity_ep in pairs(sensitivity_eps) do + if device:get_field(dt_name) == sensitivity_ep and fields.BOOLEAN_DEVICE_TYPE_INFO[dt_name].sensitivity_preference ~= "N/A" then + preference_names = preference_names .. "-" .. fields.BOOLEAN_DEVICE_TYPE_INFO[dt_name].sensitivity_preference + end + end + end + end + return preference_names +end + +function DeviceConfiguration.match_profile(driver, device, battery_supported) + local profile_name = "" + + if device:supports_capability(capabilities.contactSensor) then + profile_name = profile_name .. "-contact" + end + + if device:supports_capability(capabilities.illuminanceMeasurement) then + profile_name = profile_name .. "-illuminance" + end + + if device:supports_capability(capabilities.temperatureMeasurement) or + #device:get_endpoints(clusters.TemperatureMeasurement.ID) > 0 then + profile_name = profile_name .. "-temperature" + end + + if device:supports_capability(capabilities.relativeHumidityMeasurement) then + if #embedded_cluster_utils.get_endpoints(device, clusters.SoilMeasurement.ID) > 0 then + -- TODO: Update soil sensor profiles to use the SoilSensor category once it is available. + profile_name = profile_name .. "-soil-sensor" + else + profile_name = profile_name .. "-humidity" + end + end + + if device:supports_capability(capabilities.atmosphericPressureMeasurement) then + profile_name = profile_name .. "-pressure" + end + + if device:supports_capability(capabilities.rainSensor) then + profile_name = profile_name .. "-rain" + end + + if device:supports_capability(capabilities.temperatureAlarm) then + profile_name = profile_name .. "-freeze" + end + + if device:supports_capability(capabilities.waterSensor) then + profile_name = profile_name .. "-leak" + end + + if device:supports_capability(capabilities.flowMeasurement) then + profile_name = profile_name .. "-flow" + end + + if device:supports_capability(capabilities.button) then + profile_name = profile_name .. "-button" + end + + if battery_supported == fields.battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + elseif battery_supported == fields.battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + end + + if device:supports_capability(capabilities.hardwareFault) then + profile_name = profile_name .. "-fault" + end + + local concatenated_preferences = supports_sensitivity_preferences(device) + profile_name = profile_name .. concatenated_preferences + + if device:supports_capability(capabilities.motionSensor) then + local occupancy_support = "-motion" + -- If the Occupancy Sensing Cluster’s revision is >= 5 (corresponds to Lua Libs version 13+), and any of the AIR / RAD / RFS / VIS + -- features are supported by the device, use the presenceSensor capability. Otherwise, use the motionSensor capability. Currently, + -- presenceSensor only used for devices fingerprinting to the motion-illuminance-temperature-humidity-battery profile. + if profile_name == "-illuminance-temperature-humidity-battery" and version.api >= 13 then + if #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.ACTIVE_INFRARED}) > 0 or + #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.RADAR}) > 0 or + #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.RF_SENSING}) > 0 or + #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.VISION}) > 0 then + occupancy_support = "-presence" + end + end + profile_name = occupancy_support .. profile_name + end + + -- remove leading "-" + profile_name = string.sub(profile_name, 2) + + device:try_update_metadata({profile = profile_name}) +end + +return DeviceConfiguration diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua new file mode 100644 index 0000000000..37623d57d2 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua @@ -0,0 +1,93 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local utils = require "st.utils" + +-- Include driver-side definitions when lua libs api version is < 10 +local version = require "version" +if version.api < 10 then + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" + clusters.SmokeCoAlarm = require "embedded_clusters.SmokeCoAlarm" +end + +if version.api < 11 then + clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" +end + +if version.api < 21 then + clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" +end + +local embedded_cluster_utils = {} + +local embedded_clusters_api_10 = { + [clusters.AirQuality.ID] = clusters.AirQuality, + [clusters.CarbonMonoxideConcentrationMeasurement.ID] = clusters.CarbonMonoxideConcentrationMeasurement, + [clusters.CarbonDioxideConcentrationMeasurement.ID] = clusters.CarbonDioxideConcentrationMeasurement, + [clusters.FormaldehydeConcentrationMeasurement.ID] = clusters.FormaldehydeConcentrationMeasurement, + [clusters.NitrogenDioxideConcentrationMeasurement.ID] = clusters.NitrogenDioxideConcentrationMeasurement, + [clusters.OzoneConcentrationMeasurement.ID] = clusters.OzoneConcentrationMeasurement, + [clusters.Pm1ConcentrationMeasurement.ID] = clusters.Pm1ConcentrationMeasurement, + [clusters.Pm10ConcentrationMeasurement.ID] = clusters.Pm10ConcentrationMeasurement, + [clusters.Pm25ConcentrationMeasurement.ID] = clusters.Pm25ConcentrationMeasurement, + [clusters.RadonConcentrationMeasurement.ID] = clusters.RadonConcentrationMeasurement, + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID] = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement, + [clusters.SmokeCoAlarm.ID] = clusters.SmokeCoAlarm, +} + +local embedded_clusters_api_11 = { + [clusters.BooleanStateConfiguration.ID] = clusters.BooleanStateConfiguration +} + +local embedded_clusters_api_21 = { + [clusters.SoilMeasurement.ID] = clusters.SoilMeasurement +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 10 and embedded_clusters_api_10[cluster_id] ~= nil or + version.api < 11 and embedded_clusters_api_11[cluster_id] ~= nil or + version.api < 21 and embedded_clusters_api_21[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters_api_10[cluster_id] or embedded_clusters_api_11[cluster_id] or + embedded_clusters_api_21[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end +end + +return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua new file mode 100644 index 0000000000..232e431277 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua @@ -0,0 +1,58 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +local SensorFields = {} + +SensorFields.TEMP_BOUND_RECEIVED = "__temp_bound_received" +SensorFields.TEMP_MIN = "__temp_min" +SensorFields.TEMP_MAX = "__temp_max" +SensorFields.FLOW_BOUND_RECEIVED = "__flow_bound_received" +SensorFields.FLOW_MIN = "__flow_min" +SensorFields.FLOW_MAX = "__flow_max" +SensorFields.SOIL_LIMIT_MIN = "__soil_limit_min" +SensorFields.SOIL_LIMIT_MAX = "__soil_limit_max" + +SensorFields.battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" +} + +SensorFields.BOOLEAN_DEVICE_TYPE_INFO = { + ["RAIN_SENSOR"] = { id = 0x0044, sensitivity_preference = "rainSensitivity", sensitivity_max = "rainMax" }, + ["WATER_FREEZE_DETECTOR"] = { id = 0x0041, sensitivity_preference = "freezeSensitivity", sensitivity_max = "freezeMax" }, + ["WATER_LEAK_DETECTOR"] = { id = 0x0043, sensitivity_preference = "leakSensitivity", sensitivity_max = "leakMax" }, + ["CONTACT_SENSOR"] = { id = 0x0015, sensitivity_preference = "N/A", sensitivity_max = "N/A" }, +} + +SensorFields.ORDERED_DEVICE_TYPE_INFO = { + "RAIN_SENSOR", + "WATER_FREEZE_DETECTOR", + "WATER_LEAK_DETECTOR", + "CONTACT_SENSOR" +} + +SensorFields.BOOLEAN_CAP_EVENT_MAP = { + [true] = { + ["WATER_FREEZE_DETECTOR"] = capabilities.temperatureAlarm.temperatureAlarm.freeze(), + ["WATER_LEAK_DETECTOR"] = capabilities.waterSensor.water.wet(), + ["RAIN_SENSOR"] = capabilities.rainSensor.rain.detected(), + ["CONTACT_SENSOR"] = capabilities.contactSensor.contact.closed(), + }, + [false] = { + ["WATER_FREEZE_DETECTOR"] = capabilities.temperatureAlarm.temperatureAlarm.cleared(), + ["WATER_LEAK_DETECTOR"] = capabilities.waterSensor.water.dry(), + ["RAIN_SENSOR"] = capabilities.rainSensor.rain.undetected(), + ["CONTACT_SENSOR"] = capabilities.contactSensor.contact.open(), + } +} + +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 new file mode 100644 index 0000000000..513c9f4602 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua @@ -0,0 +1,39 @@ +-- 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) +utils.SOIL_MOISTURE_MIN = 0 +utils.SOIL_MOISTURE_MAX = 100 + +function utils.get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +function utils.set_field_for_endpoint(device, field, endpoint, value, additional_params) + 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 + if element == value then + return true + end + end + return false +end + +return utils diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..aa8b046927 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +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/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..79315fc999 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local aqs_fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" +local aqs_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils" + +local AirQualitySensorAttributeHandlers = {} + + +-- [[ GENERIC CONCENTRATION MEASUREMENT CLUSTER ATTRIBUTES ]] + +function AirQualitySensorAttributeHandlers.measurement_unit_factory(capability_name) + return function(driver, device, ib, response) + device:set_field(capability_name.."_unit", ib.data.value, {persist = true}) + end +end + +function AirQualitySensorAttributeHandlers.level_value_factory(attribute) + return function(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, attribute(aqs_fields.level_strings[ib.data.value])) + end +end + +function AirQualitySensorAttributeHandlers.measured_value_factory(capability_name, attribute, target_unit) + return function(driver, device, ib, response) + if not ib.data.value then return end + local reporting_unit = device:get_field(capability_name.."_unit") or aqs_fields.unit_default[capability_name] + local converted_value = aqs_utils.convert_value_to_unit(ib.data.value, reporting_unit, target_unit, capability_name) + + if converted_value then + device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = converted_value, unit = aqs_fields.unit_strings[target_unit]})) + -- handle case where device profile supports both fineDustLevel and dustLevel + if capability_name == capabilities.fineDustSensor.NAME and device:supports_capability(capabilities.dustSensor) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.dustSensor.fineDustLevel({value = converted_value, unit = aqs_fields.unit_strings[target_unit]})) + end + end + end +end + + +-- [[ AIR QUALITY CLUSTER ATTRIBUTES ]] -- + +function AirQualitySensorAttributeHandlers.air_quality_handler(driver, device, ib, response) + local state = ib.data.value + if state == 0 then -- Unknown + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.unknown()) + elseif state == 1 then -- Good + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.good()) + elseif state == 2 then -- Fair + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.moderate()) + elseif state == 3 then -- Moderate + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.slightlyUnhealthy()) + elseif state == 4 then -- Poor + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.unhealthy()) + elseif state == 5 then -- VeryPoor + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.veryUnhealthy()) + elseif state == 6 then -- ExtremelyPoor + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.hazardous()) + end +end + + +-- [[ PRESSURE MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AirQualitySensorAttributeHandlers.pressure_measured_value_handler(driver, device, ib, response) + local pressure = st_utils.round(ib.data.value / 10.0) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.atmosphericPressureMeasurement.atmosphericPressure(pressure)) +end + +return AirQualitySensorAttributeHandlers diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua new file mode 100644 index 0000000000..a30eaed0f8 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua @@ -0,0 +1,88 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local version = require "version" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils" +local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" + +local DeviceConfiguration = {} + +function DeviceConfiguration.supported_level_measurements(device) + local measurement_caps, level_caps = {}, {} + for _, cap in ipairs(fields.CONCENTRATION_MEASUREMENT_PROFILE_ORDERING) do + local cap_id = cap.ID + local cluster = fields.CONCENTRATION_MEASUREMENT_MAP[cap][2] + -- capability describes either a HealthConcern or Measurement/Sensor + if (cap_id:match("HealthConcern$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.LEVEL_INDICATION }) + if #attr_eps > 0 then + table.insert(level_caps, cap_id) + end + elseif (cap_id:match("Measurement$") or cap_id:match("Sensor$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.NUMERIC_MEASUREMENT }) + if #attr_eps > 0 then + table.insert(measurement_caps, cap_id) + end + end + end + return measurement_caps, level_caps +end + +-- Match Modular Profile +function DeviceConfiguration.match_profile(device) + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID) + local humidity_eps = embedded_cluster_utils.get_endpoints(device, clusters.RelativeHumidityMeasurement.ID) + + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local profile_name + local MAIN_COMPONENT_IDX = 1 + local CAPABILITIES_LIST_IDX = 2 + + if #temp_eps > 0 then + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + end + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + end + + local measurement_caps, level_caps = DeviceConfiguration.supported_level_measurements(device) + + for _, cap_id in ipairs(measurement_caps) do + table.insert(main_component_capabilities, cap_id) + end + + for _, cap_id in ipairs(level_caps) do + table.insert(main_component_capabilities, cap_id) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + + if #temp_eps > 0 and #humidity_eps > 0 then + profile_name = "aqs-modular-temp-humidity" + elseif #temp_eps > 0 then + profile_name = "aqs-modular-temp" + elseif #humidity_eps > 0 then + profile_name = "aqs-modular-humidity" + else + profile_name = "aqs-modular" + end + + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. + -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. + if version.api < 15 or version.rpc < 9 then + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.airQualityHealthConcern.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) + + device:set_field(fields.SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + end +end + +return DeviceConfiguration \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua new file mode 100644 index 0000000000..538af70968 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua @@ -0,0 +1,213 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local log = require "log" +local version = require "version" +local st_utils = require "st.utils" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" + +-- Include driver-side definitions when lua libs api version is < 10 +if version.api < 10 then + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" +end + + +local AirQualitySensorFields = {} + +AirQualitySensorFields.AIR_QUALITY_SENSOR_DEVICE_TYPE_ID = 0x002C + +AirQualitySensorFields.SUPPORTED_COMPONENT_CAPABILITIES = "__supported_component_capabilities" + +AirQualitySensorFields.units_required = { + clusters.CarbonMonoxideConcentrationMeasurement, + clusters.CarbonDioxideConcentrationMeasurement, + clusters.NitrogenDioxideConcentrationMeasurement, + clusters.OzoneConcentrationMeasurement, + clusters.FormaldehydeConcentrationMeasurement, + clusters.Pm1ConcentrationMeasurement, + clusters.Pm25ConcentrationMeasurement, + clusters.Pm10ConcentrationMeasurement, + clusters.RadonConcentrationMeasurement, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement +} + +AirQualitySensorFields.supported_profiles = +{ + "aqs", + "aqs-temp-humidity-all-level-all-meas", + "aqs-temp-humidity-all-level", + "aqs-temp-humidity-all-meas", + "aqs-temp-humidity-co2-pm25-tvoc-meas", + "aqs-temp-humidity-co2-pm1-pm25-pm10-meas", + "aqs-temp-humidity-tvoc-level-pm25-meas", + "aqs-temp-humidity-tvoc-meas", +} + +AirQualitySensorFields.CONCENTRATION_MEASUREMENT_MAP = { + [capabilities.carbonMonoxideMeasurement] = {"-co", clusters.CarbonMonoxideConcentrationMeasurement, "N/A"}, + [capabilities.carbonMonoxideHealthConcern] = {"-co", clusters.CarbonMonoxideConcentrationMeasurement, capabilities.carbonMonoxideHealthConcern.supportedCarbonMonoxideValues}, + [capabilities.carbonDioxideMeasurement] = {"-co2", clusters.CarbonDioxideConcentrationMeasurement, "N/A"}, + [capabilities.carbonDioxideHealthConcern] = {"-co2", clusters.CarbonDioxideConcentrationMeasurement, capabilities.carbonDioxideHealthConcern.supportedCarbonDioxideValues}, + [capabilities.nitrogenDioxideMeasurement] = {"-no2", clusters.NitrogenDioxideConcentrationMeasurement, "N/A"}, + [capabilities.nitrogenDioxideHealthConcern] = {"-no2", clusters.NitrogenDioxideConcentrationMeasurement, capabilities.nitrogenDioxideHealthConcern.supportedNitrogenDioxideValues}, + [capabilities.ozoneMeasurement] = {"-ozone", clusters.OzoneConcentrationMeasurement, "N/A"}, + [capabilities.ozoneHealthConcern] = {"-ozone", clusters.OzoneConcentrationMeasurement, capabilities.ozoneHealthConcern.supportedOzoneValues}, + [capabilities.formaldehydeMeasurement] = {"-ch2o", clusters.FormaldehydeConcentrationMeasurement, "N/A"}, + [capabilities.formaldehydeHealthConcern] = {"-ch2o", clusters.FormaldehydeConcentrationMeasurement, capabilities.formaldehydeHealthConcern.supportedFormaldehydeValues}, + [capabilities.veryFineDustSensor] = {"-pm1", clusters.Pm1ConcentrationMeasurement, "N/A"}, + [capabilities.veryFineDustHealthConcern] = {"-pm1", clusters.Pm1ConcentrationMeasurement, capabilities.veryFineDustHealthConcern.supportedVeryFineDustValues}, + [capabilities.fineDustSensor] = {"-pm25", clusters.Pm25ConcentrationMeasurement, "N/A"}, + [capabilities.fineDustHealthConcern] = {"-pm25", clusters.Pm25ConcentrationMeasurement, capabilities.fineDustHealthConcern.supportedFineDustValues}, + [capabilities.dustSensor] = {"-pm10", clusters.Pm10ConcentrationMeasurement, "N/A"}, + [capabilities.dustHealthConcern] = {"-pm10", clusters.Pm10ConcentrationMeasurement, capabilities.dustHealthConcern.supportedDustValues}, + [capabilities.radonMeasurement] = {"-radon", clusters.RadonConcentrationMeasurement, "N/A"}, + [capabilities.radonHealthConcern] = {"-radon", clusters.RadonConcentrationMeasurement, capabilities.radonHealthConcern.supportedRadonValues}, + [capabilities.tvocMeasurement] = {"-tvoc", clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement, "N/A"}, + [capabilities.tvocHealthConcern] = {"-tvoc", clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement, capabilities.tvocHealthConcern.supportedTvocValues}, +} + + +AirQualitySensorFields.CONCENTRATION_MEASUREMENT_PROFILE_ORDERING = { + capabilities.carbonMonoxideMeasurement, + capabilities.carbonMonoxideHealthConcern, + capabilities.carbonDioxideMeasurement, + capabilities.carbonDioxideHealthConcern, + capabilities.nitrogenDioxideMeasurement, + capabilities.nitrogenDioxideHealthConcern, + capabilities.ozoneMeasurement, + capabilities.ozoneHealthConcern, + capabilities.formaldehydeMeasurement, + capabilities.formaldehydeHealthConcern, + capabilities.veryFineDustSensor, + capabilities.veryFineDustHealthConcern, + capabilities.fineDustSensor, + capabilities.fineDustHealthConcern, + capabilities.dustSensor, + capabilities.dustHealthConcern, + capabilities.radonMeasurement, + capabilities.radonHealthConcern, + capabilities.tvocMeasurement, + capabilities.tvocHealthConcern, +} + +AirQualitySensorFields.units = { + PPM = 0, + PPB = 1, + PPT = 2, + MGM3 = 3, + UGM3 = 4, + NGM3 = 5, + PM3 = 6, + BQM3 = 7, + PCIL = 0xFF -- not in matter spec +} + +local units = AirQualitySensorFields.units -- copy to remove the prefix in uses below + +AirQualitySensorFields.unit_strings = { + [units.PPM] = "ppm", + [units.PPB] = "ppb", + [units.PPT] = "ppt", + [units.MGM3] = "mg/m^3", + [units.NGM3] = "ng/m^3", + [units.UGM3] = "μg/m^3", + [units.BQM3] = "Bq/m^3", + [units.PCIL] = "pCi/L" +} + +AirQualitySensorFields.unit_default = { + [capabilities.carbonMonoxideMeasurement.NAME] = units.PPM, + [capabilities.carbonDioxideMeasurement.NAME] = units.PPM, + [capabilities.nitrogenDioxideMeasurement.NAME] = units.PPM, + [capabilities.ozoneMeasurement.NAME] = units.PPM, + [capabilities.formaldehydeMeasurement.NAME] = units.PPM, + [capabilities.veryFineDustSensor.NAME] = units.UGM3, + [capabilities.fineDustSensor.NAME] = units.UGM3, + [capabilities.dustSensor.NAME] = units.UGM3, + [capabilities.radonMeasurement.NAME] = units.BQM3, + [capabilities.tvocMeasurement.NAME] = units.PPB -- TVOC is typically within the range of 0-5500 ppb, with good to moderate values being < 660 ppb +} + +-- All ConcentrationMeasurement clusters inherit from the same base cluster definitions, +-- so CarbonMonoxideConcentrationMeasurement is used below but the same enum types exist +-- in all ConcentrationMeasurement clusters +AirQualitySensorFields.level_strings = { + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.UNKNOWN] = "unknown", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.LOW] = "good", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.MEDIUM] = "moderate", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.HIGH] = "unhealthy", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.CRITICAL] = "hazardous", +} + +AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR = 24.45 + +-- measured in g/mol +AirQualitySensorFields.molecular_weights = { + [capabilities.carbonDioxideMeasurement.NAME] = 44.010, + [capabilities.nitrogenDioxideMeasurement.NAME] = 28.014, + [capabilities.ozoneMeasurement.NAME] = 48.0, + [capabilities.formaldehydeMeasurement.NAME] = 30.031, + [capabilities.veryFineDustSensor.NAME] = "N/A", + [capabilities.fineDustSensor.NAME] = "N/A", + [capabilities.dustSensor.NAME] = "N/A", + [capabilities.radonMeasurement.NAME] = 222.018, + [capabilities.tvocMeasurement.NAME] = "N/A", +} + +local function is_valid_molecular_weight(molecular_weight) + if type(molecular_weight) ~= "number" or molecular_weight <= 0 then + log.warn_with({hub_logs = true}, string.format("unit conversion molecular weight (%s) is not a valid number", molecular_weight)) + return false + end + return true +end + +AirQualitySensorFields.unit_conversion = { + [units.PPM] = { + [units.PPM] = function(value) return st_utils.round(value) end, + [units.PPB] = function(value) return st_utils.round(value * (10^3)) end, + [units.UGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight * 10^3) / AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) end + end, + [units.MGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight) / AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) end + end,}, + [units.PPB] = { + [units.PPM] = function(value) return st_utils.round(value/(10^3)) end, + [units.PPB] = function(value) return st_utils.round(value) end, + }, + [units.PPT] = { + [units.PPM] = function(value) return st_utils.round(value/(10^6)) end + }, + [units.MGM3] = { + [units.UGM3] = function(value) return st_utils.round(value * (10^3)) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) / molecular_weight) end + end, + }, + [units.UGM3] = { + [units.UGM3] = function(value) return st_utils.round(value) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) / (molecular_weight * 10^3)) end + end, + }, + [units.NGM3] = { + [units.UGM3] = function(value) return st_utils.round(value/(10^3)) end + }, + [units.BQM3] = { + [units.PCIL] = function(value) return st_utils.round(value/37) end + }, +} + +return AirQualitySensorFields diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/legacy_device_configuration.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/legacy_device_configuration.lua new file mode 100644 index 0000000000..bc45021341 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/legacy_device_configuration.lua @@ -0,0 +1,89 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local sensor_utils = require "sensor_utils.utils" +local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils" +local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" + +local LegacyDeviceConfiguration = {} + +function LegacyDeviceConfiguration.create_level_measurement_profile(device) + local meas_name, level_name = "", "" + for _, cap in ipairs(fields.CONCENTRATION_MEASUREMENT_PROFILE_ORDERING) do + local cap_id = cap.ID + local cluster = fields.CONCENTRATION_MEASUREMENT_MAP[cap][2] + -- capability describes either a HealthConcern or Measurement/Sensor + if (cap_id:match("HealthConcern$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.LEVEL_INDICATION }) + if #attr_eps > 0 then + level_name = level_name .. fields.CONCENTRATION_MEASUREMENT_MAP[cap][1] + end + elseif (cap_id:match("Measurement$") or cap_id:match("Sensor$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.NUMERIC_MEASUREMENT }) + if #attr_eps > 0 then + meas_name = meas_name .. fields.CONCENTRATION_MEASUREMENT_MAP[cap][1] + end + end + end + return meas_name, level_name +end + +-- MATCH STATIC PROFILE +function LegacyDeviceConfiguration.match_profile(device) + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID) + local humidity_eps = embedded_cluster_utils.get_endpoints(device, clusters.RelativeHumidityMeasurement.ID) + + local profile_name = "aqs" + + if #temp_eps > 0 then + profile_name = profile_name .. "-temp" + end + if #humidity_eps > 0 then + profile_name = profile_name .. "-humidity" + end + + local meas_name, level_name = LegacyDeviceConfiguration.create_level_measurement_profile(device) + + -- If all endpoints are supported, use '-all' in the profile name so that it + -- remains under the profile name character limit + if level_name == "-co-co2-no2-ozone-ch2o-pm1-pm25-pm10-radon-tvoc" then + level_name = "-all" + end + if level_name ~= "" then + profile_name = profile_name .. level_name .. "-level" + end + + -- If all endpoints are supported, use '-all' in the profile name so that it + -- remains under the profile name character limit + if meas_name == "-co-co2-no2-ozone-ch2o-pm1-pm25-pm10-radon-tvoc" then + meas_name = "-all" + end + if meas_name ~= "" then + profile_name = profile_name .. meas_name .. "-meas" + end + + if not sensor_utils.tbl_contains(fields.supported_profiles, profile_name) then + device.log.warn_with({hub_logs=true}, string.format("No matching profile for device. Tried to use profile %s", profile_name)) + + local function meas_find(sub_name) + return string.match(meas_name, sub_name) ~= nil + end + + -- try to best match to existing profiles + -- these checks, meas_find("co%-") and meas_find("co$"), match the string to co and NOT co2. + if meas_find("co%-") or meas_find("co$") or meas_find("no2") or meas_find("ozone") or meas_find("ch2o") or + meas_find("pm1") or meas_find("pm10") or meas_find("radon") then + profile_name = "aqs-temp-humidity-all-meas" + elseif #humidity_eps > 0 or #temp_eps > 0 or meas_find("co2") or meas_find("pm25") or meas_find("tvoc") then + profile_name = "aqs-temp-humidity-co2-pm25-tvoc-meas" + else + -- device only supports air quality at this point + profile_name = "aqs" + end + end + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +return LegacyDeviceConfiguration \ No newline at end of file diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua new file mode 100644 index 0000000000..2e8d9e487b --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua @@ -0,0 +1,137 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local log = require "log" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils" +local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" + + +local AirQualitySensorUtils = {} + +function AirQualitySensorUtils.supports_capability_by_id_modular(device, capability, component) + if not device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then + device.log.warn_with({hub_logs = true}, "Device has overriden supports_capability_by_id, but does not have supported capabilities set.") + return false + end + for _, component_capabilities in ipairs(device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES)) do + local comp_id = component_capabilities[1] + local capability_ids = component_capabilities[2] + if (component == nil) or (component == comp_id) then + for _, cap in ipairs(capability_ids) do + if cap == capability then + return true + end + end + end + end + return false +end + +local function get_supported_health_concern_values_for_air_quality(device) + local health_concern_datatype = capabilities.airQualityHealthConcern.airQualityHealthConcern + local supported_values = {health_concern_datatype.unknown.NAME, health_concern_datatype.good.NAME, health_concern_datatype.unhealthy.NAME} + if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.FAIR }) > 0 then + table.insert(supported_values, health_concern_datatype.moderate.NAME) + end + if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.MODERATE }) > 0 then + table.insert(supported_values, health_concern_datatype.slightlyUnhealthy.NAME) + end + if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.VERY_POOR }) > 0 then + table.insert(supported_values, health_concern_datatype.veryUnhealthy.NAME) + end + if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.EXTREMELY_POOR }) > 0 then + table.insert(supported_values, health_concern_datatype.hazardous.NAME) + end + return supported_values +end + +local function get_supported_health_concern_values_for_concentration_cluster(device, cluster) + -- note: health_concern_datatype is generic since all the healthConcern capabilities' datatypes are equivalent to those in airQualityHealthConcern + local health_concern_datatype = capabilities.airQualityHealthConcern.airQualityHealthConcern + local supported_values = {health_concern_datatype.unknown.NAME, health_concern_datatype.good.NAME, health_concern_datatype.unhealthy.NAME} + if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.MEDIUM_LEVEL }) > 0 then + table.insert(supported_values, health_concern_datatype.moderate.NAME) + end + if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.CRITICAL_LEVEL }) > 0 then + table.insert(supported_values, health_concern_datatype.hazardous.NAME) + end + return supported_values +end + +function AirQualitySensorUtils.set_supported_health_concern_values(device) + -- handle AQ Health Concern, since this is a mandatory capability + local supported_aqs_values = get_supported_health_concern_values_for_air_quality(device) + local aqs_ep_ids = embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID) or {} + device:emit_event_for_endpoint(aqs_ep_ids[1], capabilities.airQualityHealthConcern.supportedAirQualityValues(supported_aqs_values, { visibility = { displayed = false }})) + + for _, capability in ipairs(fields.CONCENTRATION_MEASUREMENT_PROFILE_ORDERING) do + -- all of these capabilities are optional, and capabilities stored in this field are for either a HealthConcern or a Measurement/Sensor + if device:supports_capability_by_id(capability.ID) and capability.ID:match("HealthConcern$") then + local cluster_info = fields.CONCENTRATION_MEASUREMENT_MAP[capability][2] + local supported_values_setter = fields.CONCENTRATION_MEASUREMENT_MAP[capability][3] + local supported_values = get_supported_health_concern_values_for_concentration_cluster(device, cluster_info) + local cluster_ep_ids = embedded_cluster_utils.get_endpoints(device, cluster_info.ID, { feature_bitmap = cluster_info.types.Feature.LEVEL_INDICATION }) or {} -- cluster associated with the supported capability + device:emit_event_for_endpoint(cluster_ep_ids[1], supported_values_setter(supported_values, { visibility = { displayed = false }})) + end + end +end + +function AirQualitySensorUtils.convert_value_to_unit(value, from_unit, to_unit, capability_name) + local conversion_function = fields.unit_conversion[from_unit] and fields.unit_conversion[from_unit][to_unit] + if not conversion_function then + log.info_with( {hub_logs = true} , string.format("Unsupported unit conversion from %s to %s", fields.unit_strings[from_unit], fields.unit_strings[to_unit])) + return + elseif type(value) ~= "number" then + log.info_with( {hub_logs = true} , string.format("unit conversion value (%s) is not a number", value)) + return + end + return conversion_function(value, fields.molecular_weights[capability_name]) +end + +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function AirQualitySensorUtils.deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not AirQualitySensorUtils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return AirQualitySensorUtils.deep_equals(mt_a, mt_b, opts, seen) +end + +return AirQualitySensorUtils diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/can_handle.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/can_handle.lua new file mode 100644 index 0000000000..9c1a1ab762 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_air_quality_sensor(opts, driver, device) + local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == fields.AIR_QUALITY_SENSOR_DEVICE_TYPE_ID then + return true, require("sub_drivers.air_quality_sensor") + end + end + end + + return false +end + +return is_matter_air_quality_sensor diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua new file mode 100644 index 0000000000..383af643a6 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua @@ -0,0 +1,154 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local version = require "version" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local aqs_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils" +local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" +local attribute_handlers = require "sub_drivers.air_quality_sensor.air_quality_sensor_handlers.attribute_handlers" + +-- Include driver-side definitions when lua libs api version is < 10 +if version.api < 10 then + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" +end + +-- AIR QUALITY SENSOR LIFECYCLE HANDLERS -- + +local AirQualitySensorLifecycleHandlers = {} + +function AirQualitySensorLifecycleHandlers.do_configure(driver, device) + -- we have to read the unit before reports of values will do anything + for _, cluster in ipairs(fields.units_required) do + device:send(cluster.attributes.MeasurementUnit:read(device)) + end + if version.api >= 14 and version.rpc >= 8 then + local modular_device_cfg = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.device_configuration" + modular_device_cfg.match_profile(device) + else + local legacy_device_cfg = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.legacy_device_configuration" + legacy_device_cfg.match_profile(device) + end +end + +function AirQualitySensorLifecycleHandlers.driver_switched(driver, device) + -- we have to read the unit before reports of values will do anything + for _, cluster in ipairs(fields.units_required) do + device:send(cluster.attributes.MeasurementUnit:read(device)) + end + if version.api >= 14 and version.rpc >= 8 then + local modular_device_cfg = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.device_configuration" + modular_device_cfg.match_profile(device) + else + local legacy_device_cfg = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.legacy_device_configuration" + legacy_device_cfg.match_profile(device) + end + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +function AirQualitySensorLifecycleHandlers.device_init(driver, device) + if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) and (version.api < 15 or version.rpc < 9) then + -- assume that device is using a modular profile on 0.57 FW, override supports_capability_by_id + -- library function to utilize optional capabilities + device:extend_device("supports_capability_by_id", aqs_utils.supports_capability_by_id_modular) + end + aqs_utils.set_supported_health_concern_values(device) + device:subscribe() +end + +function AirQualitySensorLifecycleHandlers.info_changed(driver, device, event, args) + if not aqs_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then + if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then + --re-up subscription with new capabilities using the modular supports_capability override + device:extend_device("supports_capability_by_id", aqs_utils.supports_capability_by_id_modular) + end + aqs_utils.set_supported_health_concern_values(device) + device:subscribe() + end +end + + +-- SUBDRIVER TEMPLATE -- + +local matter_air_quality_sensor_handler = { + NAME = "matter-air-quality-sensor", + lifecycle_handlers = { + doConfigure = AirQualitySensorLifecycleHandlers.do_configure, + driverSwitched = AirQualitySensorLifecycleHandlers.driver_switched, + infoChanged = AirQualitySensorLifecycleHandlers.info_changed, + init = AirQualitySensorLifecycleHandlers.device_init, + }, + matter_handlers = { + attr = { + [clusters.AirQuality.ID] = { + [clusters.AirQuality.attributes.AirQuality.ID] = attribute_handlers.air_quality_handler, + }, + [clusters.CarbonDioxideConcentrationMeasurement.ID] = { + [clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.carbonDioxideMeasurement.NAME, capabilities.carbonDioxideMeasurement.carbonDioxide, fields.units.PPM), + [clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.carbonDioxideMeasurement.NAME), + [clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern), + }, + [clusters.CarbonMonoxideConcentrationMeasurement.ID] = { + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.carbonMonoxideMeasurement.NAME, capabilities.carbonMonoxideMeasurement.carbonMonoxideLevel, fields.units.PPM), + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.carbonMonoxideMeasurement.NAME), + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.carbonMonoxideHealthConcern.carbonMonoxideHealthConcern), + }, + [clusters.FormaldehydeConcentrationMeasurement.ID] = { + [clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.formaldehydeMeasurement.NAME, capabilities.formaldehydeMeasurement.formaldehydeLevel, fields.units.PPM), + [clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.formaldehydeMeasurement.NAME), + [clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.formaldehydeHealthConcern.formaldehydeHealthConcern), + }, + [clusters.NitrogenDioxideConcentrationMeasurement.ID] = { + [clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.nitrogenDioxideMeasurement.NAME, capabilities.nitrogenDioxideMeasurement.nitrogenDioxide, fields.units.PPM), + [clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.nitrogenDioxideMeasurement.NAME), + [clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.nitrogenDioxideHealthConcern.nitrogenDioxideHealthConcern) + }, + [clusters.OzoneConcentrationMeasurement.ID] = { + [clusters.OzoneConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.ozoneMeasurement.NAME, capabilities.ozoneMeasurement.ozone, fields.units.PPM), + [clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.ozoneMeasurement.NAME), + [clusters.OzoneConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.ozoneHealthConcern.ozoneHealthConcern) + }, + [clusters.Pm1ConcentrationMeasurement.ID] = { + [clusters.Pm1ConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.veryFineDustSensor.NAME, capabilities.veryFineDustSensor.veryFineDustLevel, fields.units.UGM3), + [clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.veryFineDustSensor.NAME), + [clusters.Pm1ConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern), + }, + [clusters.Pm10ConcentrationMeasurement.ID] = { + [clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.dustSensor.NAME, capabilities.dustSensor.dustLevel, fields.units.UGM3), + [clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.dustSensor.NAME), + [clusters.Pm10ConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.dustHealthConcern.dustHealthConcern), + }, + [clusters.Pm25ConcentrationMeasurement.ID] = { + [clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.fineDustSensor.NAME, capabilities.fineDustSensor.fineDustLevel, fields.units.UGM3), + [clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.fineDustSensor.NAME), + [clusters.Pm25ConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.fineDustHealthConcern.fineDustHealthConcern), + }, + [clusters.PressureMeasurement.ID] = { + [clusters.PressureMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.pressure_measured_value_handler + }, + [clusters.RadonConcentrationMeasurement.ID] = { + [clusters.RadonConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.radonMeasurement.NAME, capabilities.radonMeasurement.radonLevel, fields.units.PCIL), + [clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.radonMeasurement.NAME), + [clusters.RadonConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.radonHealthConcern.radonHealthConcern) + }, + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID] = { + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.measured_value_factory(capabilities.tvocMeasurement.NAME, capabilities.tvocMeasurement.tvocLevel, fields.units.PPB), + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.measurement_unit_factory(capabilities.tvocMeasurement.NAME), + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.level_value_factory(capabilities.tvocHealthConcern.tvocHealthConcern) + } + } + }, + can_handle = require("sub_drivers.air_quality_sensor.can_handle") +} + +return matter_air_quality_sensor_handler 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/sub_drivers/bosch_button_contact/can_handle.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/bosch_button_contact/can_handle.lua new file mode 100644 index 0000000000..9c679f3607 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/bosch_button_contact/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_bosch_button_contact(opts, driver, device) + local device_lib = require "st.device" + local BOSCH_VENDOR_ID = 0x1209 + local BOSCH_PRODUCT_ID = 0x3015 + if device.network_type == device_lib.NETWORK_TYPE_MATTER and + device.manufacturer_info.vendor_id == BOSCH_VENDOR_ID and + device.manufacturer_info.product_id == BOSCH_PRODUCT_ID then + return true, require("sub_drivers.bosch_button_contact") + end + return false +end + +return is_bosch_button_contact diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/bosch_button_contact/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/bosch_button_contact/init.lua new file mode 100644 index 0000000000..cbbd71cf53 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/bosch_button_contact/init.lua @@ -0,0 +1,138 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local lua_socket = require "socket" +local log = require "log" + +local START_BUTTON_PRESS = "__start_button_press" + +local function get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +local function set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +local function init_press(device, endpoint) + set_field_for_endpoint(device, START_BUTTON_PRESS, endpoint, lua_socket.gettime(), {persist = false}) +end + +-- Some switches will send a MultiPressComplete event as part of a long press sequence. Normally the driver will create a +-- button capability event on receipt of MultiPressComplete, but in this case that would result in an extra event because +-- the "held" capability event is generated when the LongPress event is received. The IGNORE_NEXT_MPC flag is used +-- to tell the driver to ignore MultiPressComplete if it is received after a long press to avoid this extra event. +local IGNORE_NEXT_MPC = "__ignore_next_mpc" +-- These are essentially storing the supported features of a given endpoint +-- TODO: add an is_feature_supported_for_endpoint function to matter.device that takes an endpoint +local EMULATE_HELD = "__emulate_held" -- for non-MSR (MomentarySwitchRelease) devices we can emulate this on the software side +local SUPPORTS_MULTI_PRESS = "__multi_button" -- for MSM devices (MomentarySwitchMultiPress), create an event on receipt of MultiPressComplete +local INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) + +local function initial_press_event_handler(driver, device, ib, response) + if get_field_for_endpoint(device, SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + -- Receipt of an InitialPress event means we do not want to ignore the next MultiPressComplete event + -- or else we would potentially not create the expected button capability event + set_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id, nil) + elseif get_field_for_endpoint(device, INITIAL_PRESS_ONLY, ib.endpoint_id) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) + elseif get_field_for_endpoint(device, EMULATE_HELD, ib.endpoint_id) then + -- if our button doesn't differentiate between short and long holds, do it in code by keeping track of the press down time + init_press(device, ib.endpoint_id) + end +end + +local function long_press_event_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.held({state_change = true})) + if get_field_for_endpoint(device, SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + -- Ignore the next MultiPressComplete event if it is sent as part of this "long press" event sequence + set_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id, true) + end +end + +--helper function to create list of multi press values +local function create_multi_press_values_list(size, supportsHeld) + local list = {"pushed", "double"} + if supportsHeld then table.insert(list, "held") end + -- add multi press values of 3 or greater to the list + for i=3, size do + table.insert(list, string.format("pushed_%dx", i)) + end + return list +end + +local function tbl_contains(array, value) + for _, element in ipairs(array) do + if element == value then + return true + end + end + return false +end + +local function device_init (driver, device) + device:subscribe() + device:send(clusters.Switch.attributes.MultiPressMax:read(device)) +end + +local function max_press_handler(driver, device, ib, response) + local max = ib.data.value or 1 --get max number of presses + device.log.debug("Device supports "..max.." presses") + -- capability only supports up to 6 presses + if max > 6 then + log.info("Device supports more than 6 presses") + max = 6 + end + local MSL = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) + local supportsHeld = tbl_contains(MSL, ib.endpoint_id) + local values = create_multi_press_values_list(max, supportsHeld) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.supportedButtonValues(values, {visibility = {displayed = false}})) +end + +local function multi_press_complete_event_handler(driver, device, ib, response) + -- in the case of multiple button presses + -- emit number of times, multiple presses have been completed + if ib.data and not get_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id) then + local press_value = ib.data.elements.total_number_of_presses_counted.value + --capability only supports up to 6 presses + if press_value < 7 then + local button_event = capabilities.button.button.pushed({state_change = true}) + if press_value == 2 then + button_event = capabilities.button.button.double({state_change = true}) + elseif press_value > 2 then + button_event = capabilities.button.button(string.format("pushed_%dx", press_value), {state_change = true}) + end + + device:emit_event_for_endpoint(ib.endpoint_id, button_event) + else + log.info(string.format("Number of presses (%d) not supported by capability", press_value)) + end + end + set_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id, nil) +end + +local Bosch_Button_Contact_Sensor = { + NAME = "Bosch_Button_Contact_Sensor", + lifecycle_handlers = { + init = device_init + }, + matter_handlers = { + attr = { + [clusters.Switch.ID] = { + [clusters.Switch.attributes.MultiPressMax.ID] = max_press_handler + } + }, + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = initial_press_event_handler, + [clusters.Switch.events.LongPress.ID] = long_press_event_handler, + [clusters.Switch.events.MultiPressComplete.ID] = multi_press_complete_event_handler + } + }, + }, + can_handle = require("sub_drivers.bosch_button_contact.can_handle"), +} + +return Bosch_Button_Contact_Sensor diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/can_handle.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/can_handle.lua new file mode 100644 index 0000000000..31bd7e025b --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_smoke_co_alarm(opts, driver, device) + local SMOKE_CO_ALARM_DEVICE_TYPE_ID = 0x0076 + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == SMOKE_CO_ALARM_DEVICE_TYPE_ID then + return true, require("sub_drivers.smoke_co_alarm") + end + end + end + + return false +end + +return is_matter_smoke_co_alarm diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/init.lua new file mode 100644 index 0000000000..8f8653b936 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/smoke_co_alarm/init.lua @@ -0,0 +1,265 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local version = require "version" +local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils" +local sensor_utils = require "sensor_utils.utils" +local fields = require "sensor_utils.fields" + +if version.api < 10 then + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.SmokeCoAlarm = require "embedded_clusters.SmokeCoAlarm" +end + + +-- SUBDRIVER UTILS -- + +local smoke_co_alarm_utils = {} + +local CARBON_MONOXIDE_MEASUREMENT_UNIT = "CarbonMonoxideConcentrationMeasurement_unit" + +local HardwareFaultAlert = "__HardwareFaultAlert" +local BatteryAlert = "__BatteryAlert" +local BatteryLevel = "__BatteryLevel" + + +local supported_profiles = +{ + "co", + "co-battery", + "co-comeas", + "co-comeas-battery", + "co-comeas-colevel-battery", + "smoke", + "smoke-battery", + "smoke-temp-humidity-battery", + "smoke-co-comeas", + "smoke-co-comeas-battery", + "smoke-co-temp-humidity-comeas", + "smoke-co-temp-humidity-comeas-battery" +} + +function smoke_co_alarm_utils.match_profile(device, battery_supported) + local smoke_eps = embedded_cluster_utils.get_endpoints(device, clusters.SmokeCoAlarm.ID, {feature_bitmap = clusters.SmokeCoAlarm.types.Feature.SMOKE_ALARM}) + local co_eps = embedded_cluster_utils.get_endpoints(device, clusters.SmokeCoAlarm.ID, {feature_bitmap = clusters.SmokeCoAlarm.types.Feature.CO_ALARM}) + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID) + local humidity_eps = embedded_cluster_utils.get_endpoints(device, clusters.RelativeHumidityMeasurement.ID) + local co_meas_eps = embedded_cluster_utils.get_endpoints(device, clusters.CarbonMonoxideConcentrationMeasurement.ID, {feature_bitmap = clusters.CarbonMonoxideConcentrationMeasurement.types.Feature.NUMERIC_MEASUREMENT}) + local co_level_eps = embedded_cluster_utils.get_endpoints(device, clusters.CarbonMonoxideConcentrationMeasurement.ID, {feature_bitmap = clusters.CarbonMonoxideConcentrationMeasurement.types.Feature.LEVEL_INDICATION}) + + local profile_name = "" + + -- battery and hardware fault are mandatory + if #smoke_eps > 0 then + profile_name = profile_name .. "-smoke" + end + if #co_eps > 0 then + profile_name = profile_name .. "-co" + end + if #temp_eps > 0 then + profile_name = profile_name .. "-temp" + end + if #humidity_eps > 0 then + profile_name = profile_name .. "-humidity" + end + if #co_meas_eps > 0 then + profile_name = profile_name .. "-comeas" + end + if #co_level_eps > 0 then + profile_name = profile_name .. "-colevel" + end + if battery_supported == fields.battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + end + + -- remove leading "-" + profile_name = string.sub(profile_name, 2) + + if sensor_utils.tbl_contains(supported_profiles, profile_name) then + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + else + device.log.warn_with({hub_logs=true}, string.format("No matching profile for device. Tried to use profile %s.", profile_name)) + profile_name = "" + if #smoke_eps > 0 and #co_eps > 0 then + profile_name = "smoke-co" + elseif #smoke_eps > 0 and #co_eps == 0 then + profile_name = "smoke" + elseif #co_eps > 0 and #smoke_eps == 0 then + profile_name = "co" + end + device.log.info_with({hub_logs=true}, string.format("Using generic device profile %s.", profile_name)) + end + device:try_update_metadata({profile = profile_name}) +end + + +-- SMOKE CO ALARM LIFECYCLE HANDLERS -- + +local SmokeLifeycleHandlers = {} + +function SmokeLifeycleHandlers.device_init(driver, device) + device:subscribe() +end + +function SmokeLifeycleHandlers.do_configure(driver, device) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps > 0 then + device:send(clusters.PowerSource.attributes.AttributeList:read()) + else + smoke_co_alarm_utils.match_profile(device, fields.battery_support.NO_BATTERY) + end +end + +function SmokeLifeycleHandlers.info_changed(self, device, event, args) + if device.preferences then + if device.preferences["certifiedpreferences.smokeSensorSensitivity"] ~= args.old_st_store.preferences["certifiedpreferences.smokeSensorSensitivity"] then + local eps = embedded_cluster_utils.get_endpoints(device, clusters.SmokeCoAlarm.ID) + if #eps > 0 then + local smokeSensorSensitivity = device.preferences["certifiedpreferences.smokeSensorSensitivity"] + if smokeSensorSensitivity == "0" then -- High + device:send(clusters.SmokeCoAlarm.attributes.SmokeSensitivityLevel:write(device, eps[1], clusters.SmokeCoAlarm.types.SensitivityEnum.HIGH)) + elseif smokeSensorSensitivity == "1" then -- Medium + device:send(clusters.SmokeCoAlarm.attributes.SmokeSensitivityLevel:write(device, eps[1], clusters.SmokeCoAlarm.types.SensitivityEnum.STANDARD)) + elseif smokeSensorSensitivity == "2" then -- Low + device:send(clusters.SmokeCoAlarm.attributes.SmokeSensitivityLevel:write(device, eps[1], clusters.SmokeCoAlarm.types.SensitivityEnum.LOW)) + end + end + end + end + + -- resubscribe to new attributes as needed if a profile switch occured + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() + end +end + + +-- CLUSTER ATTRIBUTE HANDLERS -- + +local sub_driver_handlers = {} + +function sub_driver_handlers.smoke_co_alarm_state_factory(zeroEvent, nonZeroEvent) + return function(driver, device, ib, response) + if ib.data.value == 0 and zeroEvent ~= nil then + device:emit_event_for_endpoint(ib.endpoint_id, zeroEvent) + elseif nonZeroEvent ~= nil then + device:emit_event_for_endpoint(ib.endpoint_id, nonZeroEvent) + end + end +end + +function sub_driver_handlers.test_in_progress_handler(driver, device, ib, response) + if device:supports_capability(capabilities.smokeDetector) then + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.smokeDetector.smoke.tested()) + else + device:send(clusters.SmokeCoAlarm.attributes.SmokeState:read(device)) + end + end + if device:supports_capability(capabilities.carbonMonoxideDetector) then + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.carbonMonoxideDetector.carbonMonoxide.tested()) + else + device:send(clusters.SmokeCoAlarm.attributes.COState:read(device)) + end + end +end + +function sub_driver_handlers.carbon_monoxide_measured_value_handler(driver, device, ib, response) + local value = ib.data.value + local unit = device:get_field(CARBON_MONOXIDE_MEASUREMENT_UNIT) + if unit == clusters.CarbonMonoxideConcentrationMeasurement.types.MeasurementUnitEnum.PPB then + value = value / 1000 + elseif unit == clusters.CarbonMonoxideConcentrationMeasurement.types.MeasurementUnitEnum.PPT then + value = value / 1000000 + end + value = math.floor(value) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.carbonMonoxideMeasurement.carbonMonoxideLevel({value = value, unit = "ppm"})) +end + +function sub_driver_handlers.carbon_monoxide_measurement_unit_handler(driver, device, ib, response) + local unit = ib.data.value + device:set_field(CARBON_MONOXIDE_MEASUREMENT_UNIT, unit, { persist = true }) +end + +function sub_driver_handlers.hardware_fault_capability_handler(device) + local batLevel, batAlert = device:get_field(BatteryLevel), device:get_field(BatteryAlert) + if device:get_field(HardwareFaultAlert) == true or (batLevel and batAlert and (batAlert > batLevel)) then + device:emit_event(capabilities.hardwareFault.hardwareFault.detected()) + else + device:emit_event(capabilities.hardwareFault.hardwareFault.clear()) + end +end + +function sub_driver_handlers.hardware_fault_alert_handler(driver, device, ib, response) + device:set_field(HardwareFaultAlert, ib.data.value, {persist = true}) + sub_driver_handlers.hardware_fault_capability_handler(device) +end + +function sub_driver_handlers.battery_alert_handler(driver, device, ib, response) + device:set_field(BatteryAlert, ib.data.value, {persist = true}) + sub_driver_handlers.hardware_fault_capability_handler(device) +end + +function sub_driver_handlers.power_source_attribute_list_handler(driver, device, ib, response) + for _, attr in ipairs(ib.data.elements) do + -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or + -- BatChargeLevel (Attribute ID 0x0E) is present. + if attr.value == 0x0C then + smoke_co_alarm_utils.match_profile(device, fields.battery_support.BATTERY_PERCENTAGE) + return + elseif attr.value == 0x0E then + smoke_co_alarm_utils.match_profile(device, fields.battery_support.BATTERY_LEVEL) + return + end + end +end + +function sub_driver_handlers.bat_charge_level_handler(driver, device, ib, response) + device:set_field(BatteryLevel, ib.data.value, {persist = true}) -- value used in hardware_fault_capability_handler + if device:supports_capability(capabilities.batteryLevel) then -- check required since attribute is subscribed to even without batteryLevel support, to set the field above + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end + end +end + + +-- SUBDRIVER TEMPLATE -- + +local matter_smoke_co_alarm_handler = { + NAME = "matter-smoke-co-alarm", + lifecycle_handlers = { + init = SmokeLifeycleHandlers.device_init, + infoChanged = SmokeLifeycleHandlers.info_changed, + doConfigure = SmokeLifeycleHandlers.do_configure + }, + matter_handlers = { + attr = { + [clusters.CarbonMonoxideConcentrationMeasurement.ID] = { + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue.ID] = sub_driver_handlers.carbon_monoxide_measured_value_handler, + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit.ID] = sub_driver_handlers.carbon_monoxide_measurement_unit_handler, + }, + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.AttributeList.ID] = sub_driver_handlers.power_source_attribute_list_handler, + [clusters.PowerSource.attributes.BatChargeLevel.ID] = sub_driver_handlers.bat_charge_level_handler, + }, + [clusters.SmokeCoAlarm.ID] = { + [clusters.SmokeCoAlarm.attributes.BatteryAlert.ID] = sub_driver_handlers.battery_alert_handler, + [clusters.SmokeCoAlarm.attributes.COState.ID] = sub_driver_handlers.smoke_co_alarm_state_factory(capabilities.carbonMonoxideDetector.carbonMonoxide.clear(), capabilities.carbonMonoxideDetector.carbonMonoxide.detected()), + [clusters.SmokeCoAlarm.attributes.HardwareFaultAlert.ID] = sub_driver_handlers.hardware_fault_alert_handler, + [clusters.SmokeCoAlarm.attributes.SmokeState.ID] = sub_driver_handlers.smoke_co_alarm_state_factory(capabilities.smokeDetector.smoke.clear(), capabilities.smokeDetector.smoke.detected()), + [clusters.SmokeCoAlarm.attributes.TestInProgress.ID] = sub_driver_handlers.test_in_progress_handler, + }, + }, + }, + can_handle = require("sub_drivers.smoke_co_alarm.can_handle") +} + +return matter_smoke_co_alarm_handler diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua new file mode 100644 index 0000000000..8d3bd462a5 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua @@ -0,0 +1,836 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local SinglePrecisionFloat = require "st.matter.data_types.SinglePrecisionFloat" + +local clusters = require "st.matter.clusters" + +local version = require "version" + +-- Include driver-side definitions when lua libs api version is < 10 +if version.api < 10 then + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.SmokeCoAlarm = require "embedded_clusters.SmokeCoAlarm" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs-temp-humidity-all-level-all-meas.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonMonoxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.OzoneConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.Pm1ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.RadonConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local mock_device_common = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs-temp-humidity-co2-pm25-tvoc-meas.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local mock_device_level = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs-temp-humidity-all-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonMonoxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.OzoneConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.Pm1ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.RadonConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local mock_device_co = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.CarbonMonoxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local mock_device_co2 = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local mock_device_tvoc = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs-temp-humidity-tvoc-meas.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +-- create test_init functions +local function initialize_mock_device(generic_mock_device, generic_subscribed_attributes, expected_supported_values_setters) + test.mock_device.add_test_device(generic_mock_device) + test.socket.capability:__expect_send(generic_mock_device:generate_test_message("main", capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy",}, {visibility={displayed=false}}))) + if expected_supported_values_setters ~= nil then + expected_supported_values_setters() + end + local subscribe_request = nil + for _, attributes in pairs(generic_subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(generic_mock_device) + else + subscribe_request:merge(attribute:subscribe(generic_mock_device)) + end + end + end + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) +end + +-- TODO add tests for configuration using modular profiles +test.set_rpc_version(7) + +local function test_init() + local subscribed_attributes = { + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.carbonMonoxideMeasurement.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.carbonMonoxideHealthConcern.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.carbonDioxideMeasurement.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.carbonDioxideHealthConcern.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.nitrogenDioxideMeasurement.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.ozoneMeasurement.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.MeasuredValue, + clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.ozoneHealthConcern.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.veryFineDustSensor.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.veryFineDustHealthConcern.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.radonMeasurement.ID] = { + clusters.RadonConcentrationMeasurement.attributes.MeasuredValue, + clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.radonHealthConcern.ID] = { + clusters.RadonConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue, + }, + } + local expected_supported_values_setters = function() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.carbonMonoxideHealthConcern.supportedCarbonMonoxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.carbonDioxideHealthConcern.supportedCarbonDioxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.nitrogenDioxideHealthConcern.supportedNitrogenDioxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.ozoneHealthConcern.supportedOzoneValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.formaldehydeHealthConcern.supportedFormaldehydeValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.veryFineDustHealthConcern.supportedVeryFineDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fineDustHealthConcern.supportedFineDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.dustHealthConcern.supportedDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.radonHealthConcern.supportedRadonValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tvocHealthConcern.supportedTvocValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + end + initialize_mock_device(mock_device, subscribed_attributes, expected_supported_values_setters) +end +test.set_test_init_function(test_init) + +local function test_init_common() + local subscribed_attributes = { + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.carbonDioxideMeasurement.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + } + initialize_mock_device(mock_device_common, subscribed_attributes) +end + +local function test_init_level() + local subscribed_attributes = { + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.carbonMonoxideHealthConcern.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.carbonDioxideHealthConcern.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.ozoneHealthConcern.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.veryFineDustHealthConcern.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.radonHealthConcern.ID] = { + clusters.RadonConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue, + } + } + local expected_supported_values_setters = function() + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.carbonMonoxideHealthConcern.supportedCarbonMonoxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.carbonDioxideHealthConcern.supportedCarbonDioxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.nitrogenDioxideHealthConcern.supportedNitrogenDioxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.ozoneHealthConcern.supportedOzoneValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.formaldehydeHealthConcern.supportedFormaldehydeValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.veryFineDustHealthConcern.supportedVeryFineDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.fineDustHealthConcern.supportedFineDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.dustHealthConcern.supportedDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.radonHealthConcern.supportedRadonValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_level:generate_test_message("main", capabilities.tvocHealthConcern.supportedTvocValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + end + initialize_mock_device(mock_device_level, subscribed_attributes, expected_supported_values_setters) +end + +local function test_init_tvoc() + local subscribed_attributes = { + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + } + initialize_mock_device(mock_device_tvoc, subscribed_attributes) +end + +local function test_init_co_co2() + local attr_co = { + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + } + + local attr_co2 = { + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + } + } + + initialize_mock_device(mock_device_co, attr_co) + initialize_mock_device(mock_device_co2, attr_co2) +end + + +-- run the profile configuration tests +local function test_aqs_device_type_do_configure(generic_mock_device, expected_profile) + test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit:read()}) + generic_mock_device:expect_metadata_update({ profile = expected_profile }) + generic_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.register_coroutine_test( + "Configure should read units from device and profile change as needed", + function() + test_aqs_device_type_do_configure(mock_device, "aqs-temp-humidity-all-level-all-meas") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should read units from device and profile change to common clusters profile if applicable", + function() + test_aqs_device_type_do_configure(mock_device_common, "aqs-temp-humidity-co2-pm25-tvoc-meas") + end, + { + test_init = test_init_common, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should read units from device and profile change as needed", + function() + test_aqs_device_type_do_configure(mock_device_level, "aqs-temp-humidity-all-level") + end, + { + test_init = test_init_level, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should not catch co2, only co in the first check", + function() + test_aqs_device_type_do_configure(mock_device_co, "aqs-temp-humidity-all-meas") + test_aqs_device_type_do_configure(mock_device_co2, "aqs-temp-humidity-co2-pm25-tvoc-meas") + end, + { + test_init = test_init_co_co2, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device should profile to aqs-temp-humidity-tvoc-meas", + function() + test_aqs_device_type_do_configure(mock_device_tvoc, "aqs-temp-humidity-tvoc-meas") + end, + { + test_init = test_init_tvoc, + min_api_version = 17 + } +) + + +-- run general tests +test.register_message_test( + "Temperature reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Relative humidity reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Air Quality reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.AirQuality.server.attributes.AirQuality:build_test_report_data(mock_device, 1, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airQualityHealthConcern.airQualityHealthConcern.unknown()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.AirQuality.server.attributes.AirQuality:build_test_report_data(mock_device, 1, 6) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airQualityHealthConcern.airQualityHealthConcern.hazardous()) + }, + }, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "Measured value reports should generate events if there is a stored unit", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit:build_test_report_data( + mock_device, 1, clusters.CarbonMonoxideConcentrationMeasurement.types.MeasurementUnitEnum.PPM + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue:build_test_report_data( + mock_device, 1, SinglePrecisionFloat(0, 4, .11187500) -- ~17.9 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.carbonMonoxideMeasurement.carbonMonoxideLevel({value = 18, unit = "ppm"})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit:build_test_report_data( + mock_device, 1, clusters.Pm25ConcentrationMeasurement.types.MeasurementUnitEnum.UGM3 + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue:build_test_report_data( + mock_device, 1, SinglePrecisionFloat(0, 4, .11187500) -- ~17.9 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.dustSensor.fineDustLevel({value = 18, unit = "μg/m^3"})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit:build_test_report_data( + mock_device, 1, clusters.Pm10ConcentrationMeasurement.types.MeasurementUnitEnum.UGM3 + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue:build_test_report_data( + mock_device, 1, SinglePrecisionFloat(0, 4, .11187500) -- ~17.9 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.dustSensor.dustLevel({value = 18, unit = "μg/m^3"})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit:build_test_report_data( + mock_device, 1, clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.types.MeasurementUnitEnum.PPM + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue:build_test_report_data( + mock_device, 1, SinglePrecisionFloat(0, -1, .5) -- 0.750 ppm + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.tvocMeasurement.tvocLevel({value = 750, unit = "ppb"})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PM25 reports work for profile with only fineDustLevel capability", + function() + test.socket.matter:__queue_receive({ + mock_device_common.id, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit:build_test_report_data( + mock_device_common, 1, clusters.Pm25ConcentrationMeasurement.types.MeasurementUnitEnum.UGM3 + ) + }) + test.socket.matter:__queue_receive({ + mock_device_common.id, + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue:build_test_report_data( + mock_device_common, 1, SinglePrecisionFloat(0, 4, .11187500) -- ~17.9 + ) + }) + test.socket.capability:__expect_send( + mock_device_common:generate_test_message("main", capabilities.fineDustSensor.fineDustLevel({value = 18, unit = "μg/m^3"})) + ) + end, + { + test_init = test_init_common, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Level value reports should generate events", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.UNKNOWN + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.carbonMonoxideHealthConcern.carbonMonoxideHealthConcern.unknown()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.CarbonDioxideConcentrationMeasurement.types.LevelValueEnum.LOW + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern.good()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.NitrogenDioxideConcentrationMeasurement.types.LevelValueEnum.LOW + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.nitrogenDioxideHealthConcern.nitrogenDioxideHealthConcern.good()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.OzoneConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.OzoneConcentrationMeasurement.types.LevelValueEnum.MEDIUM + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.ozoneHealthConcern.ozoneHealthConcern.moderate()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.FormaldehydeConcentrationMeasurement.types.LevelValueEnum.MEDIUM + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.formaldehydeHealthConcern.formaldehydeHealthConcern.moderate()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm1ConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.Pm1ConcentrationMeasurement.types.LevelValueEnum.HIGH + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern.unhealthy()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.Pm25ConcentrationMeasurement.types.LevelValueEnum.CRITICAL + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.fineDustHealthConcern.fineDustHealthConcern.hazardous()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.Pm25ConcentrationMeasurement.types.LevelValueEnum.CRITICAL + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.fineDustHealthConcern.fineDustHealthConcern.hazardous()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.Pm10ConcentrationMeasurement.types.LevelValueEnum.CRITICAL + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.dustHealthConcern.dustHealthConcern.hazardous()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.RadonConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.RadonConcentrationMeasurement.types.LevelValueEnum.CRITICAL + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.radonHealthConcern.radonHealthConcern.hazardous()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue:build_test_report_data( + mock_device, 1, clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.types.LevelValueEnum.CRITICAL + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.tvocHealthConcern.tvocHealthConcern.hazardous()) + ) + end, + { + min_api_version = 17 + } +) + +-- run tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua new file mode 100644 index 0000000000..27921e0dec --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua @@ -0,0 +1,517 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local clusters = require "st.matter.clusters" + +test.disable_startup_messages() + +local mock_device_all = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonMonoxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.OzoneConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.Pm1ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.RadonConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local mock_device_common = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local mock_device_modular_fingerprint = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs-modular.yml", + {enabled_optional_capabilities = {{"main", {}}}}), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + +local function test_init_all() + test.mock_device.add_test_device(mock_device_all) + test.socket.device_lifecycle:__queue_receive({ mock_device_all.id, "init" }) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", + capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, + {visibility={displayed=false}})) + ) + -- on device create, a generic AQS device will be profiled as aqs, thus only subscribing to one attribute + local subscribe_request = clusters.AirQuality.attributes.AirQuality:subscribe(mock_device_all) + test.socket.matter:__expect_send({mock_device_all.id, subscribe_request}) +end + +local function test_init_modular_fingerprint() + test.mock_device.add_test_device(mock_device_modular_fingerprint) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular_fingerprint.id, "init" }) + test.socket.capability:__expect_send(mock_device_modular_fingerprint:generate_test_message("main", + capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, + {visibility={displayed=false}})) + ) + -- on device create, a generic AQS device will be profiled as aqs, thus only subscribing to one attribute + local subscribe_request = clusters.AirQuality.attributes.AirQuality:subscribe(mock_device_modular_fingerprint) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, subscribe_request}) +end + +local function test_init_common() + test.mock_device.add_test_device(mock_device_common) + test.socket.device_lifecycle:__queue_receive({ mock_device_common.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_common.id, "init" }) + test.socket.capability:__expect_send(mock_device_common:generate_test_message("main", + capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, + {visibility={displayed=false}})) + ) + -- on device create, a generic AQS device will be profiled as aqs, thus only subscribing to one attribute + local subscribe_request = clusters.AirQuality.attributes.AirQuality:subscribe(mock_device_common) + test.socket.matter:__expect_send({mock_device_common.id, subscribe_request}) +end + +test.set_test_init_function(test_init_all) + +local function get_subscribe_request_all() + local subscribed_attributes = { + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.carbonMonoxideMeasurement.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.carbonMonoxideHealthConcern.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.carbonDioxideMeasurement.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.carbonDioxideHealthConcern.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.nitrogenDioxideMeasurement.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.ozoneMeasurement.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.MeasuredValue, + clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.ozoneHealthConcern.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.veryFineDustSensor.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.veryFineDustHealthConcern.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.radonMeasurement.ID] = { + clusters.RadonConcentrationMeasurement.attributes.MeasuredValue, + clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.radonHealthConcern.ID] = { + clusters.RadonConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue, + }, + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_all) + else + subscribe_request:merge(attribute:subscribe(mock_device_all)) + end + end + end + return subscribe_request +end + +local function get_subscribe_request_common() + local subscribed_attributes = { + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.carbonDioxideMeasurement.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_common) + else + subscribe_request:merge(attribute:subscribe(mock_device_common)) + end + end + end + return subscribe_request +end + +local function get_subscribe_request_tvoc() + local subscribed_attributes = { + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_modular_fingerprint) + else + subscribe_request:merge(attribute:subscribe(mock_device_modular_fingerprint)) + end + end + end + return subscribe_request +end + + +-- run the profile configuration tests +local function test_aqs_device_type_update_modular_profile(generic_mock_device, expected_metadata, subscribe_request, expected_supported_values_setters) + test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({generic_mock_device.id, clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit:read()}) + generic_mock_device:expect_metadata_update(expected_metadata) + generic_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local updated_device_profile = t_utils.get_profile_definition("aqs-modular.yml", + {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.socket.device_lifecycle:__queue_receive(generic_mock_device:generate_info_changed({ profile = updated_device_profile })) + if expected_supported_values_setters ~= nil then + expected_supported_values_setters() + end + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) +end + +test.register_coroutine_test( + "Handle driverSwitched event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_modular_fingerprint.id, "driverSwitched" }) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit:read()}) + mock_device_modular_fingerprint:expect_metadata_update({ profile = "aqs-modular", optional_component_capabilities = {{"main", {"tvocMeasurement"}}} }) + mock_device_modular_fingerprint:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_modular_fingerprint, + } +) + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities - all clusters", + function() + local expected_metadata_all = { + optional_component_capabilities={ + { + "main", + { + "temperatureMeasurement", + "relativeHumidityMeasurement", + "carbonMonoxideMeasurement", + "carbonDioxideMeasurement", + "nitrogenDioxideMeasurement", + "ozoneMeasurement", + "formaldehydeMeasurement", + "veryFineDustSensor", + "fineDustSensor", + "dustSensor", + "radonMeasurement", + "tvocMeasurement", + "carbonMonoxideHealthConcern", + "carbonDioxideHealthConcern", + "nitrogenDioxideHealthConcern", + "ozoneHealthConcern", + "formaldehydeHealthConcern", + "veryFineDustHealthConcern", + "fineDustHealthConcern", + "dustHealthConcern", + "radonHealthConcern", + "tvocHealthConcern", + }, + }, + }, + profile="aqs-modular-temp-humidity", + } + local expected_supported_values_setters = function() + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.carbonMonoxideHealthConcern.supportedCarbonMonoxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.carbonDioxideHealthConcern.supportedCarbonDioxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.nitrogenDioxideHealthConcern.supportedNitrogenDioxideValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.ozoneHealthConcern.supportedOzoneValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.formaldehydeHealthConcern.supportedFormaldehydeValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.veryFineDustHealthConcern.supportedVeryFineDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.fineDustHealthConcern.supportedFineDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.dustHealthConcern.supportedDustValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.radonHealthConcern.supportedRadonValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + test.socket.capability:__expect_send(mock_device_all:generate_test_message("main", capabilities.tvocHealthConcern.supportedTvocValues({"unknown", "good", "unhealthy"}, {visibility={displayed=false}}))) + end + local subscribe_request_all = get_subscribe_request_all() + test_aqs_device_type_update_modular_profile(mock_device_all, expected_metadata_all, subscribe_request_all, expected_supported_values_setters) + end, + { + test_init = test_init_all, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities - common clusters", + function() + local expected_metadata_common = { + optional_component_capabilities={ + { + "main", + { + "temperatureMeasurement", + "relativeHumidityMeasurement", + "carbonDioxideMeasurement", + "fineDustSensor", + "tvocMeasurement", + }, + }, + }, + profile="aqs-modular-temp-humidity", + } + local expected_supported_values_setters = function() + test.socket.capability:__expect_send(mock_device_common:generate_test_message("main", capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, {visibility={displayed=false}}))) + end + local subscribe_request_common = get_subscribe_request_common() + test_aqs_device_type_update_modular_profile(mock_device_common, expected_metadata_common, subscribe_request_common, expected_supported_values_setters) + end, + { + test_init = test_init_common, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", + function() + local expected_metadata_modular_disabled = { + optional_component_capabilities={ + { + "main", + { + "tvocMeasurement", + }, + }, + }, + profile="aqs-modular", + } + local subscribe_request_tvoc = get_subscribe_request_tvoc() + local updated_device_profile = t_utils.get_profile_definition("aqs-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular_disabled.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000006" + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({ profile = updated_device_profile })) + test.socket.capability:__expect_send(mock_device_modular_fingerprint:generate_test_message("main", capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, {visibility={displayed=false}}))) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, subscribe_request_tvoc}) + end, + { test_init = test_init_modular_fingerprint } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", + function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({})) + end, + { test_init = test_init_modular_fingerprint } +) + +test.register_coroutine_test( + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", + function() + local expected_metadata_modular_disabled = { + optional_component_capabilities={ + { + "main", + { + "tvocMeasurement", + }, + }, + }, + profile="aqs-modular", + } + local subscribe_request_tvoc = get_subscribe_request_tvoc() + local updated_device_profile = t_utils.get_profile_definition("aqs-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular_disabled.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000006" + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({ profile = updated_device_profile })) + test.socket.capability:__expect_send(mock_device_modular_fingerprint:generate_test_message("main", capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, {visibility={displayed=false}}))) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, subscribe_request_tvoc}) + end, + { test_init = test_init_modular_fingerprint } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", + function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({})) + end, + { test_init = test_init_modular_fingerprint } +) + +-- run tests +test.run_registered_tests() 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() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua new file mode 100644 index 0000000000..644c4088e1 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua @@ -0,0 +1,299 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- package.path = package.path .. ";./?lua" +-- package.loaded["path"] = dofile("mock_path.lua") +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.generated.zap_clusters" +local button_attr = capabilities.button.button + + +local mock_device = test.mock_device.build_test_matter_device( + { + label = "Bosch_Button_Contact_Sensor", + profile = t_utils.get_profile_definition("contact-button-battery.yml"), + manufacturer_info = { + vendor_id = 0x1209, + product_id = 0x3015 + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + {cluster_id = clusters.BooleanState.ID, cluster_type = "SERVER"} + }, + + }, + { + endpoint_id = 2, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER", + }, + } + } + } + }) + +local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.MultiPressComplete, + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.BooleanState.attributes.StateValue +} + +local function test_init() + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + mock_device:set_field("__initial_press_only_2", true, {persist = true}) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device)}) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Handle single press sequence, no hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 2, {new_position = 1} --move to position 1? + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle single press sequence, with hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 2, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) --should send initial press + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 2, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.held({state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle release after long press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 2, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 2, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 2, {previous_position = 1} + ) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 2 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 1, 2 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle double press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 2, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 2, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.double({state_change = true})) + }, + + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle received BatPercentRemaining from device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device, 1, 150 + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ), + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Boolean state reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua new file mode 100644 index 0000000000..dd4eb87252 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua @@ -0,0 +1,140 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" + +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local mock_device= test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("flow.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FlowMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0306, device_type_revision = 1} -- Flow Sensor + } + } + } +}) + +local subscribed_attributes = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue +} + +local function test_init() + test.mock_device.add_test_device(mock_device) + local subscribe_request = subscribed_attributes[1]:subscribe(mock_device) + for i, cluster in ipairs(subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Flow reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FlowMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 20*10) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.flowMeasurement.flow({ value = 20.0, unit = "m^3/h" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min and max flow attributes set capability constraint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FlowMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_device, 1, 20) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FlowMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 5000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.flowMeasurement.flowRange({ value = { minimum = 2.0, maximum = 500.0 }, unit = "m^3/h" })) + } + }, + { + min_api_version = 17 + } +) + +local function refresh_commands(dev) + local req = clusters.FlowMeasurement.attributes.MeasuredValue:read(dev) + req:merge(clusters.FlowMeasurement.attributes.MinMeasuredValue:read(dev)) + req:merge(clusters.FlowMeasurement.attributes.MaxMeasuredValue:read(dev)) + return req +end + +test.register_message_test( + "Handle received refresh.", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + refresh_commands(mock_device) + } + }, + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua new file mode 100644 index 0000000000..02e5815c06 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua @@ -0,0 +1,251 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 11 then + clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" +end + +local mock_device_freeze_leak = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("freeze-leak-fault-freezeSensitivity-leakSensitivity.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.BooleanState.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.BooleanStateConfiguration.ID, cluster_type = "SERVER", feature_map = 31}, + }, + device_types = { + {device_type_id = 0x0043, device_type_revision = 1} -- Water Leak Detector + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.BooleanState.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.BooleanStateConfiguration.ID, cluster_type = "SERVER", feature_map = 31}, + }, + device_types = { + {device_type_id = 0x0041, device_type_revision = 1} -- Water Freeze Detector + } + } + } +}) + +local function test_init_freeze_leak() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_freeze_leak) + test.socket.device_lifecycle:__queue_receive({ mock_device_freeze_leak.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_freeze_leak.id, "init" }) + test.socket.matter:__expect_send({mock_device_freeze_leak.id, clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:read(mock_device_freeze_leak, 1)}) + test.socket.matter:__expect_send({mock_device_freeze_leak.id, clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:read(mock_device_freeze_leak, 2)}) + local subscribe_request = clusters.BooleanState.attributes.StateValue:subscribe(mock_device_freeze_leak) + subscribe_request:merge(clusters.BooleanStateConfiguration.attributes.SensorFault:subscribe(mock_device_freeze_leak)) + test.socket.matter:__expect_send({mock_device_freeze_leak.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_freeze_leak.id, "doConfigure" }) + mock_device_freeze_leak:expect_metadata_update({ profile = "freeze-leak-fault-freezeSensitivity-leakSensitivity" }) + mock_device_freeze_leak:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init_freeze_leak) + +test.register_message_test( + "Boolean state freeze detection reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_freeze_leak.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device_freeze_leak, 2, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_freeze_leak:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_freeze_leak.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device_freeze_leak, 2, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_freeze_leak:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) + } + }, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Boolean state leak detection reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_freeze_leak.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device_freeze_leak, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_freeze_leak:generate_test_message("main", capabilities.waterSensor.water.dry()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_freeze_leak.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device_freeze_leak, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_freeze_leak:generate_test_message("main", capabilities.waterSensor.water.wet()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test hardware fault alert handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.SensorFault:build_test_report_data(mock_device_freeze_leak, 1, 0x1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_freeze_leak:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.SensorFault:build_test_report_data(mock_device_freeze_leak, 1, 0x0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_freeze_leak:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to low as expected", function() + test.socket.matter:__queue_receive({ + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:build_test_report_data( + mock_device_freeze_leak, 2, 4 + ) + }) + test.wait_for_events() + + test.socket.device_lifecycle():__queue_receive(mock_device_freeze_leak:generate_info_changed({ preferences = { freezeSensitivity = "0" } })) + test.socket.matter:__expect_send({ + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(mock_device_freeze_leak, 2, 0) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to high as expected", function() + test.socket.matter:__queue_receive({ + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:build_test_report_data( + mock_device_freeze_leak, 2, 4 + ) + }) + test.wait_for_events() + test.socket.device_lifecycle():__queue_receive(mock_device_freeze_leak:generate_info_changed({ preferences = { freezeSensitivity = "2" } })) + test.socket.matter:__expect_send({ + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(mock_device_freeze_leak, 2, mock_device_freeze_leak:get_field("freezeMax") - 1) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to high after being set on-device as expected", function() + test.socket.matter:__queue_receive({ + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:build_test_report_data( + mock_device_freeze_leak, 2, 4 + ) + }) + test.wait_for_events() + test.socket.device_lifecycle():__queue_receive(mock_device_freeze_leak:generate_info_changed({ preferences = { freezeSensitivity = "2" } })) + test.socket.matter:__expect_send({ + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(mock_device_freeze_leak, 2, mock_device_freeze_leak:get_field("freezeMax") - 1) + }) + test.socket["matter"]:__queue_receive( + { + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:build_test_report_data( + mock_device_freeze_leak, 2, 2 -- put on level two + ) + } + ) + test.socket.device_lifecycle():__queue_receive(mock_device_freeze_leak:generate_info_changed({ preferences = { freezeSensitivity = "0" } })) + test.socket.matter:__expect_send({ + mock_device_freeze_leak.id, + clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(mock_device_freeze_leak, 2, 0) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua index 9c439301b0..13bd93ed70 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua @@ -1,23 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" - local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" -local PressureMeasurementCluster = require "PressureMeasurement" + +if not pcall(function(cluster) return clusters[cluster] end, + "PressureMeasurement") then + clusters.PressureMeasurement = require "embedded_clusters.PressureMeasurement" +end --Note all endpoints are being mapped to the main component -- in the matter-sensor driver. If any devices require invoke/write @@ -36,8 +28,11 @@ local matter_endpoints = { { endpoint_id = 1, clusters = { - {cluster_id = PressureMeasurementCluster.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PressureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0305, device_type_revision = 1} -- Pressure Sensor } } } @@ -47,17 +42,11 @@ local mock_device = test.mock_device.build_test_matter_device({ endpoints = matter_endpoints }) -local function subscribe_on_init(dev) - local subscribe_request = PressureMeasurementCluster.attributes.MeasuredValue:subscribe(mock_device) - subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - return subscribe_request -end - local function test_init() - test.socket.matter:__expect_send({mock_device.id, subscribe_on_init(mock_device)}) test.mock_device.add_test_device(mock_device) - -- don't check the battery for this device since we are just testing the "pressure-battery" profile specifically - mock_device:set_field("__battery_checked", 1, {persist = true}) + local subscribe_request = clusters.PressureMeasurement.attributes.MeasuredValue:subscribe(mock_device) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end test.set_test_init_function(test_init) @@ -69,7 +58,7 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - PressureMeasurementCluster.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 1054) + clusters.PressureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 1054) } }, { @@ -82,7 +71,7 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - PressureMeasurementCluster.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 1055) + clusters.PressureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 1055) } }, { @@ -95,7 +84,7 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - PressureMeasurementCluster.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 0) + clusters.PressureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 0) } }, { @@ -103,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({ value = 0, unit = "kPa" })) } + }, + { + min_api_version = 17 } ) @@ -122,13 +114,16 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) } + }, + { + min_api_version = 17 } ) local function refresh_commands(dev) local req = clusters.PowerSource.attributes.BatPercentRemaining:read(dev) - req:merge(PressureMeasurementCluster.attributes.MeasuredValue:read(dev)) + req:merge(clusters.PressureMeasurement.attributes.MeasuredValue:read(dev)) return req end @@ -151,6 +146,9 @@ test.register_message_test( refresh_commands(mock_device) } }, + }, + { + min_api_version = 17 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua new file mode 100644 index 0000000000..2b942c6f0c --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua @@ -0,0 +1,138 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 11 then + clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" +end + +local mock_device_rain = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("rain-fault.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.BooleanState.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.BooleanStateConfiguration.ID, cluster_type = "SERVER", feature_map = 31}, + }, + device_types = { + {device_type_id = 0x0044, device_type_revision = 1} -- Rain Sensor + } + } + } +}) + +local subscribed_attributes = { + clusters.BooleanState.attributes.StateValue, + clusters.BooleanStateConfiguration.attributes.SensorFault, +} + +local function test_init_rain() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_rain) + local subscribe_request = subscribed_attributes[1]:subscribe(mock_device_rain) + for i, cluster in ipairs(subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_rain)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device_rain.id, "init" }) + test.socket.matter:__expect_send({mock_device_rain.id, clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:read(mock_device_rain, 1)}) + test.socket.matter:__expect_send({mock_device_rain.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_rain.id, "doConfigure" }) + mock_device_rain:expect_metadata_update({ profile = "rain-fault-rainSensitivity" }) + mock_device_rain:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init_rain) + +test.register_message_test( + "Boolean state rain detection reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_rain.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device_rain, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rain:generate_test_message("main", capabilities.rainSensor.rain.undetected()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_rain.id, + clusters.BooleanState.server.attributes.StateValue:build_test_report_data(mock_device_rain, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rain:generate_test_message("main", capabilities.rainSensor.rain.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test hardware fault alert handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_rain.id, + clusters.BooleanStateConfiguration.attributes.SensorFault:build_test_report_data(mock_device_rain, 1, 0x1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rain:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_rain.id, + clusters.BooleanStateConfiguration.attributes.SensorFault:build_test_report_data(mock_device_rain, 1, 0x0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rain:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua index 321b4f30c1..d6024f61be 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua @@ -1,22 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" - -local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" --Note all endpoints are being mapped to the main component -- in the matter-sensor driver. If any devices require invoke/write @@ -29,7 +18,7 @@ local matter_endpoints = { {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { @@ -37,21 +26,24 @@ local matter_endpoints = { clusters = { {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "BOTH"}, - } + }, + device_types = {} }, { endpoint_id = 2, clusters = { {cluster_id = clusters.IlluminanceMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.BooleanState.ID, cluster_type = "SERVER"}, - } + }, + device_types = {} }, { endpoint_id = 3, clusters = { - {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, - } + }, + device_types = {} } } @@ -60,24 +52,86 @@ local mock_device = test.mock_device.build_test_matter_device({ endpoints = matter_endpoints }) +local matter_endpoints_presence_sensor = { + { + 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.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = {} + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.IlluminanceMeasurement.ID, cluster_type = "SERVER"} + }, + device_types = {} + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER", feature_map = clusters.OccupancySensing.types.Feature.RADAR} + }, + device_types = {} + } +} + +local mock_device_presence_sensor = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("motion-illuminance-temperature-humidity-battery.yml"), + endpoints = matter_endpoints_presence_sensor +}) + local function subscribe_on_init(dev) - local subscribe_request = clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:subscribe(mock_device) - subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MeasuredValue:subscribe(mock_device)) - subscribe_request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(mock_device)) - subscribe_request:merge(clusters.BooleanState.attributes.StateValue:subscribe(mock_device)) - subscribe_request:merge(clusters.OccupancySensing.attributes.Occupancy:subscribe(mock_device)) - subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) + local subscribe_request = clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:subscribe(dev) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MinMeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.BooleanState.attributes.StateValue:subscribe(dev)) + subscribe_request:merge(clusters.OccupancySensing.attributes.Occupancy:subscribe(dev)) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(dev)) return subscribe_request end local function test_init() test.socket.matter:__expect_send({mock_device.id, subscribe_on_init(mock_device)}) test.mock_device.add_test_device(mock_device) - -- don't check the battery for this device because we are using the catch-all "sensor.yml" profile just for testing - mock_device:set_field("__battery_checked", 1, {persist = true}) end test.set_test_init_function(test_init) +local function subscribe_on_init_presence_sensor(dev) + local subscribe_request = clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:subscribe(dev) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MinMeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(dev)) + subscribe_request:merge(clusters.OccupancySensing.attributes.Occupancy:subscribe(dev)) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(dev)) + return subscribe_request +end + +local function test_init_presence_sensor() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_presence_sensor) + test.socket.device_lifecycle:__queue_receive({ mock_device_presence_sensor.id, "init" }) + test.socket.matter:__expect_send({mock_device_presence_sensor.id, subscribe_on_init_presence_sensor(mock_device_presence_sensor)}) + test.socket.device_lifecycle:__queue_receive({ mock_device_presence_sensor.id, "doConfigure" }) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device_presence_sensor.id, read_attribute_list}) + mock_device_presence_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + test.register_message_test( "Relative humidity reports should generate correct messages", { @@ -107,6 +161,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) } + }, + { + min_api_version = 17 } ) @@ -126,6 +183,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -145,6 +205,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 17 } ) @@ -177,6 +240,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -196,6 +262,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) } + }, + { + min_api_version = 17 } ) @@ -228,12 +297,17 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) local function refresh_commands(dev) local req = clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(dev) req:merge(clusters.TemperatureMeasurement.attributes.MeasuredValue:read(dev)) + req:merge(clusters.TemperatureMeasurement.attributes.MinMeasuredValue:read(dev)) + req:merge(clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:read(dev)) req:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:read(dev)) req:merge(clusters.BooleanState.attributes.StateValue:read(dev)) req:merge(clusters.OccupancySensing.attributes.Occupancy:read(dev)) @@ -260,6 +334,55 @@ test.register_message_test( refresh_commands(mock_device) } }, + }, + { + min_api_version = 17 } ) + +test.register_message_test( + "Min and max temperature attributes set capability constraint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_device, 1, 500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 4000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 40.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change following receival of Power Source Attribute List", + function() + test.socket.matter:__queue_receive({ + mock_device_presence_sensor.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_presence_sensor, 2, {uint32(12)}) + }) + mock_device_presence_sensor:expect_metadata_update({ profile = "presence-illuminance-temperature-humidity-battery" }) + end, + { + test_init = test_init_presence_sensor, + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua new file mode 100644 index 0000000000..0709be6a31 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua @@ -0,0 +1,149 @@ +-- Copyright © 2025 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.matter.clusters" +local uint32 = require "st.matter.data_types.Uint32" + +local mock_device_humidity_battery = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("humidity-batteryLevel.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = {} + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 2}, + }, + device_types = {} + } + } +}) + +local cluster_subscribe_list_humidity_battery = { + clusters.PowerSource.attributes.BatChargeLevel, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, +} + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_humidity_battery) + local subscribe_request_humidity_battery = cluster_subscribe_list_humidity_battery[1]:subscribe(mock_device_humidity_battery) + for i, cluster in ipairs(cluster_subscribe_list_humidity_battery) do + if i > 1 then + subscribe_request_humidity_battery:merge(cluster:subscribe(mock_device_humidity_battery)) + end + end + test.socket.matter:__expect_send({mock_device_humidity_battery.id, subscribe_request_humidity_battery}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_humidity_battery.id, "init" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_humidity_battery.id, "doConfigure" }) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device_humidity_battery.id, read_attribute_list}) + mock_device_humidity_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test profile change when battery percent remaining attribute (attribute ID 12) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device_humidity_battery.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_humidity_battery, 2, + { + uint32(0), + uint32(1), + uint32(2), + uint32(12), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + mock_device_humidity_battery:expect_metadata_update({ profile = "humidity-battery" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change when battery level attribute (attribute ID 14) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device_humidity_battery.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_humidity_battery, 2, + { + uint32(0), + uint32(1), + uint32(2), + uint32(14), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + mock_device_humidity_battery:expect_metadata_update({ profile = "humidity-batteryLevel" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test that profile does not change when battery percent remaining and battery level attributes are not available", + function() + test.socket.matter:__queue_receive( + { + mock_device_humidity_battery.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_humidity_battery, 2, + { + uint32(0), + uint32(1), + uint32(2), + uint32(31), + uint32(65528), + uint32(65529), + uint32(65531), + uint32(65532), + uint32(65533), + }) + } + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua index 2c7bb7faef..0a02bbb27f 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua @@ -1,23 +1,12 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 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.matter.clusters" local mock_device_humidity_battery = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("humidity-battery.yml"), + profile = t_utils.get_profile_definition("humidity-batteryLevel.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -29,20 +18,22 @@ local mock_device_humidity_battery = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { endpoint_id = 1, clusters = { {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, - } + }, + device_types = {} }, { endpoint_id = 2, clusters = { {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 2}, - } + }, + device_types = {} } } }) @@ -60,14 +51,15 @@ local mock_device_humidity_no_battery = test.mock_device.build_test_matter_devic {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { endpoint_id = 1, clusters = { {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, - } + }, + device_types = {} } } }) @@ -85,27 +77,29 @@ local mock_device_temp_humidity = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { endpoint_id = 1, clusters = { {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, - } + }, + device_types = {} }, { endpoint_id = 2, clusters = { {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "BOTH"}, - } + }, + device_types = {} } } }) local cluster_subscribe_list_humidity_battery = { + clusters.PowerSource.attributes.BatChargeLevel, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, - clusters.PowerSource.attributes.BatPercentRemaining } local cluster_subscribe_list_humidity_no_battery = { @@ -114,67 +108,91 @@ local cluster_subscribe_list_humidity_no_battery = { local cluster_subscribe_list_temp_humidity = { clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, - clusters.TemperatureMeasurement.attributes.MeasuredValue + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue } local function test_init_humidity_battery() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_humidity_battery) local subscribe_request_humidity_battery = cluster_subscribe_list_humidity_battery[1]:subscribe(mock_device_humidity_battery) for i, cluster in ipairs(cluster_subscribe_list_humidity_battery) do if i > 1 then subscribe_request_humidity_battery:merge(cluster:subscribe(mock_device_humidity_battery)) end end - + test.socket.device_lifecycle:__queue_receive({ mock_device_humidity_battery.id, "init" }) test.socket.matter:__expect_send({mock_device_humidity_battery.id, subscribe_request_humidity_battery}) - test.mock_device.add_test_device(mock_device_humidity_battery) - mock_device_humidity_battery:expect_metadata_update({ profile = "humidity-battery" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_humidity_battery.id, "doConfigure" }) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device_humidity_battery.id, read_attribute_list}) + mock_device_humidity_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end local function test_init_humidity_no_battery() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_humidity_no_battery) local subscribe_request_humidity_no_battery = cluster_subscribe_list_humidity_no_battery[1]:subscribe(mock_device_humidity_no_battery) for i, cluster in ipairs(cluster_subscribe_list_humidity_no_battery) do if i > 1 then subscribe_request_humidity_no_battery:merge(cluster:subscribe(mock_device_humidity_no_battery)) end end - + test.socket.device_lifecycle:__queue_receive({ mock_device_humidity_no_battery.id, "init" }) test.socket.matter:__expect_send({mock_device_humidity_no_battery.id, subscribe_request_humidity_no_battery}) - test.mock_device.add_test_device(mock_device_humidity_no_battery) + + test.socket.device_lifecycle:__queue_receive({ mock_device_humidity_no_battery.id, "doConfigure" }) mock_device_humidity_no_battery:expect_metadata_update({ profile = "humidity" }) + mock_device_humidity_no_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end local function test_init_temp_humidity() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_temp_humidity) local subscribe_request_temp_humidity = cluster_subscribe_list_temp_humidity[1]:subscribe(mock_device_temp_humidity) for i, cluster in ipairs(cluster_subscribe_list_temp_humidity) do if i > 1 then subscribe_request_temp_humidity:merge(cluster:subscribe(mock_device_temp_humidity)) end end - + test.socket.device_lifecycle:__queue_receive({ mock_device_temp_humidity.id, "init" }) test.socket.matter:__expect_send({mock_device_temp_humidity.id, subscribe_request_temp_humidity}) - test.mock_device.add_test_device(mock_device_temp_humidity) + + test.socket.device_lifecycle:__queue_receive({ mock_device_temp_humidity.id, "doConfigure" }) mock_device_temp_humidity:expect_metadata_update({ profile = "temperature-humidity" }) + mock_device_temp_humidity:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.register_coroutine_test( "Test profile change on init for humidity sensor with battery", function() end, - { test_init = test_init_humidity_battery } + { + test_init = test_init_humidity_battery, + min_api_version = 17 + } ) test.register_coroutine_test( "Test profile change on init for humidity sensor without battery", function() end, - { test_init = test_init_humidity_no_battery } + { + test_init = test_init_humidity_no_battery, + min_api_version = 17 + } ) test.register_coroutine_test( "Test profile change on init for temperature-humidity sensor", function() end, - { test_init = test_init_temp_humidity } + { + test_init = test_init_temp_humidity, + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua new file mode 100644 index 0000000000..6ca73351c9 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua @@ -0,0 +1,79 @@ +-- Copyright © 2025 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.matter.clusters" + +--Note all endpoints are being mapped to the main component +-- in the matter-sensor driver. If any devices require invoke/write +-- requests to support the capabilities/preferences, custom mappings +-- will need to be setup. +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.TemperatureMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + device_type_id = 0x0301, device_type_revision = 1, -- Thermostat + } + } +} + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("sensor.yml"), + endpoints = matter_endpoints +}) + +local function subscribe_on_init(dev) + local subscribe_request = clusters.TemperatureMeasurement.attributes.MeasuredValue:subscribe(mock_device) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MinMeasuredValue:subscribe(mock_device)) + subscribe_request:merge(clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:subscribe(mock_device)) + return subscribe_request +end + +local function test_init() + test.socket.matter:__expect_send({mock_device.id, subscribe_on_init(mock_device)}) + test.mock_device.add_test_device(mock_device) + test.set_rpc_version(3) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Min and max temperature attributes do not set capability constraint when RPC version is less than 5", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_device, 1, 500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 4000) + } + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua new file mode 100644 index 0000000000..aeef7b15f9 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua @@ -0,0 +1,585 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local SinglePrecisionFloat = require "st.matter.data_types.SinglePrecisionFloat" + +local clusters = require "st.matter.clusters" +local version = require "version" +if version.api < 10 then + clusters.SmokeCoAlarm = require "embedded_clusters.SmokeCoAlarm" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("smoke-co-temp-humidity-comeas.yml"), + _provisioning_state = "TYPED", -- we want this to have doConfigure on startup + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.SmokeCoAlarm.ID, cluster_type = "SERVER", feature_map = clusters.SmokeCoAlarm.types.Feature.CO_ALARM | clusters.SmokeCoAlarm.types.Feature.SMOKE_ALARM}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonMonoxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = clusters.CarbonMonoxideConcentrationMeasurement.types.Feature.NUMERIC_MEASUREMENT}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + }, + device_types = { + {device_type_id = 0x0076, device_type_revision = 1} -- Smoke CO Alarm + } + } + } +}) + +local cluster_subscribe_list = { + clusters.SmokeCoAlarm.attributes.SmokeState, + clusters.SmokeCoAlarm.attributes.TestInProgress, + clusters.SmokeCoAlarm.attributes.COState, + clusters.SmokeCoAlarm.attributes.HardwareFaultAlert, + clusters.SmokeCoAlarm.attributes.BatteryAlert, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit, + clusters.PowerSource.attributes.BatChargeLevel, +} + +local function test_init() + -- The startup messages are enabled, so this device will get an init, + -- and doConfigure (because provisioning_state is TYPED on the device). + test.mock_device.add_test_device(mock_device) + + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Test smoke state handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.SmokeState:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.SmokeState.NORMAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.SmokeState:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.SmokeState.WARNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.SmokeState:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.SmokeState.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test CO state handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.COState:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.SmokeState.NORMAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.COState:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.SmokeState.WARNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.COState:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.SmokeState.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test hardwareFault handler with BatteryAlert and No PowerSource BatChargeLevel messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.NORMAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.WARNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test hardwareFault handler with BatteryAlert and OK PowerSource BatChargeLevel state", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data(mock_device, 1, clusters.PowerSource.attributes.BatChargeLevel.OK), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.normal()), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.NORMAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.WARNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test hardwareFault handler with BatteryAlert and Warning PowerSource BatChargeLevel state", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data(mock_device, 1, clusters.PowerSource.attributes.BatChargeLevel.WARNING), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.warning()), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.NORMAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.WARNING) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.BatteryAlert:build_test_report_data(mock_device, 1, clusters.SmokeCoAlarm.attributes.BatteryAlert.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test batteryLevel handler with PowerSource BatChargeLevel state", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data(mock_device, 1, clusters.PowerSource.attributes.BatChargeLevel.OK), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.normal()), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data(mock_device, 1, clusters.PowerSource.attributes.BatChargeLevel.WARNING), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.warning()), + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data(mock_device, 1, clusters.PowerSource.attributes.BatChargeLevel.CRITICAL), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.critical()), + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test test in progress handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.TestInProgress:build_test_report_data(mock_device, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.tested()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.TestInProgress:build_test_report_data(mock_device, 1, false) + } + }, + { + channel = "matter", + direction = "send", + message = {mock_device.id, clusters.SmokeCoAlarm.attributes.SmokeState:read(mock_device)}, + }, + { + channel = "matter", + direction = "send", + message = {mock_device.id, clusters.SmokeCoAlarm.attributes.COState:read(mock_device)} + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test hardware fault alert handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.HardwareFaultAlert:build_test_report_data(mock_device, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.SmokeCoAlarm.attributes.HardwareFaultAlert:build_test_report_data(mock_device, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Relative humidity reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 4049) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 4050) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Carbon Monoxide reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.MeasurementUnit:build_test_report_data(mock_device, 1, clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.MeasurementUnit.PPM) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, SinglePrecisionFloat(0, 6, 0.5625)) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.carbonMonoxideMeasurement.carbonMonoxideLevel({value = 100, unit = "ppm"})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.MeasurementUnit:build_test_report_data(mock_device, 1, clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.MeasurementUnit.PPB) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, SinglePrecisionFloat(0, 13, 0.220703125)) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.carbonMonoxideMeasurement.carbonMonoxideLevel({value = 10, unit = "ppm"})) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua new file mode 100644 index 0000000000..3c8102064e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua @@ -0,0 +1,134 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" +local clusters = require "st.matter.clusters" + +local version = require "version" +if version.api < 10 then + clusters.SmokeCoAlarm = require "embedded_clusters.SmokeCoAlarm" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("smoke-co-temp-humidity-comeas-battery.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.SmokeCoAlarm.ID, cluster_type = "SERVER", feature_map = clusters.SmokeCoAlarm.types.Feature.CO_ALARM | clusters.SmokeCoAlarm.types.Feature.SMOKE_ALARM}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonMonoxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = clusters.CarbonMonoxideConcentrationMeasurement.types.Feature.NUMERIC_MEASUREMENT}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + }, + device_types = { + {device_type_id = 0x0076, device_type_revision = 1} -- Smoke CO Alarm + } + } + } +}) + +local cluster_subscribe_list = { + clusters.SmokeCoAlarm.attributes.SmokeState, + clusters.SmokeCoAlarm.attributes.TestInProgress, + clusters.SmokeCoAlarm.attributes.COState, + clusters.SmokeCoAlarm.attributes.HardwareFaultAlert, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit, + clusters.PowerSource.attributes.BatPercentRemaining, + clusters.PowerSource.attributes.BatChargeLevel, + clusters.SmokeCoAlarm.attributes.BatteryAlert, +} + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test profile change when battery percent remaining attribute (attribute ID 12) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(12)}) + } + ) + mock_device:expect_metadata_update({ profile = "smoke-co-temp-humidity-comeas-battery" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test that profile does not change when battery percent remaining attribute is not available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(0)}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery percent reports should generate correct messages", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(mock_device, 1, 150) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua new file mode 100644 index 0000000000..a5a8042fc0 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua @@ -0,0 +1,229 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local version = require "version" + +if version.api < 13 then + clusters.Global = require "embedded_clusters.Global" +end + +if version.api < 21 then + clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" + -- The SOIL_MOISTURE MeasurementTypeEnum variant was added to the Global MeasurementTypeEnum + -- def in lua libs in api version 21. + clusters.Global.types.MeasurementTypeEnum = require "embedded_clusters.Global.types.MeasurementTypeEnum" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("temperature-soil-sensor.yml"), + manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 }, + 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.SoilMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0045, device_type_revision = 1 } -- Soil Sensor + } + }, + } +}) + +local subscribe_request + +local cluster_subscribe_list = { + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue +} + +local function test_init() + subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "temperature-soil-sensor" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test infoChanged lifecycle event", + function() + local updated_device_profile = t_utils.get_profile_definition("temperature-soil-sensor.yml") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + end +) + +test.register_coroutine_test( + "Relative humidity reports should generate correct messages", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 4049) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) + ) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 4050) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) + ) + end +) + +test.register_coroutine_test( + "Temperature reports should generate correct messages", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 40*100) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + ) + end +) + +test.register_coroutine_test( + "Min and max temperature attributes set capability constraint", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_device, 1, 500) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 4000) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 40.00 }, unit = "C" }) + ) + ) + end +) + +test.register_coroutine_test( + "Soil moisture is reported raw when no limits are set", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 55) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 55 })) + ) + end +) + +local function build_soil_moisture_limits(min_value, max_value) + if version.api < 21 then + clusters.Global.types.MeasurementTypeEnum = require "embedded_clusters.Global.types.MeasurementTypeEnum" + end + return clusters.Global.types.MeasurementAccuracyStruct({ + measurement_type = clusters.Global.types.MeasurementTypeEnum.SOIL_MOISTURE, + measured = true, + min_measured_value = min_value, + max_measured_value = max_value, + accuracy_ranges = {clusters.Global.types.MeasurementAccuracyRangeStruct({range_min = min_value, range_max = max_value})} + }) +end + +test.register_coroutine_test( + "Soil moisture is scaled 0-100% when min and max limits are set", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits:build_test_report_data(mock_device, 1, build_soil_moisture_limits(0, 50)) + } + ) + -- Receive a measured value of 25, which is 50% when scaled between 0 and 50 + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 25) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 25 })) + ) + end +) + +test.register_coroutine_test( + "Soil moisture scaling rounds correctly", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits:build_test_report_data(mock_device, 1, build_soil_moisture_limits(10, 90)) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 10) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 10 })) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 90) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 90 })) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua b/drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua new file mode 100644 index 0000000000..2e79f88dd7 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua @@ -0,0 +1,63 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" + +-- Unit conversion utilities for sensor +local air_quality_sensor_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils" +local air_quality_sensor_fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" + +local units = air_quality_sensor_fields.units + +test.register_coroutine_test( + "Unsupported unit conversion from PPT to MGM3 should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.PPT, units.MGM3, capabilities.ozoneMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPT to MGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from PPB to UGM3 should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.PPB, units.UGM3, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPB to UGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from NGM3 to PPM should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.NGM3, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported NGM3 to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPM to PPB should return correct value", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPM to PPB conversion") + assert(result == 100000, "Expected 100000 for PPM to PPB conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPB to PPM should return correct value", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(1000, units.PPB, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPB to PPM conversion") + assert(result == 1, "Expected 1 for PPB to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unit conversion with non-numeric value should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit("not a number", units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil when value is not a number, got: " .. tostring(result)) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/capabilities/cubeAction.yml b/drivers/SmartThings/matter-switch/capabilities/cubeAction.yml new file mode 100644 index 0000000000..fa2890e992 --- /dev/null +++ b/drivers/SmartThings/matter-switch/capabilities/cubeAction.yml @@ -0,0 +1,29 @@ +id: stse.cubeAction +version: 1 +status: proposed +name: Cube Action +ephemeral: false +attributes: + cubeAction: + schema: + type: object + properties: + value: + title: ActionType + type: string + enum: + - noAction + - shake + - rotate + - pickUpAndHold + - flipToSide1 + - flipToSide2 + - flipToSide3 + - flipToSide4 + - flipToSide5 + - flipToSide6 + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} diff --git a/drivers/SmartThings/matter-switch/capabilities/cubeFace.yml b/drivers/SmartThings/matter-switch/capabilities/cubeFace.yml new file mode 100644 index 0000000000..af7e7301f8 --- /dev/null +++ b/drivers/SmartThings/matter-switch/capabilities/cubeFace.yml @@ -0,0 +1,25 @@ +id: stse.cubeFace +version: 1 +status: proposed +name: Cube Face +ephemeral: false +attributes: + cubeFace: + schema: + type: object + properties: + value: + title: CubeFace + type: string + enum: + - face1Up + - face2Up + - face3Up + - face4Up + - face5Up + - face6Up + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index c0f58df2b4..6a4321c1a5 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -1,11 +1,317 @@ matterManufacturer: +#Aqara + - id: "4447/6145" + deviceLabel: Aqara LED Bulb T2 RGB CCT + vendorId: 0x115F + productId: 0x1801 + deviceProfileName: light-color-level + - id: "4447/6146" + deviceLabel: Aqara LED Bulb T2 CCT + vendorId: 0x115F + productId: 0x1802 + deviceProfileName: light-level-colorTemperature-2700K-6500K + - id: "4447/6149" + deviceLabel: Aqara LED Bulb T2 RGB CCT + vendorId: 0x115F + productId: 0x1805 + deviceProfileName: light-color-level + - id: "4447/6150" + deviceLabel: Aqara LED Bulb T2 CCT + vendorId: 0x115F + productId: 0x1806 + deviceProfileName: light-level-colorTemperature-2700K-6500K + - id: "4447/8196" + deviceLabel: Climate Sensor W100 + vendorId: 0x115F + productId: 0x2004 + deviceProfileName: 3-button-battery-temperature-humidity + - id: "4447/4105" + deviceLabel: Aqara Light Switch H2 EU (4 Buttons 2 Channel) + vendorId: 0x115F + productId: 0x1009 + deviceProfileName: 4-button + - id: "4447/4104" + deviceLabel: Aqara Light Switch H2 EU (2 Buttons 1 Channel) + vendorId: 0x115F + productId: 0x1008 + deviceProfileName: 2-button + - id: "4447/4099" + deviceLabel: Aqara Light Switch H2 US (2 Buttons 1 Channel) + vendorId: 0x115F + productId: 0x1003 + deviceProfileName: 2-button + - id: "4447/4100" + deviceLabel: Aqara Light Switch H2 US (2 Buttons 2 Channel) + vendorId: 0x115F + productId: 0x1004 + deviceProfileName: 2-button + - id: "4447/4101" + deviceLabel: Aqara Light Switch H2 US (4 Buttons 3 Channel) + vendorId: 0x115F + productId: 0x1005 + deviceProfileName: 4-button +#AiDot + - id: "Linkind/Smart/Light/Bulb/A19/RGBTW" + deviceLabel: Linkind Smart Light Bulb A19 RGBTW + vendorId: 0x1168 + productId: 0x03e8 + deviceProfileName: light-color-level-1800K-6500K + - id: "OREiN/Smart/Light/Bulb/A19/RGBTW" + deviceLabel: OREiN Smart Light Bulb A19 RGBTW + vendorId: 0x1168 + productId: 0x03ea + deviceProfileName: light-color-level-1800K-6500K + - id: "Linkind/Smart/Light/Bulb/BR30/RGBTW" + deviceLabel: Linkind Smart Light Bulb BR30 RGBTW + vendorId: 0x1168 + productId: 0x03eb + deviceProfileName: light-color-level-1800K-6500K + - id: "OREiN/Smart/Light/Bulb/BR30/RGBTW" + deviceLabel: OREiN Smart Light Bulb BR30 RGBTW + vendorId: 0x1168 + productId: 0x03ec + deviceProfileName: light-color-level-1800K-6500K + - id: "Consciot/Smart/Light/Bulb/A19/RGBT" + deviceLabel: Consciot Smart Light Bulb A19 RGBT + vendorId: 0x1168 + productId: 0x0405 + deviceProfileName: light-color-level-1800K-6500K + - id: "4456/1010" + deviceLabel: Aidot OREiN Matter Smart Light Bulb BR30 RGBTW 1200lm + vendorId: 0x1168 + productId: 0x03F2 + deviceProfileName: light-color-level-1800K-6500K + - id: "4456/1011" + deviceLabel: Smart Light Bulb A60 + vendorId: 0x1168 + productId: 0x03F3 + deviceProfileName: light-color-level-1800K-6500K + - id: "4456/1012" + deviceLabel: Aidot Linkind Matter Smart Light Bulb BR30 RGBTW 1200lm + vendorId: 0x1168 + productId: 0x03F4 + deviceProfileName: light-color-level-1800K-6500K + - id: "4456/1013" + deviceLabel: Matter Smart Light Bulb A21 RGBTW 1600lm + vendorId: 0x1168 + productId: 0x03F5 + deviceProfileName: light-color-level-1800K-6500K + - id: "AiDot/Smart/Light/Bulb/A19/RGBTW" + deviceLabel: AiDot Smart Light Bulb A19 RGBT + vendorId: 0x1168 + productId: 0x03f8 + deviceProfileName: light-color-level-1800K-6500K + - id: "Linkind/Smart/Light/Bulb/A19/CCT" + deviceLabel: Smart Light Bulb A19 CCT + vendorId: 0x1168 + productId: 0x03f9 + deviceProfileName: light-level-colorTemperature-2700K-6500K + - id: "4456/1024" + deviceLabel: Smart Plug + vendorId: 0x1168 + productId: 0x0400 + deviceProfileName: plug-binary + - id: "4456/1007" + deviceLabel: Matter Smart Light Bulb A21 RGBTW 1600lm + vendorId: 0x1168 + productId: 0x03EF + deviceProfileName: light-color-level-1800K-6500K + - id: "4456/1005" + deviceLabel: Matter Smart Light Bulb A19 RGBTW 1100lm + vendorId: 0x1168 + productId: 0x03ED + deviceProfileName: light-color-level-1800K-6500K + - id: "4456/1006" + deviceLabel: Matter Smart Light Bulb A19 RGBTW 1100lm + vendorId: 0x1168 + productId: 0x03EE + deviceProfileName: light-color-level-1800K-6500K + - id: "4456/1102" + deviceLabel: Matter Smart Plug + vendorId: 0x1168 + productId: 0x044E + deviceProfileName: plug-binary + - id: "4456/1019" + deviceLabel: Matter Smart Filament Bulb ST58 CCT + vendorId: 0x1168 + productId: 0x03FB + deviceProfileName: light-level-colorTemperature-2700K-6500K + - id: "4456/1020" + deviceLabel: AiDot OREiN Matter Smart Filament Bulb ST58 CCT + vendorId: 0x1168 + productId: 0x03FC + deviceProfileName: light-level-colorTemperature-2700K-6500K + - id: "5014/4096" + deviceLabel: Linkind LED Light Strip EL6 + vendorId: 0x1396 + productId: 0x1000 + deviceProfileName: light-color-level-1800K-6500K + - id: "5014/4101" + deviceLabel: Linkind Smart Plug with Light + vendorId: 0x1396 + productId: 0x1005 + deviceProfileName: plug-binary + - id: "5014/4097" + deviceLabel: OREIN Bath Fan OLO5S + vendorId: 0x1396 + productId: 0x1001 + deviceProfileName: fan-modular + - id: "5014/4214" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1076 + deviceProfileName: light-color-level + - id: "5014/4245" + deviceLabel: OREiN Matter smart Bathroom Fan + vendorId: 0x1396 + productId: 0x1095 + deviceProfileName: fan-modular + - id: "5014/4246" + deviceLabel: OREiN Matter smart Bathroom Fan + vendorId: 0x1396 + productId: 0x1096 + deviceProfileName: fan-modular + - id: "5014/4247" + deviceLabel: OREiN Matter smart Bathroom Fan + vendorId: 0x1396 + productId: 0x1097 + deviceProfileName: fan-modular + - id: "5014/4215" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1077 + deviceProfileName: light-color-level + - id: "5014/4273" + deviceLabel: Linkind Smart Ceiling Light + vendorId: 0x1396 + productId: 0x10B1 + deviceProfileName: light-color-level + - id: "5014/4164" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1044 + deviceProfileName: light-color-level + - id: "5014/4118" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x1016 + deviceProfileName: light-color-level + - id: "5014/4116" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1014 + deviceProfileName: light-color-level + - id: "5014/4165" + deviceLabel: Linkind Smart Downlight + vendorId: 0x1396 + productId: 0x1045 + deviceProfileName: light-color-level + - id: "5014/4578" + deviceLabel: Linkind Light Stick T19 + vendorId: 0x1396 + productId: 0x11E2 + deviceProfileName: light-color-level + - id: "5014/4274" + deviceLabel: Linkind Smart Ceiling Light + vendorId: 0x1396 + productId: 0x10B2 + deviceProfileName: light-color-level + - id: "5014/4642" + deviceLabel: Linkind Smart Permanent Outdoor Lights + vendorId: 0x1396 + productId: 0x1222 + deviceProfileName: light-color-level + - id: "5014/4629" + deviceLabel: Smart Outdoor String Lights + vendorId: 0x1396 + productId: 0x1215 + deviceProfileName: light-color-level + - id: "5014/4158" + deviceLabel: Linkind Smart Ceiling Fan + vendorId: 0x1396 + productId: 0x103E + deviceProfileName: fan-modular +#Bosch Smart Home + - id: "4617/12310" + deviceLabel: Plug Compact [M] + vendorId: 0x1209 + productId: 0x3016 + deviceProfileName: plug-power-energy-powerConsumption + - id: 4617/12307 + deviceLabel: "Motion Detector II [M]" + vendorId: 0x1209 + productId: 0x3013 + deviceProfileName: light-level-battery-illuminance-motion-temperature +#Chengdu + - id: "5218/8197" + deviceLabel: Magic Cube DS001 + vendorId: 0x1462 + productId: 0x2005 + deviceProfileName: light-level-colorTemperature #ThirdReality - id: "ThirdReality/WiFi" deviceLabel: THIRDREALITY Night Light vendorId: 0x1407 productId: 0x1088 - deviceProfileName: light-color-level-illuminance-motion-1000K-15000K - + deviceProfileName: light-color-level-illuminance-motion-1000K-15000K + - id: "5127/5121" + deviceLabel: ThirdReality Smart Night Light -T + vendorId: 0x1407 + productId: 0x1401 + deviceProfileName: light-color-level-illuminance-motion + - id: "5127/4744" + deviceLabel: Smart Bridge MZ1 + vendorId: 0x1407 + productId: 0x1288 + deviceProfileName: matter-bridge + - id: "5127/5000" + deviceLabel: Smart Mechanical Keyboard MK1 + vendorId: 0x1407 + productId: 0x1388 + deviceProfileName: 12-button-keyboard + - id: "5127/4248" + deviceLabel: Smart Garage Door Opener + vendorId: 0x1407 + productId: 0x1098 + deviceProfileName: garage-door-battery + +#Edishine + - id: "5458/3328" + deviceLabel: Edishine Smart Led Bulb + vendorId: 0x1552 + productId: 0x0D00 + deviceProfileName: light-color-level + +#ELEGRP + - id: "5158/3" + deviceLabel: ELEGRP Smart Mini Plug + vendorId: 0x1426 + productId: 0x0003 + deviceProfileName: plug-binary +#Elko + - id: "5170/4096" + deviceLabel: RFSAI-62B-SL/MT + vendorId: 0x1432 + productId: 0x1000 + deviceProfileName: switch-binary + - id: "5170/4097" + deviceLabel: RFGB-40B(W)/MT + vendorId: 0x1432 + productId: 0x1001 + deviceProfileName: 4-button-battery + - id: "5170/4099" + deviceLabel: RFWB-40G/MT + vendorId: 0x1432 + productId: 0x1003 + deviceProfileName: 4-button-battery + +#Eufy + - id: "5427/19" + deviceLabel: eufy Permanent Outdoor Lights S4 + vendorId: 0x1533 + productId: 0x0013 + deviceProfileName: light-color-level-2200K-6500K + #Eve - id: "Eve/Energy/US" deviceLabel: Eve Energy @@ -32,6 +338,11 @@ matterManufacturer: vendorId: 0x130A productId: 0x69 deviceProfileName: power-energy-powerConsumption + - id: "4874/121" + deviceLabel: Eve Dimmer Switch + vendorId: 0x130A + productId: 0x0079 + deviceProfileName: light-level - id: "4874/106" deviceLabel: Eve Energy Switzerland vendorId: 0x130A @@ -42,6 +353,28 @@ matterManufacturer: vendorId: 0x130A productId: 0x006B deviceProfileName: power-energy-powerConsumption + - id: "4874/93" + deviceLabel: Eve Light Switch + vendorId: 0x130A + productId: 0x005D + deviceProfileName: switch-binary + - id: "4874/67" + deviceLabel: Eve Light Switch + vendorId: 0x130A + productId: 0x0043 + deviceProfileName: switch-binary + +#Ezviz + - id: "5172/4096" + deviceLabel: A3 Home Gateway + vendorId: 0x1434 + productId: 0x1000 + deviceProfileName: matter-bridge + - id: "5172/4352" + deviceLabel: Wi-Fi Relay + vendorId: 0x1434 + productId: 0x1100 + deviceProfileName: plug-binary #GE - id: "4921/177" @@ -68,12 +401,17 @@ matterManufacturer: deviceLabel: Cync Full Color 6 Inch Wafer vendorId: 0x1339 productId: 0x00AF - deviceProfileName: light-color-level-2000K-7000K + deviceProfileName: light-color-level-2000K-7000K - id: "4921/181" deviceLabel: Cync 6 Inch High Ceiling Wafer vendorId: 0x1339 productId: 0x00B5 deviceProfileName: light-color-level-2000K-7000K + - id: "4921/182" + deviceLabel: Cync Full Color A19 Clear Spiral + vendorId: 0x1339 + productId: 0x00B6 + deviceProfileName: light-color-level-2000K-7000K - id: "4921/41" deviceLabel: Cync Full Color Undercabinet 12 Inch vendorId: 0x1339 @@ -128,19 +466,827 @@ matterManufacturer: deviceLabel: Cync Indoor Light Strip 16ft vendorId: 0x1339 productId: 0x006E - deviceProfileName: light-color-level-2000K-7000K + deviceProfileName: light-color-level-2000K-7000K - id: "4921/123" deviceLabel: Cync Indoor Light Strip 32ft vendorId: 0x1339 productId: 0x007B - deviceProfileName: light-color-level-2000K-7000K + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/109" + deviceLabel: Cync Reveal Full Color A21 + vendorId: 0x1339 + productId: 0x006D + deviceProfileName: light-color-level-2000K-7000K - id: "4921/111" deviceLabel: Cync Outdoor Plug vendorId: 0x1339 productId: 0x006F deviceProfileName: plug-binary + - id: "4921/21" + deviceLabel: Cync Full Color A19 + vendorId: 0x1339 + productId: 0x0015 + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/101" + deviceLabel: Cync Full Color Deco Candle Base + vendorId: 0x1339 + productId: 0x0065 + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/105" + deviceLabel: Cync Full Color A21 + vendorId: 0x1339 + productId: 0x0069 + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/173" + deviceLabel: Cync Full Color BR30 + vendorId: 0x1339 + productId: 0x00AD + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/107" + deviceLabel: Cync Reveal Full Color A19 + vendorId: 0x1339 + productId: 0x006B + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/124" + deviceLabel: Cync Keypad Dimmer + vendorId: 0x1339 + productId: 0x007C + deviceProfileName: light-level-2-button + - id: "4921/125" + deviceLabel: Cync Paddle Dimmer + vendorId: 0x1339 + productId: 0x007D + deviceProfileName: switch-level + - id: "4921/108" + deviceLabel: Cync Reveal Full Color BR30 + vendorId: 0x1339 + productId: 0x006C + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/64" + deviceLabel: Cync Indoor Plug + vendorId: 0x1339 + productId: 0x0040 + deviceProfileName: plug-binary + - id: "4921/118" + deviceLabel: Cync Button Switch w Traveler + vendorId: 0x1339 + productId: 0x0076 + deviceProfileName: light-binary + - id: "4921/119" + deviceLabel: Cync Paddle Switch w Traveler + vendorId: 0x1339 + productId: 0x0077 + deviceProfileName: light-binary + - id: "4921/120" + deviceLabel: Cync Toggle Switch w Traveler + vendorId: 0x1339 + productId: 0x0078 + deviceProfileName: light-binary + - id: "4921/116" + deviceLabel: Cync Button Dimmer w Traveler + vendorId: 0x1339 + productId: 0x0074 + deviceProfileName: switch-level + - id: "4921/117" + deviceLabel: Cync Motion Dimmer w Traveler + vendorId: 0x1339 + productId: 0x0075 + deviceProfileName: switch-level + - id: "4921/22" + deviceLabel: Cync Full Color RS Can + vendorId: 0x1339 + productId: 0x0016 + deviceProfileName: light-color-level-2000K-7000K + - id: "4921/86" + deviceLabel: Cync Smart Shop Light + vendorId: 0x1339 + productId: 0x0056 + deviceProfileName: light-color-level + - id: "4921/85" + deviceLabel: Dual Socket Smart Plug + vendorId: 0x1339 + productId: 0x0055 + deviceProfileName: plug-binary +#Govee + - id: "4999/24740" + deviceLabel: Govee Square Ceiling Light (12 inch) + vendorId: 0x1387 + productId: 0x60A4 + deviceProfileName: light-color-level + - id: "4999/28871" + deviceLabel: Govee Christmas String Lights 2 (Clear Wire) 164ft/50m + vendorId: 0x1387 + productId: 0x70C7 + deviceProfileName: light-color-level + - id: "4999/25043" + deviceLabel: Govee Neon Rope Light 2 9.8ft/3m + vendorId: 0x1387 + productId: 0x61D3 + deviceProfileName: light-color-level + - id: "4999/24595" + deviceLabel: Govee RGBWW Smart LED Bulb BR30 850lm + vendorId: 0x1387 + productId: 0x6013 + deviceProfileName: light-color-level + - id: "4999/25011" + deviceLabel: Govee Strip Light with Cover 9.8f/3m + vendorId: 0x1387 + productId: 0x61B3 + deviceProfileName: light-color-level + - id: "4999/24586" + deviceLabel: Govee RGBWW Smart LED Bulb A19 1200lm + vendorId: 0x1387 + productId: 0x600A + deviceProfileName: light-color-level + - id: "4999/26116" + deviceLabel: Govee HDMI 2.1 Sync Box 2 75-85 + vendorId: 0x1387 + productId: 0x6604 + deviceProfileName: light-color-level + - id: "4999/24729" + deviceLabel: Govee TV Backlight 3 Lite 75-85 + vendorId: 0x1387 + productId: 0x6099 + deviceProfileName: light-color-level + # - id: "4999/28871" + # deviceLabel: Govee Christmas String Lights 2 (Green Wire) 164ft/50m + # vendorId: 0x1387 + # productId: 0x70C7 + # deviceProfileName: light-color-level + - id: "4999/28873" + deviceLabel: Govee Christmas String Lights 2 (Green Wire) 328ft/100m + vendorId: 0x1387 + productId: 0x70C9 + deviceProfileName: light-color-level + - id: "4999/24754" + deviceLabel: Govee Tree Floor Lamp + vendorId: 0x1387 + productId: 0x60B2 + deviceProfileName: light-color-level + - id: "4999/25078" + deviceLabel: Govee Strip Light 2 Pro 32.8ft/10m + vendorId: 0x1387 + productId: 0x61F6 + deviceProfileName: light-color-level + - id: "4999/25077" + deviceLabel: Govee Strip Light 2 Pro 16.4ft/5m + vendorId: 0x1387 + productId: 0x61F5 + deviceProfileName: light-color-level + - id: "4999/24588" + deviceLabel: Govee RGBWW Smart LED Bulb E14 450lm + vendorId: 0x1387 + productId: 0x600C + deviceProfileName: light-color-level + - id: "4999/28868" + deviceLabel: Govee Christmas String Lights 2 (Clear Wire) 66ft/20m + vendorId: 0x1387 + productId: 0x70C4 + deviceProfileName: light-color-level + - id: "4999/25074" + deviceLabel: Govee Strip Light 2 Pro 6.56ft/2m + vendorId: 0x1387 + productId: 0x61F2 + deviceProfileName: light-color-level + - id: "4999/25013" + deviceLabel: Govee Strip Light with Cover 16.4ft/5m + vendorId: 0x1387 + productId: 0x61B5 + deviceProfileName: light-color-level + - id: "4999/24753" + deviceLabel: Govee Torchiere Floor Lamp + vendorId: 0x1387 + productId: 0x60B1 + deviceProfileName: light-color-level + - id: "4999/28869" + deviceLabel: Govee Christmas String Lights 2 (Green Wire) 99ft/30m + vendorId: 0x1387 + productId: 0x70C5 + deviceProfileName: light-color-level + - id: "4999/24592" + deviceLabel: Govee RGBWW Smart LED Bulb BR30 1200lm + vendorId: 0x1387 + productId: 0x6010 + deviceProfileName: light-color-level + # - id: "4999/28869" + # deviceLabel: Govee Christmas String Lights 2 (Clear Wire) 99ft/30m + # vendorId: 0x1387 + # productId: 0x70C5 + # deviceProfileName: light-color-level + - id: "4999/24607" + deviceLabel: Govee Recessed Downlight 2 (6 inch) + vendorId: 0x1387 + productId: 0x601F + deviceProfileName: light-color-level + - id: "4999/25045" + deviceLabel: Govee Neon Rope Light 2 16.4ft/5m + vendorId: 0x1387 + productId: 0x61D5 + deviceProfileName: light-color-level + - id: "4999/24603" + deviceLabel: Govee Recessed Downlight (4 inch) + vendorId: 0x1387 + productId: 0x601B + deviceProfileName: light-color-level + - id: "4999/24602" + deviceLabel: Govee Recessed Downlight (6 inch) + vendorId: 0x1387 + productId: 0x601A + deviceProfileName: light-color-level + - id: "4999/25014" + deviceLabel: Govee Strip Light with Cover 32.8ft/10m + vendorId: 0x1387 + productId: 0x61B6 + deviceProfileName: light-color-level + - id: "4999/24610" + deviceLabel: Govee Table Lamp 2 + vendorId: 0x1387 + productId: 0x6022 + deviceProfileName: light-color-level + - id: "4999/24606" + deviceLabel: Govee Recessed Downlight 2 (4 inch) + vendorId: 0x1387 + productId: 0x601E + deviceProfileName: light-color-level + - id: "4999/24589" + deviceLabel: Govee RGBWW Smart LED Bulb MR16 400lm + vendorId: 0x1387 + productId: 0x600D + deviceProfileName: light-color-level + - id: "4999/24700" + deviceLabel: Govee Floor Lamp 2 + vendorId: 0x1387 + productId: 0x607C + deviceProfileName: light-color-level + # - id: "4999/26116" + # deviceLabel: Govee HDMI 2.1 Sync Box 2 55-65 + # vendorId: 0x1387 + # productId: 0x6604 + # deviceProfileName: light-color-level + - id: "4999/24694" + deviceLabel: Govee Floor Lamp Basic + vendorId: 0x1387 + productId: 0x6076 + deviceProfileName: light-color-level + - id: "4999/24587" + deviceLabel: Govee RGBWW Smart LED Bulb E12 450lm + vendorId: 0x1387 + productId: 0x600B + deviceProfileName: light-color-level + - id: "4999/25017" + deviceLabel: Govee Strip Light with Skyline Kit 19.7ft/6m + vendorId: 0x1387 + productId: 0x61B9 + deviceProfileName: light-color-level + - id: "4999/24742" + deviceLabel: Govee Ceiling Light Pro + vendorId: 0x1387 + productId: 0x60A6 + deviceProfileName: light-color-level + - id: "4999/24666" + deviceLabel: Govee TV Backlight 3 Lite Kit 55-65 + vendorId: 0x1387 + productId: 0x605A + deviceProfileName: light-color-level + - id: "4999/24608" + deviceLabel: Govee Table Lamp 2 Pro Sound by JBL + vendorId: 0x1387 + productId: 0x6020 + deviceProfileName: light-color-level + # - id: "4999/28868" + # deviceLabel: Govee Christmas String Lights 2 (Green Wire) 66ft/20m + # vendorId: 0x1387 + # productId: 0x70C4 + # deviceProfileName: light-color-level + - id: "4999/25046" + deviceLabel: Govee Neon Rope Light 2 32.8ft/10m + vendorId: 0x1387 + productId: 0x61D6 + deviceProfileName: light-color-level + - id: "4999/25061" + deviceLabel: Govee COB Strip Light Pro 9.8ft/3m + vendorId: 0x1387 + productId: 0x61E5 + deviceProfileName: light-color-level + - id: "4999/24723" + deviceLabel: Govee Star Light Projector (Aurora) + vendorId: 0x1387 + productId: 0x6093 + deviceProfileName: light-color-level + - id: "4999/25062" + deviceLabel: Govee COB Strip Light Pro 16.4ft/5m + vendorId: 0x1387 + productId: 0x61E6 + deviceProfileName: light-color-level + - id: "4999/24752" + deviceLabel: Govee Uplighter Floor Lamp + vendorId: 0x1387 + productId: 0x60B0 + deviceProfileName: light-color-level + - id: "4999/24727" + deviceLabel: Govee TV Backlight 3 Lite 40-50 + vendorId: 0x1387 + productId: 0x6097 + deviceProfileName: light-color-level + - id: "4999/24582" + deviceLabel: Govee RGBWW Smart LED Bulb A19 1000lm + vendorId: 0x1387 + productId: 0x6006 + deviceProfileName: light-color-level + - id: "4999/25016" + deviceLabel: Govee Strip Light with Skyline Kit 13.1ft/4m + vendorId: 0x1387 + productId: 0x61B8 + deviceProfileName: light-color-level + - id: "4999/26737" + deviceLabel: Govee Christmas Sparkle String Lights 99ft + vendorId: 0x1387 + productId: 0x6871 + deviceProfileName: light-color-level + - id: "4999/26736" + deviceLabel: Govee Christmas Sparkle String Lights 66ft + vendorId: 0x1387 + productId: 0x6870 + deviceProfileName: light-color-level + - id: "4999/26272" + deviceLabel: Govee TV Backlight 3 Pro 55-65 + vendorId: 0x1387 + productId: 0x66A0 + deviceProfileName: light-color-level + # - id: "4999/26272" + # deviceLabel: Govee TV Backlight 3 Pro 75-85 + # vendorId: 0x1387 + # productId: 0x66A0 + # deviceProfileName: light-color-level + - id: "4999/28854" + deviceLabel: Govee Curtain Lights Pro + vendorId: 0x1387 + productId: 0x70B6 + deviceProfileName: light-color-level + - id: "4999/24733" + deviceLabel: Govee Galaxy Light Star Projector 2 Pro + vendorId: 0x1387 + productId: 0x609D + deviceProfileName: light-color-level + - id: "4999/24725" + deviceLabel: Govee Star Projector Light + vendorId: 0x1387 + productId: 0x6095 + deviceProfileName: light-color-level + - id: "4999/24724" + deviceLabel: Govee Star Projector Light + vendorId: 0x1387 + productId: 0x6094 + deviceProfileName: light-color-level + - id: "4999/24646" + deviceLabel: Govee TV Light Bar + vendorId: 0x1387 + productId: 0x6046 + deviceProfileName: light-color-level + - id: "4999/24662" + deviceLabel: Govee RGBICWW Light Bar + vendorId: 0x1387 + productId: 0x6056 + deviceProfileName: light-color-level + - id: "4999/5281" + deviceLabel: Govee Smart Bulb PAR38 + vendorId: 0x1387 + productId: 0x14A1 + deviceProfileName: light-color-level + - id: "4999/5121" + deviceLabel: Govee Smart Bulb A21 1600lm + vendorId: 0x1387 + productId: 0x1401 + deviceProfileName: light-color-level + - id: "4999/5313" + deviceLabel: Govee Edison Bulb + vendorId: 0x1387 + productId: 0x14C1 + deviceProfileName: light-color-level + - id: "4999/5312" + deviceLabel: Govee Edison Bulb + vendorId: 0x1387 + productId: 0x14C0 + deviceProfileName: light-color-level + - id: "4999/5680" + deviceLabel: Govee Lantern Floor Lamp + vendorId: 0x1387 + productId: 0x1630 + deviceProfileName: light-color-level + - id: "4999/5953" + deviceLabel: Govee Table Lamp Classic + vendorId: 0x1387 + productId: 0x1741 + deviceProfileName: light-color-level + - id: "4999/5808" + deviceLabel: Govee Floor Lamp 3 + vendorId: 0x1387 + productId: 0x16B0 + deviceProfileName: light-color-level + - id: "4999/5824" + deviceLabel: Govee Floor Lamp 3 Lite + vendorId: 0x1387 + productId: 0x16C0 + deviceProfileName: light-color-level + - id: "4999/4720" + deviceLabel: Govee Ceiling Light Ultra (21inch) + vendorId: 0x1387 + productId: 0x1270 + deviceProfileName: light-color-level + - id: "4999/24755" + deviceLabel: Govee Uplighter Floor Lamp with Nebula Effect + vendorId: 0x1387 + productId: 0x60B3 + deviceProfileName: light-color-level +# Hager + - id: "4741/8" + deviceLabel: Hager matter 2 buttons (battery) + vendorId: 0x1285 + productId: 0x0008 + deviceProfileName: 2-button-batteryLevel + - id: "4741/9" + deviceLabel: Hager matter 4 buttons (battery) + vendorId: 0x1285 + productId: 0x0009 + deviceProfileName: 4-button-batteryLevel + - id: "4741/5" + deviceLabel: Hager Switch 1G + vendorId: 0x1285 + productId: 0x0005 + deviceProfileName: matter-bridge + - id: "4741/6" + deviceLabel: Hager Switch 2G + vendorId: 0x1285 + productId: 0x0006 + deviceProfileName: matter-bridge + - id: "4741/7" + deviceLabel: Hager PIR 1.1M + vendorId: 0x1285 + productId: 0x0007 + deviceProfileName: matter-bridge + - id: "4741/10" + deviceLabel: Hager PIR 2.2M + vendorId: 0x1285 + productId: 0x000A + deviceProfileName: matter-bridge +# HAOJAI + - id: "5530/4113" + deviceLabel: HAOJAI Smart Switch 3-key + vendorId: 0x159A + productId: 0x1011 + deviceProfileName: 3-button-motion + - id: "5530/4098" + deviceLabel: HAOJAI Smart Switch 6-key + vendorId: 0x159A + productId: 0x1002 + deviceProfileName: 6-button-motion +# Hue + - id: "4107/2049" + deviceLabel: Hue W 1600 A21 E26 1P NAM + vendorId: 0x100B + productId: 0x0801 + deviceProfileName: light-level + - id: "4107/297" + deviceLabel: Hue WCA 650 BR30 E26 1P NAM + vendorId: 0x100B + productId: 0x0129 + deviceProfileName: light-color-level + - id: "4107/2048" + deviceLabel: Hue WA 810 A19 E26 1P NAM + vendorId: 0x100B + productId: 0x0800 + deviceProfileName: light-level-colorTemperature +# Intecular + - id: "5226/32769" + deviceLabel: InvisOutlet + vendorId: 0x146A + productId: 0x8001 + deviceProfileName: plug-binary +#Ledvance + - id: "4489/843" + deviceLabel: Matter Filament RGBW + vendorId: 0x1189 + productId: 0x034B + deviceProfileName: light-color-level + - id: "4489/844" + deviceLabel: Matter Filament RGBW + vendorId: 0x1189 + productId: 0x034C + deviceProfileName: light-color-level + - id: "4489/845" + deviceLabel: Matter Filament RGBW + vendorId: 0x1189 + productId: 0x034D + deviceProfileName: light-color-level + - id: "4489/828" + deviceLabel: Ledvance Matter Classic A RGBW + vendorId: 0x1189 + productId: 0x033C + deviceProfileName: light-color-level + - id: "4489/829" + deviceLabel: Ledvance Matter Classic A RGBW + vendorId: 0x1189 + productId: 0x033D + deviceProfileName: light-color-level + - id: "4489/831" + deviceLabel: Ledvance Matter Classic A RGBW + vendorId: 0x1189 + productId: 0x033F + deviceProfileName: light-color-level + - id: "4489/832" + deviceLabel: Ledvance Matter Classic A RGBW + vendorId: 0x1189 + productId: 0x0340 + deviceProfileName: light-color-level + - id: "4489/833" + deviceLabel: Ledvance Matter Classic B P RGBW + vendorId: 0x1189 + productId: 0x0341 + deviceProfileName: light-color-level + - id: "4489/835" + deviceLabel: Ledvance Matter Classic B P RGBW + vendorId: 0x1189 + productId: 0x0343 + deviceProfileName: light-color-level + - id: "4489/1120" + deviceLabel: Ledvance Matter PAR16 RGBW + vendorId: 0x1189 + productId: 0x0460 + deviceProfileName: light-color-level + - id: "4489/1121" + deviceLabel: Ledvance Matter Classic G RGBW + vendorId: 0x1189 + productId: 0x0461 + deviceProfileName: light-color-level + - id: "4489/1372" + deviceLabel: MATTER PLUG EU WH + vendorId: 0x1189 + productId: 0x055C + deviceProfileName: plug-binary + - id: "4489/2377" + deviceLabel: LEDVANCE MATTER PLUG CH + vendorId: 0x1189 + productId: 0x0949 + deviceProfileName: plug-binary + - id: "4489/2473" + deviceLabel: MATTER FLEX RGBTW EU + vendorId: 0x1189 + productId: 0x09A9 + deviceProfileName: light-color-level + - id: "4489/2176" + deviceLabel: MATTER PLUG EU BK + vendorId: 0x1189 + productId: 0x0880 + deviceProfileName: plug-binary + - id: "4489/1587" + deviceLabel: MATTER COMP OUT PLUG EU + vendorId: 0x1189 + productId: 0x0633 + deviceProfileName: plug-binary + - id: "4489/2193" + deviceLabel: SMART WIFI MATTER WALL SWITCH 2G + vendorId: 0x1189 + productId: 0x0891 + deviceProfileName: switch-binary +#Ikea + - id: "4476/32768" + deviceLabel: BILRESA Scroll Wheel + vendorId: 0x117C + productId: 0x8000 + deviceProfileName: ikea-scroll + - id: "4476/32769" + deviceLabel: BILRESA Dual Button + vendorId: 0x117C + productId: 0x8001 + deviceProfileName: ikea-2-button-battery + - id: "4476/36885" + deviceLabel: KAJPLATS E12 WS Globe 800lm + vendorId: 0x117C + productId: 0x9015 + deviceProfileName: light-level-colorTemperature + - id: "4476/36880" + deviceLabel: VARMBLIXT table/wall lamp + vendorId: 0x117C + productId: 0x9010 + deviceProfileName: light-color-level + - id: "4476/36881" + deviceLabel: KAJPLATS E26 450lm + vendorId: 0x117C + productId: 0x9011 + deviceProfileName: light-level-colorTemperature + - id: "4476/36882" + deviceLabel: KAJPLATS E26 1100lm + vendorId: 0x117C + productId: 0x9012 + deviceProfileName: light-level-colorTemperature + - id: "4476/36884" + deviceLabel: KAJPLATS E26 WS 1600lm + vendorId: 0x117C + productId: 0x9014 + deviceProfileName: light-level-colorTemperature + - id: "4476/36886" + deviceLabel: KAJPLATS E26 1100lm CWS + vendorId: 0x117C + productId: 0x9016 + deviceProfileName: light-color-level + - id: "4476/36887" + deviceLabel: KAJPLATS E12 CWS Globe 800lm + vendorId: 0x117C + productId: 0x9017 + deviceProfileName: light-color-level + - id: "4476/36889" + deviceLabel: KAJPLATS E12 WS B38 CL 470lm + vendorId: 0x117C + productId: 0x9019 + deviceProfileName: light-level-colorTemperature + - id: "17526/16534" + deviceLabel: GRILLPLATS plug + vendorId: 0x4476 + productId: 0x4096 + deviceProfileName: plug-power-energy-powerConsumption + - id: "17526/16535" + deviceLabel: TOFSMYGGA + vendorId: 0x4476 + productId: 0x4097 + deviceProfileName: plug-power-energy-powerConsumption +#Innovation Matters + - id: "4978/1" + deviceLabel: M2D Bridge + vendorId: 0x1372 + productId: 0x0001 + deviceProfileName: light-level-colorTemperature-2200K-6500K + - id: "4978/2" + deviceLabel: IM Pushbutton Module + vendorId: 0x1372 + productId: 0x0002 + deviceProfileName: 4-button +#JUNG + - id: "5161/1" + deviceLabel: Matter Push button 2-gang + vendorId: 0x1429 + productId: 0x0001 + deviceProfileName: 4-button-battery +#Legrand + - id: "4129/3" + deviceLabel: Smart Lights Smart Plug + vendorId: 0x1021 + productId: 0x0003 + deviceProfileName: plug-binary + - id: "4129/4" + deviceLabel: Smart Lights Outdoor Plug + vendorId: 0x1021 + productId: 0x0004 + deviceProfileName: plug-binary + - id: "4129/7" + deviceLabel: Radiant Smart Receptacle + vendorId: 0x1021 + productId: 0x0007 + deviceProfileName: plug-binary + - id: "4129/5" + deviceLabel: Radiant Smart Switch + vendorId: 0x1021 + productId: 0x0005 + deviceProfileName: switch-binary + - id: "4129/6" + deviceLabel: Radiant Smart LED Dimmer + vendorId: 0x1021 + productId: 0x0006 + deviceProfileName: switch-level +#Leviton + - id: "4251/4097" + deviceLabel: Decora Smart Wi-Fi (2nd Gen) Switch + vendorId: 0x109B + productId: 0x1001 + deviceProfileName: switch-binary + - id: "4251/4096" + deviceLabel: Decora Smart Wi-Fi (2nd Gen) 600W Dimmer + vendorId: 0x109B + productId: 0x1000 + deviceProfileName: switch-level + - id: "4251/4107" + deviceLabel: Decora Smart Wi-Fi ELV Dimmer + vendorId: 0x109B + productId: 0x100B + deviceProfileName: switch-level + - id: "4251/4110" + deviceLabel: Decora Smart Wi-Fi 0-10V Dimmer + vendorId: 0x109B + productId: 0x100E + deviceProfileName: switch-level + - id: "4251/4108" + deviceLabel: Decora Evolve Smart Dimmer Module + vendorId: 0x109B + productId: 0x100C + deviceProfileName: switch-level + - id: "4251/4109" + deviceLabel: Decora Evolve Smart Switch Module + vendorId: 0x109B + productId: 0x100D + deviceProfileName: switch-binary + - id: "4251/4105" + deviceLabel: Decora Smart Wi-Fi Outdoor Plug-In Switch + vendorId: 0x109B + productId: 0x1009 + deviceProfileName: switch-binary + - id: "4251/4099" + deviceLabel: Leviton Decora Smart Wi-Fi Mini Plug-In Outlet + vendorId: 0x109B + productId: 0x1003 + deviceProfileName: switch-binary + - id: "4251/4100" + deviceLabel: Decora Smart Wi-Fi (2nd Gen) Tamper Resistant Outlet + vendorId: 0x109B + productId: 0x1004 + deviceProfileName: switch-binary + - id: "4251/4098" + deviceLabel: Decora Smart Wi-Fi (2nd Gen) Mini Plug-In Dimmer + vendorId: 0x109B + productId: 0x1002 + deviceProfileName: switch-level + - id: "4251/4102" + deviceLabel: Decora Smart Motion Sensing Dimmer Switch + vendorId: 0x109B + productId: 0x1006 + deviceProfileName: light-level-motion + - id: "4251/4103" + deviceLabel: Decora Smart Wi-Fi (2nd Gen) Scene Controller Switch + vendorId: 0x109B + productId: 0x1007 + deviceProfileName: 3-button + - id: "4251/4113" + deviceLabel: "Decora Smart Wi-Fi (3rd Gen) 15A Switch" + vendorId: 0x109B + productId: 0x1011 + deviceProfileName: switch-binary + - id: "4251/4112" + deviceLabel: "Decora Smart Wi-Fi (3rd Gen) 600W Dimmer" + vendorId: 0x109B + productId: 0x1010 + deviceProfileName: switch-level +#LeTianPai + - id: "5163/4097" + deviceLabel: LeTianPai Smart Light Bulb + vendorId: 0x142B + productId: 0x1001 + deviceProfileName: light-color-level + - id: "5163/4100" + deviceLabel: LeTianPai Smart Switch 3 Way + vendorId: 0x142B + productId: 0x1004 + deviceProfileName: switch-binary + - id: "5163/4099" + deviceLabel: LeTianPai Smart Switch 2 Way + vendorId: 0x142B + productId: 0x1003 + deviceProfileName: switch-binary + - id: "5163/4098" + deviceLabel: LeTianPai Smart Switch 1 Way + vendorId: 0x142B + productId: 0x1002 + deviceProfileName: switch-binary +#LG + - id: "4142/8785" + deviceLabel: LG Smart Button (2 Button) + vendorId: 0x102E + productId: 0x2251 + deviceProfileName: 2-button-battery + - id: "4142/8800" + deviceLabel: LG Smart Plug + vendorId: 0x102E + productId: 0x2260 + deviceProfileName: plug-power + - id: "4142/8704" + deviceLabel: LG Smart Wall Switch (1 Button) + vendorId: 0x102E + productId: 0x2200 + deviceProfileName: light-binary + - id: "4142/8705" + deviceLabel: LG Smart Wall Switch (2 Button) + vendorId: 0x102E + productId: 0x2201 + deviceProfileName: light-binary + - id: "4142/8706" + deviceLabel: LG Smart Wall Switch (3 Button) + vendorId: 0x102E + productId: 0x2202 + deviceProfileName: light-binary #Lifx + - id: "5155/161" + deviceLabel: LIFX Neon Outdoor + vendorId: 0x1423 + productId: 0x00A1 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/162" + deviceLabel: LIFX Neon Outdoor + vendorId: 0x1423 + productId: 0x00A2 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/168" + deviceLabel: LIFX Downlight (Retrofit 6) + vendorId: 0x1423 + productId: 0x00A8 + deviceProfileName: light-level-colorTemperature-1500k-9000k - id: "5155/169" deviceLabel: LIFX Color (A21) vendorId: 0x1423 @@ -154,24 +1300,276 @@ matterManufacturer: - id: "5155/173" deviceLabel: LIFX Path (Round) vendorId: 0x1423 - productId: 0x00AD - deviceProfileName: light-level-colorTemperature-1500k-9000k - - id: "5155/174" - deviceLabel: LIFX Path (Square) + productId: 0x00AD + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/174" + deviceLabel: LIFX Path (Square) + vendorId: 0x1423 + productId: 0x00AE + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/175" + deviceLabel: LIFX Color (PAR38) + vendorId: 0x1423 + productId: 0x00AF + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/176" + deviceLabel: LIFX Ceiling + vendorId: 0x1423 + productId: 0x00B0 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/178" + deviceLabel: LIFX Downlight (Canless 6) + vendorId: 0x1423 + productId: 0x00B2 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/179" + deviceLabel: LIFX Downlight (Retrofit 4) + vendorId: 0x1423 + productId: 0x00B3 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/180" + deviceLabel: LIFX Downlight (Canless 4) + vendorId: 0x1423 + productId: 0x00B4 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/185" + deviceLabel: LIFX Candle + vendorId: 0x1423 + productId: 0x00B9 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/201" + deviceLabel: LIFX Ceiling 13x26 + vendorId: 0x1423 + productId: 0x00C9 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/219" + deviceLabel: LIFX Luna + vendorId: 0x1423 + productId: 0x00DB + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/220" + deviceLabel: LIFX Luna + vendorId: 0x1423 + productId: 0x00DC + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/213" + deviceLabel: LIFX Permanent Outdoor + vendorId: 0x1423 + productId: 0x00D5 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/215" + deviceLabel: LIFX Candle (B10) + vendorId: 0x1423 + productId: 0x00D7 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/217" + deviceLabel: LIFX Tube + vendorId: 0x1423 + productId: 0x00D9 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/119" + deviceLabel: LIFX Beam + vendorId: 0x1423 + productId: 0x0077 + deviceProfileName: light-level-colorTemperature-1500k-9000k + - id: "5155/191" + deviceLabel: LIFX Everyday Smart Light 2-Pack + vendorId: 0x1423 + productId: 0x00BF + deviceProfileName: light-color-level + - id: "5155/163" + deviceLabel: LIFX Supercolor (A19) + vendorId: 0x1423 + productId: 0x00A3 + deviceProfileName: light-color-level + - id: "5155/118" + deviceLabel: LIFX Lightstrip + vendorId: 0x1423 + productId: 0x0076 + deviceProfileName: light-color-level + - id: "5155/221" + deviceLabel: LIFX Spot + vendorId: 0x1423 + productId: 0x00DD + deviceProfileName: light-color-level + - id: "5155/144" + deviceLabel: LIFX String + vendorId: 0x1423 + productId: 0x0090 + deviceProfileName: light-color-level + - id: "5155/216" + deviceLabel: LIFX Candle Color (B10) + vendorId: 0x1423 + productId: 0x00D8 + deviceProfileName: light-color-level + - id: "5155/225" + deviceLabel: LIFX PAR38 + vendorId: 0x1423 + productId: 0x00E1 + deviceProfileName: light-color-level + - id: "5155/186" + deviceLabel: LIFX Candle Color + vendorId: 0x1423 + productId: 0x00BA + deviceProfileName: light-color-level + - id: "5155/202" + deviceLabel: LIFX Ceiling 13x26 + vendorId: 0x1423 + productId: 0x00CA + deviceProfileName: light-color-level + - id: "5155/143" + deviceLabel: LIFX String + vendorId: 0x1423 + productId: 0x008F + deviceProfileName: light-color-level + - id: "5155/166" + deviceLabel: LIFX Supercolour (BR30) + vendorId: 0x1423 + productId: 0x00A6 + deviceProfileName: light-color-level + - id: "5155/167" + deviceLabel: LIFX Downlight + vendorId: 0x1423 + productId: 0x00A7 + deviceProfileName: light-color-level + - id: "5155/207" + deviceLabel: LIFX Everyday Lightstrip + vendorId: 0x1423 + productId: 0x00CF + deviceProfileName: light-color-level + - id: "5155/222" + deviceLabel: LIFX Path (Round) + vendorId: 0x1423 + productId: 0x00DE + deviceProfileName: light-color-level + - id: "5155/203" + deviceLabel: LIFX String + vendorId: 0x1423 + productId: 0x00CB + deviceProfileName: light-color-level + - id: "5155/218" + deviceLabel: LIFX Tube + vendorId: 0x1423 + productId: 0x00DA + deviceProfileName: light-color-level + - id: "5155/214" + deviceLabel: LIFX Permanent Outdoor + vendorId: 0x1423 + productId: 0x00D6 + deviceProfileName: light-color-level + - id: "5155/117" + deviceLabel: LIFX Lightstrip + vendorId: 0x1423 + productId: 0x0075 + deviceProfileName: light-color-level + - id: "5155/223" + deviceLabel: LIFX Downlight (6 Retro Downlight) + vendorId: 0x1423 + productId: 0x00DF + deviceProfileName: light-color-level + - id: "5155/224" + deviceLabel: LIFX Downlight (90mm Downlight) + vendorId: 0x1423 + productId: 0x00E0 + deviceProfileName: light-color-level + - id: "5155/204" + deviceLabel: LIFX String + vendorId: 0x1423 + productId: 0x00CC + deviceProfileName: light-color-level + - id: "5155/206" + deviceLabel: LIFX Neon + vendorId: 0x1423 + productId: 0x00CE + deviceProfileName: light-color-level + - id: "5155/164" + deviceLabel: LIFX Supercolor (BR30) + vendorId: 0x1423 + productId: 0x00A4 + deviceProfileName: light-color-level + - id: "5155/120" + deviceLabel: LIFX Beam + vendorId: 0x1423 + productId: 0x0078 + deviceProfileName: light-color-level + - id: "5155/208" + deviceLabel: LIFX Everyday Lightstrip + vendorId: 0x1423 + productId: 0x00D0 + deviceProfileName: light-color-level + - id: "5155/165" + deviceLabel: LIFX Supercolour (A19) + vendorId: 0x1423 + productId: 0x00A5 + deviceProfileName: light-color-level + - id: "5155/142" + deviceLabel: LIFX Neon + vendorId: 0x1423 + productId: 0x008E + deviceProfileName: light-color-level + - id: "5155/141" + deviceLabel: LIFX Neon + vendorId: 0x1423 + productId: 0x008D + deviceProfileName: light-color-level + - id: "5155/177" + deviceLabel: LIFX Ceiling + vendorId: 0x1423 + productId: 0x00B1 + deviceProfileName: light-color-level + - id: "5155/170" + deviceLabel: LIFX Supercolour (A21) + vendorId: 0x1423 + productId: 0x00AA + deviceProfileName: light-color-level + - id: "5155/205" + deviceLabel: LIFX Neon + vendorId: 0x1423 + productId: 0x00CD + deviceProfileName: light-color-level + - id: "5155/265" + deviceLabel: Ceiling 13 vendorId: 0x1423 - productId: 0x00AE - deviceProfileName: light-level-colorTemperature-1500k-9000k - - id: "5155/175" - deviceLabel: LIFX Color (PAR38) + productId: 0x0109 + deviceProfileName: light-color-level + - id: "5155/266" + deviceLabel: LIFX Ceiling 13 vendorId: 0x1423 - productId: 0x00AF - deviceProfileName: light-level-colorTemperature-1500k-9000k - - id: "5155/176" - deviceLabel: LIFX Ceiling + productId: 0x010A + deviceProfileName: light-color-level + - id: "5155/267" + deviceLabel: LIFX Mirror vendorId: 0x1423 - productId: 0x00B0 - deviceProfileName: light-level-colorTemperature-1500k-9000k + productId: 0x010B + deviceProfileName: light-color-level + - id: "5155/268" + deviceLabel: LIFX Mirror + vendorId: 0x1423 + productId: 0x010C + deviceProfileName: light-color-level +#LG + - id: "4142/8784" + deviceLabel: LG Smart Button (1 Button) + vendorId: 0x102E + productId: 0x2250 + deviceProfileName: button-battery +#Meross + - id: "4933/40987" + deviceLabel: Smart Wi-Fi Switch + vendorId: 0x1345 + productId: 0xA01B + deviceProfileName: switch-binary + - id: "4933/40978" + deviceLabel: Smart Wi-Fi Switch + vendorId: 0x1345 + productId: 0xA012 + deviceProfileName: switch-binary + - id: "4933/45057" + deviceLabel: Smart Wi-Fi Power Strip + vendorId: 0x1345 + productId: 0xB001 + deviceProfileName: switch-binary #Nanoleaf - id: "Nanoleaf NL53" deviceLabel: Essentials BR30 @@ -203,6 +1601,589 @@ matterManufacturer: vendorId: 0x115A productId: 0x711 deviceProfileName: light-color-level-2700K-6500K + - id: "4442/72" + deviceLabel: Essentials Indoor Lights + vendorId: 0x115A + productId: 0x0048 + deviceProfileName: light-color-level-2700K-6500K + - id: "4442/73" + deviceLabel: Essentials Outdoor Lights + vendorId: 0x115A + productId: 0x0049 + deviceProfileName: light-color-level-2700K-6500K + - id: "4442/71" + deviceLabel: Smart Holiday String Lights + vendorId: 0x115A + productId: 0x0047 + deviceProfileName: light-color-level-2700K-6500K + - id: "4442/75" + deviceLabel: Essentials Wifi A19-A60 Smart Bulb + vendorId: 0x115A + productId: 0x004B + deviceProfileName: light-color-level-2700K-6500K +#Neo + - id: "4991/635" + deviceLabel: Power Plug + vendorId: 0x137F + productId: 0x027B + deviceProfileName: plug-binary +#Netatmo + - id: "4129/8" + deviceLabel: Netatmo Thermo Hub + vendorId: 0x1021 + productId: 0x0008 + deviceProfileName: matter-bridge +#Onvis + - id: "5181/4097" + deviceLabel: Onvis Smart Plug S4EU + vendorId: 0x143D + productId: 0x1001 + deviceProfileName: plug-binary +#Osram + - id: "4489/2564" + deviceLabel: OSRAM MATTER PLUG UK + vendorId: 0x1189 + productId: 0x0A04 + deviceProfileName: plug-binary + - id: "4489/2757" + deviceLabel: SMART MAT P40 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0AC5 + deviceProfileName: light-color-level + - id: "4489/61442" + deviceLabel: OSRAM MATTER CLASSIC A 100W + vendorId: 0x1189 + productId: 0xF002 + deviceProfileName: light-color-level + - id: "4489/2755" + deviceLabel: SMART MAT A60 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0AC3 + deviceProfileName: light-color-level + - id: "4489/61444" + deviceLabel: OSRAM MATTER PLUG UK + vendorId: 0x1189 + productId: 0xF004 + deviceProfileName: plug-binary + - id: "4489/61443" + deviceLabel: OSRAM MATTER PLUG EU WH + vendorId: 0x1189 + productId: 0xF003 + deviceProfileName: plug-binary + - id: "4489/61441" + deviceLabel: OSRAM MATTER CLASSIC A 60W + vendorId: 0x1189 + productId: 0xF001 + deviceProfileName: light-color-level + - id: "4489/2353" + deviceLabel: SMART MATTER FLOORCORN200 MGC WT + vendorId: 0x1189 + productId: 0x0931 + deviceProfileName: light-color-level + - id: "4489/2350" + deviceLabel: SMART MATTER FLOORCORN140 MGC BK + vendorId: 0x1189 + productId: 0x092E + deviceProfileName: light-color-level + - id: "4489/2352" + deviceLabel: SMART MATTER FLOORCORN140 MGC WT + vendorId: 0x1189 + productId: 0x0930 + deviceProfileName: light-color-level + - id: "4489/2351" + deviceLabel: SMART MATTER FLOORCORN200 MGC BK + vendorId: 0x1189 + productId: 0x092F + deviceProfileName: light-color-level + - id: "4489/2715" + deviceLabel: SMART MAT B40 TW 827 FR E14 + vendorId: 0x1189 + productId: 0x0A9B + deviceProfileName: light-level-colorTemperature + - id: "4489/2686" + deviceLabel: SMART MAT A53 DIM FILGD 824 E27 + vendorId: 0x1189 + productId: 0x0A7E + deviceProfileName: light-level + - id: "4489/2725" + deviceLabel: SMART MAT PAR16 RGBW GU10 + vendorId: 0x1189 + productId: 0x0AA5 + deviceProfileName: light-color-level + - id: "4489/2766" + deviceLabel: SMART MAT B40 RGBW 827 FR E14 + vendorId: 0x1189 + productId: 0x0ACE + deviceProfileName: light-color-level + - id: "4489/2765" + deviceLabel: SMART MAT P40 RGBW 827 FR E14 + vendorId: 0x1189 + productId: 0x0ACD + deviceProfileName: light-color-level + - id: "4489/2764" + deviceLabel: SMART MAT G95 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0ACC + deviceProfileName: light-color-level + - id: "4489/2774" + deviceLabel: SMART MAT A40 FIL RGBW 827 E27 + vendorId: 0x1189 + productId: 0x0AD6 + deviceProfileName: light-color-level + - id: "4489/2775" + deviceLabel: SMART MAT E40 FIL RGBW 827 E27 + vendorId: 0x1189 + productId: 0x0AD7 + deviceProfileName: light-color-level + - id: "4489/2776" + deviceLabel: SMART MAT G40 FIL RGBW 827 E27 + vendorId: 0x1189 + productId: 0x0AD8 + deviceProfileName: light-color-level + - id: "4489/2837" + deviceLabel: SMART MAT A75 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0B15 + deviceProfileName: light-color-level + - id: "4489/2713" + deviceLabel: SMART MAT A60 TW 827 FR E27 + vendorId: 0x1189 + productId: 0x0A99 + deviceProfileName: light-level-colorTemperature + - id: "4489/2711" + deviceLabel: SMART MAT A100 TW 827 FR E27 + vendorId: 0x1189 + productId: 0x0A97 + deviceProfileName: light-level-colorTemperature + - id: "4489/2716" + deviceLabel: SMART MAT P40 TW 827 FR E14 + vendorId: 0x1189 + productId: 0x0A9C + deviceProfileName: light-level-colorTemperature + - id: "4489/2726" + deviceLabel: SMART MAT PAR16 TW 827 GU10 + vendorId: 0x1189 + productId: 0x0AA6 + deviceProfileName: light-level-colorTemperature + - id: "4489/2678" + deviceLabel: SMART MAT E60 DIM FIL 827 E27 + vendorId: 0x1189 + productId: 0x0A76 + deviceProfileName: light-level + - id: "4489/2676" + deviceLabel: SMART MAT G60 DIM FIL 827 E27 + vendorId: 0x1189 + productId: 0x0A74 + deviceProfileName: light-level + - id: "4489/2687" + deviceLabel: SMART MAT B40 DIM FIL 827 E14 + vendorId: 0x1189 + productId: 0x0A7F + deviceProfileName: light-level + - id: "4489/2685" + deviceLabel: SMART MAT E53 DIM FILGD 824 E27 + vendorId: 0x1189 + productId: 0x0A7D + deviceProfileName: light-level + - id: "4489/2684" + deviceLabel: SMART MAT G53 DIM FILGD 824 E27 + vendorId: 0x1189 + productId: 0x0A7C + deviceProfileName: light-level + - id: "4489/2681" + deviceLabel: SMART MAT A60 DIM FIL 827 E27 + vendorId: 0x1189 + productId: 0x0A79 + deviceProfileName: light-level + - id: "4489/2682" + deviceLabel: SMART MAT P40 DIM FIL 827 E14 + vendorId: 0x1189 + productId: 0x0A7A + deviceProfileName: light-level + - id: "4489/2727" + deviceLabel: SMART MAT PAR16 DIM GU10 + vendorId: 0x1189 + productId: 0x0AA7 + deviceProfileName: light-level + - id: "4489/2712" + deviceLabel: SMART MAT G95 100 TW 827 E27 + vendorId: 0x1189 + productId: 0x0A98 + deviceProfileName: light-level-colorTemperature + - id: "4489/2836" + deviceLabel: SMART MAT A75 TW 827 FR E27 + vendorId: 0x1189 + productId: 0x0B14 + deviceProfileName: light-level-colorTemperature + - id: "4489/2514" + deviceLabel: SMART MAT ORBIS DL 200 TW WT + vendorId: 0x1189 + productId: 0x09D2 + deviceProfileName: light-color-level + - id: "4489/2518" + deviceLabel: SMART MAT ORBIS DL 400 TW WT + vendorId: 0x1189 + productId: 0x09D6 + deviceProfileName: light-color-level + - id: "4489/2519" + deviceLabel: SMART MAT ORBIS DL SQ400 TW WT + vendorId: 0x1189 + productId: 0x09D7 + deviceProfileName: light-color-level + - id: "4489/2763" + deviceLabel: SMART MAT A60 RGBW 827 FR B22D + vendorId: 0x1189 + productId: 0x0ACB + deviceProfileName: light-color-level + - id: "4489/2756" + deviceLabel: SMART MAT A100 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0AC4 + deviceProfileName: light-color-level + - id: "4489/3027" + deviceLabel: SMART MAT DECORWALLSWAN300X150TW + vendorId: 0x1189 + productId: 0x0BD3 + deviceProfileName: light-level-colorTemperature + - id: "4489/2994" + deviceLabel: SMART MAT PLANON FL 120X30 TW + vendorId: 0x1189 + productId: 0x0BB2 + deviceProfileName: light-level-colorTemperature + - id: "4489/3032" + deviceLabel: SMART WIFI MAT BATH W400 IP44 TW + vendorId: 0x1189 + productId: 0x0BD8 + deviceProfileName: light-level-colorTemperature + - id: "4489/3026" + deviceLabel: SMART WIFI MAT WAVE300X300IP44TW + vendorId: 0x1189 + productId: 0x0BD2 + deviceProfileName: light-level-colorTemperature + - id: "4489/2957" + deviceLabel: SMART WIFI MAT TRACKSP OSAKA TW + vendorId: 0x1189 + productId: 0x0B8D + deviceProfileName: light-level-colorTemperature + - id: "4489/3025" + deviceLabel: SMART WIFI MAT CYLINDER 450 TW + vendorId: 0x1189 + productId: 0x0BD1 + deviceProfileName: light-level-colorTemperature + - id: "4489/2969" + deviceLabel: SMART MAT PLANON PLUS 120X30TW + vendorId: 0x1189 + productId: 0x0B99 + deviceProfileName: light-level-colorTemperature + - id: "4489/3028" + deviceLabel: SMART MAT DISC 400 IP44 TW + vendorId: 0x1189 + productId: 0x0BD4 + deviceProfileName: light-level-colorTemperature + - id: "4489/2971" + deviceLabel: SMART MAT PLANON FL 80X10TW + vendorId: 0x1189 + productId: 0x0B9B + deviceProfileName: light-level-colorTemperature + - id: "4489/3014" + deviceLabel: SMART MAT PLANONFLSPARK45X45TW + vendorId: 0x1189 + productId: 0x0BC6 + deviceProfileName: light-level-colorTemperature + - id: "4489/3023" + deviceLabel: SMART WIFI MAT MAGNET 600X300 TW + vendorId: 0x1189 + productId: 0x0BCF + deviceProfileName: light-level-colorTemperature + - id: "4489/3021" + deviceLabel: SMART WIFI MAT AQUA280X160IP44TW + vendorId: 0x1189 + productId: 0x0BCD + deviceProfileName: light-level-colorTemperature + - id: "4489/3035" + deviceLabel: SMART WIFI MAT DUPLO 300 IP44 TW + vendorId: 0x1189 + productId: 0x0BDB + deviceProfileName: light-level-colorTemperature + - id: "4489/3024" + deviceLabel: SMART WIFI MAT DUPLO 580 IP44 TW + vendorId: 0x1189 + productId: 0x0BD0 + deviceProfileName: light-level-colorTemperature + - id: "4489/2998" + deviceLabel: SMART WIFI MAT DL SLIM 120 TW + vendorId: 0x1189 + productId: 0x0BB6 + deviceProfileName: light-level-colorTemperature + - id: "4489/3020" + deviceLabel: SMART MATDECORWALLTWIST230X127TW + vendorId: 0x1189 + productId: 0x0BCC + deviceProfileName: light-level-colorTemperature + - id: "4489/3049" + deviceLabel: SMART WIFI MAT TRACKSP CIRCLE TW + vendorId: 0x1189 + productId: 0x0BE9 + deviceProfileName: light-level-colorTemperature + - id: "4489/3029" + deviceLabel: SMART WIFI MAT DUPLO 450 IP44 TW + vendorId: 0x1189 + productId: 0x0BD5 + deviceProfileName: light-level-colorTemperature + - id: "4489/2991" + deviceLabel: SMART MAT PLANON PLUS 60X60 TW + vendorId: 0x1189 + productId: 0x0BAF + deviceProfileName: light-level-colorTemperature + - id: "4489/2985" + deviceLabel: SMART MAT PLANON PLUS 30X30 TW + vendorId: 0x1189 + productId: 0x0BA9 + deviceProfileName: light-level-colorTemperature + - id: "4489/3033" + deviceLabel: SMART WIFI MAT BATH W300 IP44 TW + vendorId: 0x1189 + productId: 0x0BD9 + deviceProfileName: light-level-colorTemperature + - id: "4489/2999" + deviceLabel: SMART WIFI MAT DL SLIM 225 TW + vendorId: 0x1189 + productId: 0x0BB7 + deviceProfileName: light-level-colorTemperature + - id: "4489/3037" + deviceLabel: SMART WIFI MAT MAGNET 300X300 TW + vendorId: 0x1189 + productId: 0x0BDD + deviceProfileName: light-level-colorTemperature + - id: "4489/2990" + deviceLabel: SMART MAT PLANON PLUS 60X30 TW + vendorId: 0x1189 + productId: 0x0BAE + deviceProfileName: light-level-colorTemperature + - id: "4489/3047" + deviceLabel: SMART WIFI MAT TRACKSP OSAKA TW + vendorId: 0x1189 + productId: 0x0BE7 + deviceProfileName: light-level-colorTemperature + - id: "4489/3031" + deviceLabel: SMART WIFI MAT MAGNET 600X300 TW + vendorId: 0x1189 + productId: 0x0BD7 + deviceProfileName: light-level-colorTemperature + - id: "4489/3034" + deviceLabel: SMART WIFI MAT DISC 300 IP44 TW + vendorId: 0x1189 + productId: 0x0BDA + deviceProfileName: light-level-colorTemperature + - id: "4489/3030" + deviceLabel: SMART WIFI MAT MAGNET 450X450 TW + vendorId: 0x1189 + productId: 0x0BD6 + deviceProfileName: light-level-colorTemperature + - id: "4489/3022" + deviceLabel: SMART WIFI MAT AQUA200X200IP44TW + vendorId: 0x1189 + productId: 0x0BCE + deviceProfileName: light-level-colorTemperature + - id: "4489/2993" + deviceLabel: SMART MAT PLANON FL 30X30 TW + vendorId: 0x1189 + productId: 0x0BB1 + deviceProfileName: light-level-colorTemperature + - id: "4489/3048" + deviceLabel: SMART WIFI MAT TRACKSP CIRCLE TW + vendorId: 0x1189 + productId: 0x0BE8 + deviceProfileName: light-level-colorTemperature + - id: "4489/2997" + deviceLabel: SMART WIFI MAT DL SLIM 85 TW WT + vendorId: 0x1189 + productId: 0x0BB5 + deviceProfileName: light-level-colorTemperature + - id: "4489/2949" + deviceLabel: SMART WIFI MAT SPIRAL 500 TW + vendorId: 0x1189 + productId: 0x0B85 + deviceProfileName: light-level-colorTemperature + - id: "4489/3036" + deviceLabel: SMART WFMTDECORWALLSWAN200X200TW + vendorId: 0x1189 + productId: 0x0BDC + deviceProfileName: light-level-colorTemperature + - id: "4489/2995" + deviceLabel: SMART MAT PLANON FL 120X10 TW + vendorId: 0x1189 + productId: 0x0BB3 + deviceProfileName: light-level-colorTemperature + - id: "4489/2987" + deviceLabel: SMART MAT PLANON PLUS 45X45 TW + vendorId: 0x1189 + productId: 0x0BAB + deviceProfileName: light-level-colorTemperature + - id: "4489/3018" + deviceLabel: SMART WFMTDECORWALLWOOD210X110TW + vendorId: 0x1189 + productId: 0x0BCA + deviceProfileName: light-level-colorTemperature + - id: "4489/3019" + deviceLabel: SMART MATDECORWALLTWIST230X127TW + vendorId: 0x1189 + productId: 0x0BCB + deviceProfileName: light-level-colorTemperature + - id: "4489/3055" + deviceLabel: SMART WIFI MAT FLOOD 50W RGBW + vendorId: 0x1189 + productId: 0x0BEF + deviceProfileName: light-color-level + - id: "4489/2970" + deviceLabel: SMART MAT PLANON PLUS60X60RGBTW + vendorId: 0x1189 + productId: 0x0B9A + deviceProfileName: light-color-level + - id: "4489/3057" + deviceLabel: SMART WIFI MAT FLOOD 20W RGBW + vendorId: 0x1189 + productId: 0x0BF1 + deviceProfileName: light-color-level + - id: "4489/2973" + deviceLabel: SMART MAT PLANON PLUS45X45RGBW + vendorId: 0x1189 + productId: 0x0B9D + deviceProfileName: light-color-level + - id: "4489/2974" + deviceLabel: SMART MAT PLANON PLUS60X30RGBW + vendorId: 0x1189 + productId: 0x0B9E + deviceProfileName: light-color-level + - id: "4489/3059" + deviceLabel: SMART WIFI MAT CLARIA 490RGBTW + vendorId: 0x1189 + productId: 0x0BF3 + deviceProfileName: light-color-level + - id: "4489/3058" + deviceLabel: SMART WIFI MAT FLOOD 30W RGBW + vendorId: 0x1189 + productId: 0x0BF2 + deviceProfileName: light-color-level + - id: "4489/2972" + deviceLabel: SMART MAT PLANON PLUS60X60RGBW + vendorId: 0x1189 + productId: 0x0B9C + deviceProfileName: light-color-level + - id: "4489/2984" + deviceLabel: SMART MAT PLANON PLUS120X30RGBW + vendorId: 0x1189 + productId: 0x0BA8 + deviceProfileName: light-color-level + - id: "4489/3001" + deviceLabel: SMART WIFI MAT SP170 110DEGRGBTW + vendorId: 0x1189 + productId: 0x0BB9 + deviceProfileName: light-color-level + - id: "4489/2992" + deviceLabel: SMART MAT PLANON PLUS30X30RGBW + vendorId: 0x1189 + productId: 0x0BB0 + deviceProfileName: light-color-level + - id: "4489/3056" + deviceLabel: SMART WIFI MAT FLOOD 10W RGBW + vendorId: 0x1189 + productId: 0x0BF0 + deviceProfileName: light-color-level + - id: "4489/3016" + deviceLabel: SMART OUTD MAT BEAMADJWALL RGBTW + vendorId: 0x1189 + productId: 0x0BC8 + deviceProfileName: light-color-level + - id: "4489/3000" + deviceLabel: SMART WIFI MAT SP 86 100DEGRGBTW + vendorId: 0x1189 + productId: 0x0BB8 + deviceProfileName: light-color-level + - id: "4489/3003" + deviceLabel: SMART WIFI MAT STEA485RD RGBTW + vendorId: 0x1189 + productId: 0x0BBB + deviceProfileName: light-color-level + - id: "4489/2963" + deviceLabel: SMART WIFI MAT FLOOD 100W RGBW + vendorId: 0x1189 + productId: 0x0B93 + deviceProfileName: light-color-level + - id: "4489/2194" + deviceLabel: SMART WIFI MATTER WALL SWITCH 1G + vendorId: 0x1189 + productId: 0x0892 + deviceProfileName: switch-binary + - id: "4489/4097" + deviceLabel: OSRAM PLANON SLIM + vendorId: 0x1189 + productId: 0x1001 + deviceProfileName: light-color-level +#Shelly + - id: "5264/1" + deviceLabel: Shelly Plug S MTR Gen3 + vendorId: 0x1490 + productId: 0x0001 + deviceProfileName: plug-binary + - id: "5264/6227" + deviceLabel: Shelly Outdoor Plug S Gen3 + vendorId: 0x1490 + productId: 0x1853 + deviceProfileName: plug-binary + - id: "5264/4121" + deviceLabel: Shelly 1PM Gen3 + vendorId: 0x1490 + productId: 0x1019 + deviceProfileName: plug-binary + - id: "5264/4120" + deviceLabel: Shelly 1 Gen3 + vendorId: 0x1490 + productId: 0x1018 + deviceProfileName: plug-binary + - id: "5264/4115" + deviceLabel: Shelly 2L Gen3 + vendorId: 0x1490 + productId: 0x1013 + deviceProfileName: plug-binary + - id: "5264/4116" + deviceLabel: Shelly 1L Gen3 + vendorId: 0x1490 + productId: 0x1014 + deviceProfileName: plug-binary + - id: "5264/4117" + deviceLabel: Shelly 1 Mini Gen3 + vendorId: 0x1490 + productId: 0x1015 + deviceProfileName: plug-binary + - id: "5264/4118" + deviceLabel: Shelly 1PM Mini Gen3 + vendorId: 0x1490 + productId: 0x1016 + deviceProfileName: plug-binary + - id: "5264/4137" + deviceLabel: Shelly 1PM Gen4 + vendorId: 0x1490 + productId: 0x1029 + deviceProfileName: plug-power-energy-powerConsumption + - id: "5264/4101" + deviceLabel: Shelly 2PM Gen3 + vendorId: 0x1490 + productId: 0x1005 + deviceProfileName: plug-power-energy-powerConsumption + +#SmartSetup + - id: "5385/4" + deviceLabel: SmartSetup Smart Multi-Switch + vendorId: 0x1509 + productId: 0x0004 + deviceProfileName: switch-binary + - id: "5385/1" + deviceLabel: SmartSetup Smart Switch + vendorId: 0x1509 + productId: 0x0001 + deviceProfileName: switch-binary + #SONOFF - id: "SONOFF MINIR4M" deviceLabel: Smart Plug-in Unit @@ -242,42 +2223,22 @@ matterManufacturer: productId: 0x9002 deviceProfileName: light-color-level-2700K-6500K -#AiDot - - id: "Linkind/Smart/Light/Bulb/A19/RGBTW" - deviceLabel: Linkind Smart Light Bulb A19 RGBTW - vendorId: 0x1168 - productId: 0x03e8 - deviceProfileName: light-color-level-1800K-6500K - - id: "OREiN/Smart/Light/Bulb/A19/RGBTW" - deviceLabel: OREiN Smart Light Bulb A19 RGBTW - vendorId: 0x1168 - productId: 0x03ea - deviceProfileName: light-color-level-1800K-6500K - - id: "Linkind/Smart/Light/Bulb/BR30/RGBTW" - deviceLabel: Linkind Smart Light Bulb BR30 RGBTW - vendorId: 0x1168 - productId: 0x03eb - deviceProfileName: light-color-level-1800K-6500K - - id: "OREiN/Smart/Light/Bulb/BR30/RGBTW" - deviceLabel: OREiN Smart Light Bulb BR30 RGBTW - vendorId: 0x1168 - productId: 0x03ec - deviceProfileName: light-color-level-1800K-6500K - - id: "Consciot/Smart/Light/Bulb/A19/RGBT" - deviceLabel: Consciot Smart Light Bulb A19 RGBT - vendorId: 0x1168 - productId: 0x0405 - deviceProfileName: light-color-level-1800K-6500K - - id: "AiDot/Smart/Light/Bulb/A19/RGBTW" - deviceLabel: AiDot Smart Light Bulb A19 RGBT - vendorId: 0x1168 - productId: 0x03f8 - deviceProfileName: light-color-level-1800K-6500K - - id: "4456/1011" - deviceLabel: Smart Light Bulb A60 - vendorId: 0x1168 - productId: 0x03F3 - deviceProfileName: light-color-level-1800K-6500K +#U-Tec + - id: "5247/3" + deviceLabel: U-tec Smart Matter Plug + vendorId: 0x147F + productId: 0x0003 + deviceProfileName: plug-binary + - id: "5247/4" + deviceLabel: U-tec Smart Matter Switch + vendorId: 0x147F + productId: 0x0004 + deviceProfileName: switch-binary + - id: "5247/2" + deviceLabel: UTEC Smart Matter Light Bulb + vendorId: 0x147F + productId: 0x0002 + deviceProfileName: light-color-level-2700K-6500K #WiZ - id: "WiZ A19" deviceLabel: WiZ A19 @@ -2074,12 +4035,110 @@ matterManufacturer: vendorId: 0x100b productId: 0x228F deviceProfileName: light-color-level-2200K-6500K + - id: "4107/8954" + deviceLabel: WiZ Gradient Light Bars + vendorId: 0x100B + productId: 0x22FA + deviceProfileName: light-color-level + - id: "4107/8953" + deviceLabel: WiZ Gradient Light Bars + vendorId: 0x100B + productId: 0x22F9 + deviceProfileName: light-color-level + - id: "4107/8949" + deviceLabel: WiZ Gradient Floor lamp + vendorId: 0x100B + productId: 0x22F5 + deviceProfileName: light-color-level + - id: "4107/8948" + deviceLabel: WiZ Gradient Floor lamp + vendorId: 0x100B + productId: 0x22F4 + deviceProfileName: light-color-level #Zemismart - id: "5020/61154" deviceLabel: Zemismart Inline Module vendorId: 0x139C productId: 0xEEE2 deviceProfileName: switch-binary + - id: "5020/53249" + deviceLabel: Zemismart Downlight + vendorId: 0x139C + productId: 0xD001 + deviceProfileName: light-color-level + - id: "5020/53250" + deviceLabel: Zemismart Bulb + vendorId: 0x139C + productId: 0xD002 + deviceProfileName: light-color-level + - id: "5020/53251" + deviceLabel: Zemismart LED Light Strip Driver + vendorId: 0x139C + productId: 0xD003 + deviceProfileName: light-color-level + - id: "5020/43784" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB08 + deviceProfileName: switch-binary + - id: "5020/43825" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB31 + deviceProfileName: switch-binary + - id: "5020/43780" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB04 + deviceProfileName: switch-binary + - id: "5020/43777" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB01 + deviceProfileName: switch-binary + - id: "5020/43843" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB43 + deviceProfileName: switch-binary + - id: "5020/904" + deviceLabel: Zemismart M6 Hub + vendorId: 0x139C + productId: 0x0388 + deviceProfileName: matter-bridge + - id: "5020/43778" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB02 + deviceProfileName: switch-binary + - id: "5020/43779" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB03 + deviceProfileName: switch-binary + - id: "5020/43781" + deviceLabel: Zemismart WiFi Smart Switch + vendorId: 0x139C + productId: 0xAB05 + deviceProfileName: switch-binary + - id: "5020/903" + deviceLabel: Zemismart M1 Hub + vendorId: 0x139C + productId: 0x0387 + deviceProfileName: matter-bridge +#Zimi + - id: "5410/3" + deviceLabel: Zimi Matter Connect + vendorId: 0x1522 + productId: 0x0003 + deviceProfileName: matter-bridge +#TUO + - id: "5150/1" + deviceLabel: "TUO Smart Button" + vendorId: 0x141E + productId: 0x0001 + deviceProfileName: "button-battery" + #Bridge devices need manufacturer specific fingerprints until #bridge support is released to all hubs. This is because of the way generic @@ -2124,6 +4183,33 @@ matterManufacturer: vendorId: 0x138A productId: 0x1392 deviceProfileName: matter-bridge + - id: "4932/1054" + deviceLabel: Eltako Matter Bridge + vendorId: 0x1344 + productId: 0x041E + deviceProfileName: matter-bridge +#Tapo + - id: "5010/269" + deviceLabel: Tapo Smart Power Strip + vendorId: 0x1392 + productId: 0x010D + deviceProfileName: plug-binary + - id: "5010/262" + deviceLabel: Mini Smart Wi-Fi Plug Energy Monitoring Matter + vendorId: 0x1392 + productId: 0x0106 + deviceProfileName: plug-power-energy-powerConsumption + - id: "5010/276" + deviceLabel: Smart WiFi Outdoor Plug + vendorId: 0x1392 + productId: 0x0114 + deviceProfileName: plug-energy-powerConsumption +#GIANT LIGHTING + - id: "5707/1" + deviceLabel: Floor Lamp + vendorId: 0x164B + productId: 0x0001 + deviceProfileName: light-color-level matterGeneric: @@ -2195,6 +4281,108 @@ matterGeneric: deviceTypes: - id: 0x010B # Dimmable Plug-in Unit deviceProfileName: plug-level + - id: "matter/on-off/plug/electrical-sensor" + deviceLabel: Matter OnOff Plug + deviceTypes: + - id: 0x010A # On Off Plug-in Unit + - id: 0x0510 # Electrical Sensor + deviceProfileName: plug-power-energy-powerConsumption + - id: "matter/mounted/on-off/control" + deviceLabel: Matter Mounted OnOff Control + deviceTypes: + - id: 0x010F # Mounted On/Off Control + deviceProfileName: switch-binary + - id: "matter/mounted/dimmable/load/control" + deviceLabel: Matter Mounted Dimmable Load Control + deviceTypes: + - id: 0x0110 # Mounted Dimmable Load Control + deviceProfileName: switch-level + - id: "matter/irrigation-system" + deviceLabel: Matter Irrigation System + deviceTypes: + - id: 0x0040 # Irrigation System + deviceProfileName: irrigation-system + - id: "matter/water-valve" + deviceLabel: Matter Water Valve + deviceTypes: + - id: 0x0042 # Water Valve + deviceProfileName: water-valve + - id: "button" + deviceLabel: Matter Button + deviceTypes: + - id: 0x000F + deviceProfileName: button-battery # err on the side of buttons having batteries, it'll get fixed in the driver + - id: "matter/dimmable/light/button" + deviceLabel: Matter Dimmable Light/Button + deviceTypes: + - id: 0x0101 # Dimmable Light + - id: 0x000F # Generic Switch + deviceProfileName: light-level-button + - id: "matter/color/light/fan" + deviceLabel: Matter Color Light Fan + deviceTypes: + - id: 0x010D # Extended Color Light + - id: 0x002B # Fan + deviceProfileName: fan-modular + - id: "matter/dimmable/light/motion" + deviceLabel: Matter Dimmable Light Occupancy Sensor + deviceTypes: + - id: 0x0101 # Dimmable Light + - id: 0x0107 # Occupancy Sensor + deviceProfileName: light-level-motion + - id: "matter/camera" + deviceLabel: Matter Camera + deviceTypes: + - id: 0x0142 # Camera + deviceProfileName: camera + - id: "matter/on-off/fan/light" + deviceLabel: Matter OnOff Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x0100 # OnOff Light + deviceProfileName: fan-modular + - id: "matter/dimmable/fan/light" + deviceLabel: Matter Dimmable Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x0101 # Dimmable Light + deviceProfileName: fan-modular + - id: "matter/colorTemperature/fan/light" + deviceLabel: Matter Color Temperature Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x010C # Color Temperature Light + deviceProfileName: fan-modular + - id: "matter/color/fan/light" + deviceLabel: Matter Color Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x010D # Extended Color Light + deviceProfileName: fan-modular + - id: "matter/on-off/fan/plug" + deviceLabel: Matter OnOff Fan Plug + deviceTypes: + - id: 0x002B # Fan + - id: 0x010A # On Off Plug-in Unit + deviceProfileName: fan-modular + - id: "matter/dimmable/fan/plug" + deviceLabel: Matter Dimmable Fan Plug + deviceTypes: + - id: 0x002B # Fan + - id: 0x010B # Dimmable Plug-in Unit + deviceProfileName: fan-modular + - id: "matter/mounted/on-off/control/fan" + deviceLabel: Matter Mounted OnOff Control Fan + deviceTypes: + - id: 0x002B # Fan + - id: 0x010F # Mounted On/Off Control + deviceProfileName: fan-modular + - id: "matter/mounted/dim/load/control/fan" + deviceLabel: Matter Mounted Dimmable Load Control Fan + deviceTypes: + - id: 0x002B # Fan + - id: 0x0110 # Mounted Dimmable Load Control + deviceProfileName: fan-modular matterThing: - id: SmartThings/MatterThing diff --git a/drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml b/drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml new file mode 100644 index 0000000000..6d6550ff17 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml @@ -0,0 +1,79 @@ +name: 12-button-keyboard +components: + - id: main + label: F1 + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: F2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F9 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F10 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F11 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F12 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/2-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/2-button-battery.yml new file mode 100644 index 0000000000..e8340b26cc --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/2-button-battery.yml @@ -0,0 +1,20 @@ +name: 2-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/2-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/2-button-batteryLevel.yml new file mode 100644 index 0000000000..94e44264ee --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/2-button-batteryLevel.yml @@ -0,0 +1,20 @@ +name: 2-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/2-button.yml b/drivers/SmartThings/matter-switch/profiles/2-button.yml new file mode 100644 index 0000000000..1faf2dfbed --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/2-button.yml @@ -0,0 +1,18 @@ +name: 2-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml b/drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml new file mode 100644 index 0000000000..2fa77164ef --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml @@ -0,0 +1,99 @@ +name: 3-button-battery-temperature-humidity +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: TempHumiditySensor +- id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + - component: button1 + capability: button + version: 1 + - component: button2 + capability: button + version: 1 + - component: button3 + capability: button + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: button1 + capability: button + version: 1 + - component: button2 + capability: button + version: 1 + - component: button3 + capability: button + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-switch/profiles/3-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/3-button-battery.yml new file mode 100644 index 0000000000..e73104374e --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/3-button-battery.yml @@ -0,0 +1,26 @@ +name: 3-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/3-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/3-button-batteryLevel.yml new file mode 100644 index 0000000000..1e44719f28 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/3-button-batteryLevel.yml @@ -0,0 +1,26 @@ +name: 3-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/3-button-motion.yml b/drivers/SmartThings/matter-switch/profiles/3-button-motion.yml new file mode 100644 index 0000000000..2852dd2ccd --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/3-button-motion.yml @@ -0,0 +1,26 @@ +name: 3-button-motion +components: + - id: main + capabilities: + - id: button + version: 1 + - id: motionSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/profiles/3-button.yml b/drivers/SmartThings/matter-switch/profiles/3-button.yml new file mode 100644 index 0000000000..e17b159ae0 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/3-button.yml @@ -0,0 +1,24 @@ +name: 3-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/4-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/4-button-battery.yml new file mode 100644 index 0000000000..000de4edf2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/4-button-battery.yml @@ -0,0 +1,32 @@ +name: 4-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/4-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/4-button-batteryLevel.yml new file mode 100644 index 0000000000..d720e19b51 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/4-button-batteryLevel.yml @@ -0,0 +1,32 @@ +name: 4-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/4-button.yml b/drivers/SmartThings/matter-switch/profiles/4-button.yml new file mode 100644 index 0000000000..d2fb7b4410 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/4-button.yml @@ -0,0 +1,30 @@ +name: 4-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/5-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/5-button-battery.yml new file mode 100644 index 0000000000..fe8efd9053 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/5-button-battery.yml @@ -0,0 +1,38 @@ +name: 5-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/5-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/5-button-batteryLevel.yml new file mode 100644 index 0000000000..1772f7c0ef --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/5-button-batteryLevel.yml @@ -0,0 +1,38 @@ +name: 5-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/5-button.yml b/drivers/SmartThings/matter-switch/profiles/5-button.yml new file mode 100644 index 0000000000..0ded733b29 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/5-button.yml @@ -0,0 +1,36 @@ +name: 5-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/6-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/6-button-battery.yml new file mode 100644 index 0000000000..8ec3642e81 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/6-button-battery.yml @@ -0,0 +1,44 @@ +name: 6-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/6-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/6-button-batteryLevel.yml new file mode 100644 index 0000000000..f05d8efb71 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/6-button-batteryLevel.yml @@ -0,0 +1,44 @@ +name: 6-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/6-button-motion.yml b/drivers/SmartThings/matter-switch/profiles/6-button-motion.yml new file mode 100644 index 0000000000..f191e95fdc --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/6-button-motion.yml @@ -0,0 +1,44 @@ +name: 6-button-motion +components: + - id: main + capabilities: + - id: button + version: 1 + - id: motionSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/profiles/6-button.yml b/drivers/SmartThings/matter-switch/profiles/6-button.yml new file mode 100644 index 0000000000..248ddd2bcb --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/6-button.yml @@ -0,0 +1,42 @@ +name: 6-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/7-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/7-button-battery.yml new file mode 100644 index 0000000000..51c08463f0 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/7-button-battery.yml @@ -0,0 +1,50 @@ +name: 7-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/7-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/7-button-batteryLevel.yml new file mode 100644 index 0000000000..7c10563d5e --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/7-button-batteryLevel.yml @@ -0,0 +1,50 @@ +name: 7-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/7-button.yml b/drivers/SmartThings/matter-switch/profiles/7-button.yml new file mode 100644 index 0000000000..c9e3fc008d --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/7-button.yml @@ -0,0 +1,48 @@ +name: 7-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/8-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/8-button-battery.yml new file mode 100644 index 0000000000..ec9f1a46cc --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/8-button-battery.yml @@ -0,0 +1,57 @@ +name: 8-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + diff --git a/drivers/SmartThings/matter-switch/profiles/8-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/8-button-batteryLevel.yml new file mode 100644 index 0000000000..5da4aa6fa0 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/8-button-batteryLevel.yml @@ -0,0 +1,57 @@ +name: 8-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + diff --git a/drivers/SmartThings/matter-switch/profiles/8-button.yml b/drivers/SmartThings/matter-switch/profiles/8-button.yml new file mode 100644 index 0000000000..5fc93a51bc --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/8-button.yml @@ -0,0 +1,55 @@ +name: 8-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + diff --git a/drivers/SmartThings/matter-switch/profiles/9-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/9-button-battery.yml new file mode 100644 index 0000000000..8aa7df8e39 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/9-button-battery.yml @@ -0,0 +1,63 @@ +name: 9-button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button9 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + diff --git a/drivers/SmartThings/matter-switch/profiles/9-button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/9-button-batteryLevel.yml new file mode 100644 index 0000000000..ae90de2d01 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/9-button-batteryLevel.yml @@ -0,0 +1,62 @@ +name: 9-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button9 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/9-button.yml b/drivers/SmartThings/matter-switch/profiles/9-button.yml new file mode 100644 index 0000000000..3894a40d13 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/9-button.yml @@ -0,0 +1,60 @@ +name: 9-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button9 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/button-battery.yml b/drivers/SmartThings/matter-switch/profiles/button-battery.yml new file mode 100644 index 0000000000..b614393f1e --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/button-battery.yml @@ -0,0 +1,14 @@ +name: button-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/matter-switch/profiles/button-batteryLevel.yml b/drivers/SmartThings/matter-switch/profiles/button-batteryLevel.yml new file mode 100644 index 0000000000..df891ab0d2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/button-batteryLevel.yml @@ -0,0 +1,14 @@ +name: button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/matter-switch/profiles/button.yml b/drivers/SmartThings/matter-switch/profiles/button.yml new file mode 100644 index 0000000000..6c6bced1c2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/button.yml @@ -0,0 +1,12 @@ +name: button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/matter-switch/profiles/camera.yml b/drivers/SmartThings/matter-switch/profiles/camera.yml new file mode 100644 index 0000000000..1f911a9e10 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/camera.yml @@ -0,0 +1,159 @@ +name: camera +components: + - id: main + capabilities: + - id: webrtc + version: 1 + optional: true + - id: videoCapture2 + version: 1 + optional: true + - id: videoStreamSettings + version: 1 + optional: true + - id: imageCapture + version: 1 + optional: true + - id: mechanicalPanTiltZoom + version: 1 + optional: true + - id: hdr + version: 1 + optional: true + - id: nightVision + version: 1 + optional: true + - id: imageControl + version: 1 + optional: true + - id: audioRecording + version: 1 + optional: true + - id: sounds + version: 1 + optional: true + - id: cameraPrivacyMode + version: 1 + optional: true + - id: zoneManagement + version: 1 + optional: true + - id: localMediaStorage + version: 1 + optional: true + - id: cameraViewportSettings + version: 1 + optional: true + - id: motionSensor + version: 1 + optional: true + - id: vision.clipAnalysis + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Camera + - id: statusLed + optional: true + capabilities: + - id: switch + version: 1 + optional: true + - id: mode + version: 1 + optional: true + - id: speaker + optional: true + capabilities: + - id: audioMute + version: 1 + optional: true + - id: audioVolume + version: 1 + optional: true + - id: microphone + optional: true + capabilities: + - id: audioMute + version: 1 + optional: true + - id: audioVolume + version: 1 + optional: true + - id: doorbell + optional: true + capabilities: + - id: button + version: 1 + optional: true +deviceConfig: + dashboard: + states: + - component: main + capability: imageCapture + version: 1 + values: + - label: "{{___PO_CODE_SAMSUNGELECTRONICS.IM_DEFAULT_IMAGE_CAPTURE}}" + visibleCondition: + component: main + capability: imageCapture + version: 1 + value: captureTime.value + valueType: string + operator: CONTAINS + operand: T + isOffline: false + basicPlus: + - displayType: camera + camera: + image: + component: main + capability: imageCapture + version: 1 + value: image.value + overlayIcons: + - iconUrl: "res://ic_camera_motion_detected" + visibleCondition: + component: main + capability: motionSensor + version: 1 + value: motion.value + valueType: string + operator: EQUALS + operand: "active" + detailView: + - component: main + capability: webrtc + version: 1 + - component: main + capability: mechanicalPanTiltZoom + version: 1 + - component: main + capability: motionSensor + version: 1 + - component: main + capability: videoCapture2 + version: 1 + automation: + conditions: + - component: main + capability: motionSensor + version: 1 + actions: + - component: main + capability: videoCapture2 + version: 1 + - component: main + capability: imageCapture + version: 1 + dpInfo: + - os: ios + dpUri: "storyboard://HMVSController/HMVSViewController" + - os: android + dpUri: "plugin://com.samsung.android.plugin.camera" +metadata: + mnmn: SmartThingsEdge + vid: matter-camera diff --git a/drivers/SmartThings/matter-switch/profiles/cube-t1-pro.yml b/drivers/SmartThings/matter-switch/profiles/cube-t1-pro.yml new file mode 100644 index 0000000000..1558c9cd11 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/cube-t1-pro.yml @@ -0,0 +1,22 @@ +name: cube-t1-pro +components: + - id: main + capabilities: + - id: stse.cubeAction + version: 1 + - id: stse.cubeFace + version: 1 + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_CubeT1Pro + diff --git a/drivers/SmartThings/matter-switch/profiles/fan-modular.yml b/drivers/SmartThings/matter-switch/profiles/fan-modular.yml new file mode 100644 index 0000000000..878c9ed615 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/fan-modular.yml @@ -0,0 +1,17 @@ +name: fan-modular +components: +- id: main + capabilities: + - id: fanMode + version: 1 + optional: true + - id: fanSpeedPercent + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan + diff --git a/drivers/SmartThings/matter-switch/profiles/garage-door-battery.yml b/drivers/SmartThings/matter-switch/profiles/garage-door-battery.yml new file mode 100644 index 0000000000..bbab3c16ad --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/garage-door-battery.yml @@ -0,0 +1,14 @@ +name: garage-door-battery +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: GarageDoor diff --git a/drivers/SmartThings/matter-switch/profiles/ikea-2-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/ikea-2-button-battery.yml new file mode 100644 index 0000000000..011ecc1683 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/ikea-2-button-battery.yml @@ -0,0 +1,56 @@ +name: ikea-2-button-battery +components: + - id: main + label: Button 1 + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + label: Button 2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +deviceConfig: + icons: + - group: main + iconUrl: 'icon://button_multi' + dashboard: + states: + - component: main + capability: button + version: 1 + detailView: + - component: main + capability: button + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + - component: button2 + capability: button + version: 1 + automation: + conditions: + - component: main + capability: button + version: 1 + - component: main + capability: battery + version: 1 + - component: button2 + capability: button + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml b/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml new file mode 100644 index 0000000000..1793ae209e --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml @@ -0,0 +1,45 @@ +name: ikea-scroll +components: + - id: main + label: Group 1 + capabilities: + - id: button + version: 1 + - id: knob + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button + - id: group2 + label: Group 2 + capabilities: + - id: button + version: 1 + - id: knob + version: 1 + categories: + - name: Button + - id: group3 + label: Group 3 + capabilities: + - id: button + version: 1 + - id: knob + version: 1 + categories: + - name: Button +preferences: + - preferenceId: knobSensitivityGroup1 + explicit: true + - preferenceId: knobSensitivityGroup2 + explicit: true + - preferenceId: knobSensitivityGroup3 + explicit: true +metadata: + mnmn: SmartThingsEdge + vid: ikea-scroll diff --git a/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml new file mode 100644 index 0000000000..15896fbfee --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml @@ -0,0 +1,26 @@ +name: irrigation-system +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: level + version: 1 + config: + values: + - key: "level.value" + range: [0, 100] + optional: true + - id: flowMeasurement + version: 1 + optional: true + - id: operationalState + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Irrigation + diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml index 1638962b8f..a0f5196b12 100755 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-color-level-1800K-6500K components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1800, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml index 3fb0742f0d..7424d241e2 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-color-level-2000K-7000K components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2000, 7000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml index f6c2269431..a098cdd06f 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-color-level-2200K-6500K components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml index ebdaa520c1..1b469ae8e8 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-color-level-2700K-6500K components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml new file mode 100644 index 0000000000..1b1129e8be --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml @@ -0,0 +1,35 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: light-color-level-fan +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: colorTemperature + version: 1 + config: + values: + - key: "colorTemperature.value" + range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 + - id: colorControl + version: 1 + - id: fanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml index 0b815134b3..fb7a4fca94 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-color-level-illuminance-motion-1000K-15000K components: - id: main @@ -6,12 +7,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1000, 15000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: motionSensor diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml index 999e64a047..50f68f60fc 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml @@ -6,12 +6,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: motionSensor diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level.yml index 572686ffdd..2f0f673bee 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/light-energy-powerConsumption.yml new file mode 100644 index 0000000000..1f62938b9d --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-energy-powerConsumption.yml @@ -0,0 +1,16 @@ +name: light-energy-powerConsumption +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml new file mode 100644 index 0000000000..6a4fbcc9d0 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml @@ -0,0 +1,32 @@ +name: light-level-2-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml new file mode 100644 index 0000000000..9e3106d9de --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml @@ -0,0 +1,38 @@ +name: light-level-3-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml new file mode 100644 index 0000000000..43bcec5955 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml @@ -0,0 +1,44 @@ +name: light-level-4-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml new file mode 100644 index 0000000000..2b98b9784d --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml @@ -0,0 +1,50 @@ +name: light-level-5-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml new file mode 100644 index 0000000000..230b2ea341 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml @@ -0,0 +1,56 @@ +name: light-level-6-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml new file mode 100644 index 0000000000..13b3ee9443 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml @@ -0,0 +1,62 @@ +name: light-level-7-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml new file mode 100644 index 0000000000..a2dc732257 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml @@ -0,0 +1,68 @@ +name: light-level-8-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml b/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml index e2542bef5c..effe5f93b6 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-level-colorTemperature-1500k-9000k components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1500, 9000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-battery-illuminance-motion-temperature.yml b/drivers/SmartThings/matter-switch/profiles/light-level-battery-illuminance-motion-temperature.yml new file mode 100644 index 0000000000..fb1cd099a4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-battery-illuminance-motion-temperature.yml @@ -0,0 +1,32 @@ +name: light-level-battery-illuminance-motion-temperature +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: motionSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true + diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-button.yml new file mode 100644 index 0000000000..8334e32feb --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-button.yml @@ -0,0 +1,26 @@ +name: light-level-button +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light + - id: button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml index 2348027636..2541fddc09 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-level-colorTemperature-2200K-6500K components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml new file mode 100644 index 0000000000..7c845e8238 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml @@ -0,0 +1,29 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: light-level-colorTemperature-2700K-6500K +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: colorTemperature + version: 1 + config: + values: + - key: "colorTemperature.value" + range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml index 46a6444e92..5ccc67ab4c 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-level-colorTemperature-2710k-6500k components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2710, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml index 4d130281d7..5681a97e9e 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml new file mode 100644 index 0000000000..b2ddcc0a60 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml @@ -0,0 +1,24 @@ +name: light-level-energy-powerConsumption +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-motion.yml b/drivers/SmartThings/matter-switch/profiles/light-level-motion.yml new file mode 100644 index 0000000000..54ce30abdf --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-motion.yml @@ -0,0 +1,22 @@ +name: light-level-motion +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: motionSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml new file mode 100755 index 0000000000..31c6d44ee1 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml @@ -0,0 +1,26 @@ +name: light-level-power-energy-powerConsumption +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-power.yml b/drivers/SmartThings/matter-switch/profiles/light-level-power.yml new file mode 100644 index 0000000000..86d61c0345 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-power.yml @@ -0,0 +1,22 @@ +name: light-level-power +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: powerMeter + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/light-level.yml b/drivers/SmartThings/matter-switch/profiles/light-level.yml index e266f497c9..3b286f8262 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml new file mode 100644 index 0000000000..1e64f63594 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml @@ -0,0 +1,18 @@ +name: light-power-energy-powerConsumption +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/light-power.yml b/drivers/SmartThings/matter-switch/profiles/light-power.yml new file mode 100644 index 0000000000..27e07d0a49 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-power.yml @@ -0,0 +1,14 @@ +name: light-power +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml b/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml new file mode 100644 index 0000000000..dfe052043c --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml @@ -0,0 +1,14 @@ +name: motion-illuminance +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/SmartThings/matter-switch/profiles/plug-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/plug-energy-powerConsumption.yml new file mode 100644 index 0000000000..9c44a4d29f --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/plug-energy-powerConsumption.yml @@ -0,0 +1,16 @@ +name: plug-energy-powerConsumption +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml new file mode 100644 index 0000000000..4ae29d2320 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml @@ -0,0 +1,20 @@ +name: plug-level-energy-powerConsumption +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: statelessSwitchLevelStep + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml new file mode 100644 index 0000000000..477fb8b769 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml @@ -0,0 +1,22 @@ +name: plug-level-power-energy-powerConsumption +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: statelessSwitchLevelStep + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/matter-switch/profiles/m5stack.yml b/drivers/SmartThings/matter-switch/profiles/plug-level-power.yml similarity index 63% rename from drivers/SmartThings/matter-switch/profiles/m5stack.yml rename to drivers/SmartThings/matter-switch/profiles/plug-level-power.yml index edf662b462..8bb1c56361 100644 --- a/drivers/SmartThings/matter-switch/profiles/m5stack.yml +++ b/drivers/SmartThings/matter-switch/profiles/plug-level-power.yml @@ -1,4 +1,4 @@ -name: m5stack +name: plug-level-power components: - id: main capabilities: @@ -6,17 +6,13 @@ components: version: 1 - id: switchLevel version: 1 - - id: firmwareUpdate + - id: statelessSwitchLevelStep version: 1 - - id: refresh + - id: powerMeter version: 1 - categories: - - name: Switch -- id: switch2 - capabilities: - - id: switch + - id: firmwareUpdate version: 1 - id: refresh version: 1 categories: - - name: Switch + - name: SmartPlug diff --git a/drivers/SmartThings/matter-switch/profiles/plug-level.yml b/drivers/SmartThings/matter-switch/profiles/plug-level.yml index 0d888b843f..90fa2ece47 100644 --- a/drivers/SmartThings/matter-switch/profiles/plug-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/plug-level.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/plug-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/plug-power-energy-powerConsumption.yml new file mode 100644 index 0000000000..5d09912dd3 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/plug-power-energy-powerConsumption.yml @@ -0,0 +1,18 @@ +name: plug-power-energy-powerConsumption +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/matter-switch/profiles/plug-power.yml b/drivers/SmartThings/matter-switch/profiles/plug-power.yml new file mode 100644 index 0000000000..018fba88ee --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/plug-power.yml @@ -0,0 +1,14 @@ +name: plug-power +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/matter-switch/profiles/switch-2.yml b/drivers/SmartThings/matter-switch/profiles/switch-2.yml index 45fac9402f..f2a1f35e87 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-2.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-2.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: switch-2 components: - id: main diff --git a/drivers/SmartThings/matter-switch/profiles/switch-3.yml b/drivers/SmartThings/matter-switch/profiles/switch-3.yml index 050675b28c..a9a0756287 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-3.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-3.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: switch-3 components: - id: main diff --git a/drivers/SmartThings/matter-switch/profiles/switch-4.yml b/drivers/SmartThings/matter-switch/profiles/switch-4.yml index fd81619b44..c6e526a0a7 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-4.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-4.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: switch-4 components: - id: main diff --git a/drivers/SmartThings/matter-switch/profiles/switch-5.yml b/drivers/SmartThings/matter-switch/profiles/switch-5.yml index 5971405fe5..257da9cdfb 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-5.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-5.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: switch-5 components: - id: main diff --git a/drivers/SmartThings/matter-switch/profiles/switch-6.yml b/drivers/SmartThings/matter-switch/profiles/switch-6.yml index 882738a9d6..3e5136dc52 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-6.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-6.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: switch-6 components: - id: main diff --git a/drivers/SmartThings/matter-switch/profiles/switch-7.yml b/drivers/SmartThings/matter-switch/profiles/switch-7.yml index bbc075ffe3..14536f7ccf 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-7.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-7.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: switch-7 components: - id: main diff --git a/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml b/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml index f1f9e78438..c684f632c9 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml @@ -6,12 +6,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml b/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml index 42e3ef6257..9cba419d02 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml @@ -6,12 +6,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/switch-level.yml b/drivers/SmartThings/matter-switch/profiles/switch-level.yml index 8f3b9f5e5c..827fcdd898 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-level.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/water-valve-level.yml b/drivers/SmartThings/matter-switch/profiles/water-valve-level.yml new file mode 100644 index 0000000000..48bfb78eef --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/water-valve-level.yml @@ -0,0 +1,18 @@ +name: water-valve-level +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: level + version: 1 + config: + values: + - key: "level.value" + range: [0, 100] + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterValve diff --git a/drivers/SmartThings/matter-switch/profiles/water-valve.yml b/drivers/SmartThings/matter-switch/profiles/water-valve.yml new file mode 100644 index 0000000000..7f2827b648 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/water-valve.yml @@ -0,0 +1,12 @@ +name: water-valve +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterValve diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering.yml b/drivers/SmartThings/matter-switch/profiles/window-covering.yml new file mode 100644 index 0000000000..b588b41ec9 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering.yml @@ -0,0 +1,21 @@ +name: window-covering +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: presetPosition + explicit: true + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua new file mode 100644 index 0000000000..12419a9a26 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua @@ -0,0 +1,57 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local DescriptorServerAttributes = require "embedded_clusters.Descriptor.server.attributes" +local DescriptorTypes = require "embedded_clusters.Descriptor.types" + +local Descriptor = {} + +Descriptor.ID = 0x001D +Descriptor.NAME = "Descriptor" +Descriptor.server = {} +Descriptor.client = {} +Descriptor.server.attributes = DescriptorServerAttributes:set_parent_cluster(Descriptor) +Descriptor.types = DescriptorTypes + +function Descriptor:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "DeviceTypeList", + [0x0003] = "PartsList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function Descriptor:get_server_command_by_id(command_id) + local server_id_map = { + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +Descriptor.attribute_direction_map = { + ["DeviceTypeList"] = "server", + ["PartsList"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Descriptor.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Descriptor.NAME)) + end + return Descriptor[direction].attributes[key] +end +Descriptor.attributes = {} +setmetatable(Descriptor.attributes, attribute_helper_mt) + +setmetatable(Descriptor, {__index = cluster_base}) + +return Descriptor + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/DeviceTypeList.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/DeviceTypeList.lua new file mode 100644 index 0000000000..7bd42e15eb --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/DeviceTypeList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local DeviceTypeList = { + ID = 0x0000, + NAME = "DeviceTypeList", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.Descriptor.types.DeviceTypeStruct", +} + +function DeviceTypeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, DeviceTypeList.element_type) + end +end + +function DeviceTypeList:new_value(...) + local o = self.base_type(table.unpack({ ... })) + return o +end + +function DeviceTypeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function DeviceTypeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function DeviceTypeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function DeviceTypeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function DeviceTypeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(DeviceTypeList, { __call = DeviceTypeList.new_value, __index = DeviceTypeList.base_type }) +return DeviceTypeList + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua new file mode 100644 index 0000000000..7c5b24d28d --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PartsList = { + ID = 0x0003, + NAME = "PartsList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint16", +} + +function PartsList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, PartsList.element_type) + end +end + +function PartsList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function PartsList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PartsList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PartsList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PartsList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PartsList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(PartsList, {__call = PartsList.new_value, __index = PartsList.base_type}) +return PartsList + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/init.lua new file mode 100644 index 0000000000..8a3d9ea3c2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Descriptor.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local DescriptorServerAttributes = {} + +function DescriptorServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DescriptorServerAttributes, attr_mt) + +return DescriptorServerAttributes + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/DeviceTypeStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/DeviceTypeStruct.lua new file mode 100644 index 0000000000..0cb771558d --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/DeviceTypeStruct.lua @@ -0,0 +1,82 @@ +-- Copyright 2026 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local DeviceTypeStruct = {} +local new_mt = StructureABC.new_mt({ NAME = "DeviceTypeStruct", ID = data_types.name_to_id_map["Structure"] }) + +DeviceTypeStruct.field_defs = { + { + name = "device_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "revision", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +DeviceTypeStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do + -- Note: _idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +DeviceTypeStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = DeviceTypeStruct.init +new_mt.__index.serialize = DeviceTypeStruct.serialize + +DeviceTypeStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(DeviceTypeStruct, new_mt) + +return DeviceTypeStruct + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/init.lua new file mode 100644 index 0000000000..8ecaa987fd --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2026 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.Descriptor.types." .. key) + end + return types_mt.__types_cache[key] +end + +local DescriptorTypes = {} + +setmetatable(DescriptorTypes, types_mt) + +return DescriptorTypes diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua new file mode 100644 index 0000000000..0f564673a9 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua @@ -0,0 +1,56 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ElectricalEnergyMeasurementServerAttributes = require "embedded_clusters.ElectricalEnergyMeasurement.server.attributes" +local ElectricalEnergyMeasurementTypes = require "embedded_clusters.ElectricalEnergyMeasurement.types" +local ElectricalEnergyMeasurement = {} + +ElectricalEnergyMeasurement.ID = 0x0091 +ElectricalEnergyMeasurement.NAME = "ElectricalEnergyMeasurement" +ElectricalEnergyMeasurement.server = {} +ElectricalEnergyMeasurement.client = {} +ElectricalEnergyMeasurement.server.attributes = ElectricalEnergyMeasurementServerAttributes:set_parent_cluster(ElectricalEnergyMeasurement) +ElectricalEnergyMeasurement.types = ElectricalEnergyMeasurementTypes + +function ElectricalEnergyMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0001] = "CumulativeEnergyImported", + [0x0003] = "PeriodicEnergyImported", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +ElectricalEnergyMeasurement.attribute_direction_map = { + ["CumulativeEnergyImported"] = "server", + ["PeriodicEnergyImported"] = "server", +} + +ElectricalEnergyMeasurement.FeatureMap = ElectricalEnergyMeasurement.types.Feature + +function ElectricalEnergyMeasurement.are_features_supported(feature, feature_map) + if (ElectricalEnergyMeasurement.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ElectricalEnergyMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ElectricalEnergyMeasurement.NAME)) + end + return ElectricalEnergyMeasurement[direction].attributes[key] +end +ElectricalEnergyMeasurement.attributes = {} +setmetatable(ElectricalEnergyMeasurement.attributes, attribute_helper_mt) + +setmetatable(ElectricalEnergyMeasurement, {__index = cluster_base}) + +return ElectricalEnergyMeasurement + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua new file mode 100644 index 0000000000..2d41790440 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CumulativeEnergyImported = { + ID = 0x0001, + NAME = "CumulativeEnergyImported", + base_type = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function CumulativeEnergyImported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CumulativeEnergyImported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyImported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyImported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CumulativeEnergyImported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CumulativeEnergyImported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CumulativeEnergyImported, {__call = CumulativeEnergyImported.new_value, __index = CumulativeEnergyImported.base_type}) +return CumulativeEnergyImported + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua new file mode 100644 index 0000000000..5daccf48ab --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PeriodicEnergyImported = { + ID = 0x0003, + NAME = "PeriodicEnergyImported", + base_type = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function PeriodicEnergyImported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function PeriodicEnergyImported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyImported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyImported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PeriodicEnergyImported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PeriodicEnergyImported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(PeriodicEnergyImported, {__call = PeriodicEnergyImported.new_value, __index = PeriodicEnergyImported.base_type}) +return PeriodicEnergyImported + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..57bc0d1f72 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ElectricalEnergyMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ElectricalEnergyMeasurementServerAttributes = {} + +function ElectricalEnergyMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ElectricalEnergyMeasurementServerAttributes, attr_mt) + +return ElectricalEnergyMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua new file mode 100644 index 0000000000..5452b287ac --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua @@ -0,0 +1,115 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local EnergyMeasurementStruct = {} +local new_mt = StructureABC.new_mt({NAME = "EnergyMeasurementStruct", ID = data_types.name_to_id_map["Structure"]}) + +EnergyMeasurementStruct.field_defs = { + { + name = "energy", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "start_timestamp", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "end_timestamp", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "start_systime", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "end_systime", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "apparent_energy", + field_id = 5, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "reactive_energy", + field_id = 6, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Int64", + }, +} + +EnergyMeasurementStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +EnergyMeasurementStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = EnergyMeasurementStruct.init +new_mt.__index.serialize = EnergyMeasurementStruct.serialize + +EnergyMeasurementStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements or {}) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements or {}) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(EnergyMeasurementStruct, new_mt) + +return EnergyMeasurementStruct + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua new file mode 100644 index 0000000000..e3db76f49d --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua @@ -0,0 +1,31 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.IMPORTED_ENERGY = 0x0001 +Feature.EXPORTED_ENERGY = 0x0002 +Feature.CUMULATIVE_ENERGY = 0x0004 +Feature.PERIODIC_ENERGY = 0x0008 + +function Feature.bits_are_valid(feature) + local max = + Feature.IMPORTED_ENERGY | + Feature.EXPORTED_ENERGY | + Feature.CUMULATIVE_ENERGY | + Feature.PERIODIC_ENERGY + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua new file mode 100644 index 0000000000..ec29b53e05 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.ElectricalEnergyMeasurement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ElectricalEnergyMeasurementTypes = {} + +setmetatable(ElectricalEnergyMeasurementTypes, types_mt) + +return ElectricalEnergyMeasurementTypes + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/init.lua new file mode 100644 index 0000000000..c9061e3cc4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/init.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ElectricalPowerMeasurementServerAttributes = require "embedded_clusters.ElectricalPowerMeasurement.server.attributes" + +local ElectricalPowerMeasurement = {} + +ElectricalPowerMeasurement.ID = 0x0090 +ElectricalPowerMeasurement.NAME = "ElectricalPowerMeasurement" +ElectricalPowerMeasurement.server = {} +ElectricalPowerMeasurement.client = {} +ElectricalPowerMeasurement.server.attributes = ElectricalPowerMeasurementServerAttributes:set_parent_cluster(ElectricalPowerMeasurement) + +function ElectricalPowerMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0008] = "ActivePower", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +ElectricalPowerMeasurement.attribute_direction_map = { + ["ActivePower"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ElectricalPowerMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ElectricalPowerMeasurement.NAME)) + end + return ElectricalPowerMeasurement[direction].attributes[key] +end +ElectricalPowerMeasurement.attributes = {} +setmetatable(ElectricalPowerMeasurement.attributes, attribute_helper_mt) + +setmetatable(ElectricalPowerMeasurement, {__index = cluster_base}) + +return ElectricalPowerMeasurement + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua new file mode 100644 index 0000000000..f1696509f5 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ActivePower = { + ID = 0x0008, + NAME = "ActivePower", + base_type = require "st.matter.data_types.Int64", +} + +function ActivePower:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ActivePower:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActivePower:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActivePower:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ActivePower:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ActivePower:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ActivePower, {__call = ActivePower.new_value, __index = ActivePower.base_type}) +return ActivePower diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..6de69b94ff --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ElectricalPowerMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ElectricalPowerMeasurementServerAttributes = {} + +function ElectricalPowerMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ElectricalPowerMeasurementServerAttributes, attr_mt) + +return ElectricalPowerMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/init.lua new file mode 100644 index 0000000000..0205e69b1f --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/init.lua @@ -0,0 +1,55 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local PowerTopologyServerAttributes = require "embedded_clusters.PowerTopology.server.attributes" +local PowerTopologyTypes = require "embedded_clusters.PowerTopology.types" + +local PowerTopology = {} + +PowerTopology.ID = 0x009C +PowerTopology.NAME = "PowerTopology" +PowerTopology.server = {} +PowerTopology.client = {} +PowerTopology.server.attributes = PowerTopologyServerAttributes:set_parent_cluster(PowerTopology) +PowerTopology.types = PowerTopologyTypes + +function PowerTopology:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "AvailableEndpoints", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +PowerTopology.attribute_direction_map = { + ["AvailableEndpoints"] = "server", +} + +PowerTopology.FeatureMap = PowerTopology.types.Feature + +function PowerTopology.are_features_supported(feature, feature_map) + if (PowerTopology.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = PowerTopology.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, PowerTopology.NAME)) + end + return PowerTopology[direction].attributes[key] +end +PowerTopology.attributes = {} +setmetatable(PowerTopology.attributes, attribute_helper_mt) + +setmetatable(PowerTopology, {__index = cluster_base}) + +return PowerTopology + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/AvailableEndpoints.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/AvailableEndpoints.lua new file mode 100644 index 0000000000..1536301d11 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/AvailableEndpoints.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AvailableEndpoints = { + ID = 0x0000, + NAME = "AvailableEndpoints", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint16", +} + +function AvailableEndpoints:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AvailableEndpoints:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AvailableEndpoints:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AvailableEndpoints:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AvailableEndpoints:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AvailableEndpoints:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AvailableEndpoints, {__call = AvailableEndpoints.new_value, __index = AvailableEndpoints.base_type}) +return AvailableEndpoints + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/init.lua new file mode 100644 index 0000000000..ff88697a0e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/server/attributes/init.lua @@ -0,0 +1,23 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.PowerTopology.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local PowerTopologyServerAttributes = {} + +function PowerTopologyServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(PowerTopologyServerAttributes, attr_mt) + +return PowerTopologyServerAttributes + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/Feature.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/Feature.lua new file mode 100644 index 0000000000..c2fe8087de --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/Feature.lua @@ -0,0 +1,32 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.NODE_TOPOLOGY = 0x0001 +Feature.TREE_TOPOLOGY = 0x0002 +Feature.SET_TOPOLOGY = 0x0004 +Feature.DYNAMIC_POWER_FLOW = 0x0008 + +function Feature.bits_are_valid(feature) + local max = + Feature.NODE_TOPOLOGY | + Feature.TREE_TOPOLOGY | + Feature.SET_TOPOLOGY | + Feature.DYNAMIC_POWER_FLOW + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/init.lua new file mode 100644 index 0000000000..7609939803 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/PowerTopology/types/init.lua @@ -0,0 +1,14 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__index = function(self, key) + return require("embedded_clusters.PowerTopology.types." .. key) +end + +local PowerTopologyTypes = {} + +setmetatable(PowerTopologyTypes, types_mt) + +return PowerTopologyTypes + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/init.lua new file mode 100644 index 0000000000..be2141f09c --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/init.lua @@ -0,0 +1,84 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ValveConfigurationAndControlServerAttributes = require "embedded_clusters.ValveConfigurationAndControl.server.attributes" +local ValveConfigurationAndControlServerCommands = require "embedded_clusters.ValveConfigurationAndControl.server.commands" +local ValveConfigurationAndControlTypes = require "embedded_clusters.ValveConfigurationAndControl.types" +local ValveConfigurationAndControl = {} + +ValveConfigurationAndControl.ID = 0x0081 +ValveConfigurationAndControl.NAME = "ValveConfigurationAndControl" +ValveConfigurationAndControl.server = {} +ValveConfigurationAndControl.client = {} +ValveConfigurationAndControl.server.attributes = ValveConfigurationAndControlServerAttributes:set_parent_cluster(ValveConfigurationAndControl) +ValveConfigurationAndControl.server.commands = ValveConfigurationAndControlServerCommands:set_parent_cluster(ValveConfigurationAndControl) +ValveConfigurationAndControl.types = ValveConfigurationAndControlTypes + +function ValveConfigurationAndControl:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0004] = "CurrentState", + [0x0006] = "CurrentLevel", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ValveConfigurationAndControl:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "Open", + [0x0001] = "Close", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ValveConfigurationAndControl.attribute_direction_map = { + ["CurrentState"] = "server", + ["CurrentLevel"] = "server", +} + +ValveConfigurationAndControl.command_direction_map = { + ["Open"] = "server", + ["Close"] = "server", +} + +ValveConfigurationAndControl.FeatureMap = ValveConfigurationAndControl.types.Feature + +function ValveConfigurationAndControl.are_features_supported(feature, feature_map) + if (ValveConfigurationAndControl.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ValveConfigurationAndControl.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ValveConfigurationAndControl.NAME)) + end + return ValveConfigurationAndControl[direction].attributes[key] +end +ValveConfigurationAndControl.attributes = {} +setmetatable(ValveConfigurationAndControl.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ValveConfigurationAndControl.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ValveConfigurationAndControl.NAME)) + end + return ValveConfigurationAndControl[direction].commands[key] +end +ValveConfigurationAndControl.commands = {} +setmetatable(ValveConfigurationAndControl.commands, command_helper_mt) + +setmetatable(ValveConfigurationAndControl, {__index = cluster_base}) + +return ValveConfigurationAndControl diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua new file mode 100644 index 0000000000..c77f0f54ac --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentLevel = { + ID = 0x0006, + NAME = "CurrentLevel", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentLevel:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentLevel:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentLevel:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentLevel:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentLevel:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentLevel:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentLevel, {__call = CurrentLevel.new_value, __index = CurrentLevel.base_type}) +return CurrentLevel diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentState.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentState.lua new file mode 100644 index 0000000000..033d69e0a8 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentState.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentState = { + ID = 0x0004, + NAME = "CurrentState", + base_type = require "embedded_clusters.ValveConfigurationAndControl.types.ValveStateEnum", +} + +function CurrentState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CurrentState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CurrentState, {__call = CurrentState.new_value, __index = CurrentState.base_type}) +return CurrentState diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/init.lua new file mode 100644 index 0000000000..ddefc6b205 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/init.lua @@ -0,0 +1,26 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ValveConfigurationAndControl.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ValveConfigurationAndControlServerAttributes = {} + +function ValveConfigurationAndControlServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ValveConfigurationAndControlServerAttributes, attr_mt) + +return ValveConfigurationAndControlServerAttributes diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Close.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Close.lua new file mode 100644 index 0000000000..263bf0d849 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Close.lua @@ -0,0 +1,92 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local Close = {} + +Close.NAME = "Close" +Close.ID = 0x0001 +Close.field_defs = { +} + +function Close:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Close:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Close, + __tostring = Close.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Close:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Close:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Close:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Close, {__call = Close.init}) + +return Close diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Open.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Open.lua new file mode 100644 index 0000000000..fd2f62801a --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Open.lua @@ -0,0 +1,106 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local Open = {} + +Open.NAME = "Open" +Open.ID = 0x0000 +Open.field_defs = { + { + name = "open_duration", + field_id = 0, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "target_level", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function Open:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Open:init(device, endpoint_id, open_duration, target_level) + local out = {} + local args = {open_duration, target_level} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Open, + __tostring = Open.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Open:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Open:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Open:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Open, {__call = Open.init}) + +return Open diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/init.lua new file mode 100644 index 0000000000..86eb2b9546 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/init.lua @@ -0,0 +1,25 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ValveConfigurationAndControl.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local ValveConfigurationAndControlServerCommands = {} + +function ValveConfigurationAndControlServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ValveConfigurationAndControlServerCommands, command_mt) + +return ValveConfigurationAndControlServerCommands diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/Feature.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/Feature.lua new file mode 100644 index 0000000000..bcbc7f0f38 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/Feature.lua @@ -0,0 +1,26 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.TIME_SYNC = 0x0001 +Feature.LEVEL = 0x0002 + +function Feature.bits_are_valid(feature) + local max = + Feature.TIME_SYNC | + Feature.LEVEL + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/ValveStateEnum.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/ValveStateEnum.lua new file mode 100644 index 0000000000..e445376bb8 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/ValveStateEnum.lua @@ -0,0 +1,32 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" +local ValveStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "ValveStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.CLOSED] = "CLOSED", + [self.OPEN] = "OPEN", + [self.TRANSITIONING] = "TRANSITIONING", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.CLOSED = 0x00 +new_mt.__index.OPEN = 0x01 +new_mt.__index.TRANSITIONING = 0x02 + +ValveStateEnum.CLOSED = 0x00 +ValveStateEnum.OPEN = 0x01 +ValveStateEnum.TRANSITIONING = 0x02 + +ValveStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ValveStateEnum, new_mt) + +return ValveStateEnum diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/init.lua new file mode 100644 index 0000000000..ae8ebed286 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.ValveConfigurationAndControl.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ValveConfigurationAndControlTypes = {} + +setmetatable(ValveConfigurationAndControlTypes, types_mt) + +return ValveConfigurationAndControlTypes diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index e29df15055..adebbb0ec1 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -1,542 +1,230 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" -local log = require "log" -local clusters = require "st.matter.clusters" local MatterDriver = require "st.matter.driver" -local utils = require "st.utils" +local capabilities = require "st.capabilities" local device_lib = require "st.device" +local clusters = require "st.matter.clusters" +local log = require "log" +local version = require "version" +local cfg = require "switch_utils.device_configuration" +local button_cfg = cfg.ButtonCfg +local device_cfg = cfg.DeviceCfg +local switch_cfg = cfg.SwitchCfg +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" +local attribute_handlers = require "switch_handlers.attribute_handlers" +local event_handlers = require "switch_handlers.event_handlers" +local capability_handlers = require "switch_handlers.capability_handlers" -local MOST_RECENT_TEMP = "mostRecentTemp" -local RECEIVED_X = "receivedX" -local RECEIVED_Y = "receivedY" -local HUESAT_SUPPORT = "huesatSupport" -local MIRED_KELVIN_CONVERSION_CONSTANT = 1000000 --- These values are a "sanity check" to check that values we are getting are reasonable -local COLOR_TEMPERATURE_KELVIN_MAX = 15000 -local COLOR_TEMPERATURE_KELVIN_MIN = 1000 -local COLOR_TEMPERATURE_MIRED_MAX = MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MIN -local COLOR_TEMPERATURE_MIRED_MIN = MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MAX -local SWITCH_LEVEL_LIGHTING_MIN = 1 - -local SWITCH_INITIALIZED = "__switch_intialized" --- COMPONENT_TO_ENDPOINT_MAP is here only to perserve the endpoint mapping for --- devices that were joined to this driver as MCD devices before the transition --- to join all matter-switch devices as parent-child. This value will only exist --- in the device table for devices that joined prior to this transition, and it --- will not be set for new devices. -local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" -local IS_PARENT_CHILD_DEVICE = "__is_parent_child_device" -local COLOR_TEMP_BOUND_RECEIVED = "__colorTemp_bound_received" -local COLOR_TEMP_MIN = "__color_temp_min" -local COLOR_TEMP_MAX = "__color_temp_max" -local LEVEL_BOUND_RECEIVED = "__level_bound_received" -local LEVEL_MIN = "__level_min" -local LEVEL_MAX = "__level_max" -local AGGREGATOR_DEVICE_TYPE_ID = 0x000E -local ON_OFF_LIGHT_DEVICE_TYPE_ID = 0x0100 -local DIMMABLE_LIGHT_DEVICE_TYPE_ID = 0x0101 -local COLOR_TEMP_LIGHT_DEVICE_TYPE_ID = 0x010C -local EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID = 0x010D -local ON_OFF_PLUG_DEVICE_TYPE_ID = 0x010A -local DIMMABLE_PLUG_DEVICE_TYPE_ID = 0x010B -local device_type_profile_map = { - [ON_OFF_LIGHT_DEVICE_TYPE_ID] = "light-binary", - [DIMMABLE_LIGHT_DEVICE_TYPE_ID] = "light-level", - [COLOR_TEMP_LIGHT_DEVICE_TYPE_ID] = "light-level-colorTemperature", - [EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID] = "light-color-level", - [ON_OFF_PLUG_DEVICE_TYPE_ID] = "plug-binary", - [DIMMABLE_PLUG_DEVICE_TYPE_ID] = "plug-level" -} -local detect_matter_thing - -local function get_field_for_endpoint(device, field, endpoint) - return device:get_field(string.format("%s_%d", field, endpoint)) -end - -local function set_field_for_endpoint(device, field, endpoint, value, additional_params) - device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) -end - -local function convert_huesat_st_to_matter(val) - return math.floor((val * 0xFE) / 100.0 + 0.5) -end - -local function mired_to_kelvin(value) - if value == 0 then -- shouldn't happen, but has - value = 1 - log.warn(string.format("Received a color temperature of 0 mireds. Using a color temperature of 1 mired to avoid divide by zero")) - end - -- we divide inside the rounding and multiply outside of it because we expect these - -- bounds to be multiples of 100 - return utils.round((MIRED_KELVIN_CONVERSION_CONSTANT / value) / 100) * 100 +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.PowerTopology = require "embedded_clusters.PowerTopology" + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" end ---- component_to_endpoint helper function to handle situations where ---- device does not have endpoint ids in sequential order from 1 ---- In this case the function returns the lowest endpoint value that isn't 0 ---- and supports the OnOff cluster. This is done to bypass the ---- BRIDGED_NODE_DEVICE_TYPE on bridged devices -local function find_default_endpoint(device, component) - local eps = device:get_endpoints(clusters.OnOff.ID) - table.sort(eps) - for _, v in ipairs(eps) do - if v ~= 0 then --0 is the matter RootNode endpoint - return v - end - end - device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return device.MATTER_DEFAULT_ENDPOINT +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" end -local function assign_child_profile(device, child_ep) - local profile - for _, ep in ipairs(device.endpoints) do - if ep.endpoint_id == child_ep then - -- Some devices report multiple device types which are a subset of - -- a superset device type (For example, Dimmable Light is a superset of - -- On/Off light). This mostly applies to the four light types, so we will want - -- to match the profile for the superset device type. This can be done by - -- matching to the device type with the highest ID - local id = 0 - for _, dt in ipairs(ep.device_types) do - id = math.max(id, dt.device_type_id) - end - profile = device_type_profile_map[id] - end - end - -- default to "switch-binary" if no profile is found - return profile or "switch-binary" +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" end -local function initialize_switch(driver, device) - local switch_eps = device:get_endpoints(clusters.OnOff.ID) - table.sort(switch_eps) +local SwitchLifecycleHandlers = {} - -- Since we do not support bindings at the moment, we only want to count On/Off - -- clusters that have been implemented as server. This can be removed when we have - -- support for bindings. - local num_server_eps = 0 - local main_endpoint = find_default_endpoint(device) - for _, ep in ipairs(switch_eps) do - if device:supports_server_cluster(clusters.OnOff.ID, ep) then - num_server_eps = num_server_eps + 1 - if ep ~= main_endpoint then -- don't create a child device that maps to the main endpoint - local name = string.format("%s %d", device.label, num_server_eps) - local child_profile = assign_child_profile(device, ep) - driver:try_create_device( - { - type = "EDGE_CHILD", - label = name, - profile = child_profile, - parent_device_id = device.id, - parent_assigned_child_key = string.format("%d", ep), - vendor_provided_label = name - } - ) - end - end - end - - if num_server_eps > 1 then - -- If the device is a parent child device, then set the find_child function on init. - -- This is persisted because initialize switch is only run once, but find_child function should be set - -- on each driver init. - device:set_field(IS_PARENT_CHILD_DEVICE, true, {persist = true}) - end - - device:set_field(SWITCH_INITIALIZED, true) - -- The case where num_server_eps > 0 is a workaround for devices that have the On/Off - -- Light Switch device type but implement the On Off cluster as server (which is against the spec - -- for this device type). By default, we do not support On/Off Light Switch because by spec these - -- devices need bindings to work correctly (On/Off cluster is client in this case), so this device type - -- does not have a generic fingerprint and will join as a matter-thing. However, we have - -- seen some devices claim to be On/Off Light Switch device type and still implement On/Off server, so this - -- is a workaround for those devices. - if num_server_eps > 0 and detect_matter_thing(device) == true then - device:try_update_metadata({profile = "switch-binary"}) - end -end - -local function component_to_endpoint(device, component) - local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} - if map[component] then - return map[component] - end - return find_default_endpoint(device, component) -end - -local function endpoint_to_component(device, ep) - local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} - for component, endpoint in pairs(map) do - if endpoint == ep then - return component - end +function SwitchLifecycleHandlers.device_added(driver, device) + -- refresh child devices to get an initial attribute state for OnOff in case child device + -- was created after the initial subscription report + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + device:send(clusters.OnOff.attributes.OnOff:read(device)) end - return "main" -end -local function find_child(parent, ep_id) - return parent:get_child_by_parent_assigned_key(string.format("%d", ep_id)) -end - -local function detect_bridge(device) - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == AGGREGATOR_DEVICE_TYPE_ID then - return true - end - end + -- The device init event is guaranteed in FW versions 58+, so this is only needed for older hubs + if version.rpc < 10 then + -- call device init in case init is not called after added due to device caching + SwitchLifecycleHandlers.device_init(driver, device) end - return false end -local function device_init(driver, device) - if device.network_type == device_lib.NETWORK_TYPE_MATTER then - -- initialize_switch will create parent-child devices as needed for multi-switch devices. - -- However, we want to maintain support for existing MCD devices, so do not initialize - -- device if it has already been previously initialized as an MCD device. - -- Also, do not attempt a profile switch for a bridge device. - if not device:get_field(COMPONENT_TO_ENDPOINT_MAP) and - not device:get_field(SWITCH_INITIALIZED) and - not detect_bridge(device) then - -- create child devices as needed for multi-switch devices - initialize_switch(driver, device) - end - device:set_component_to_endpoint_fn(component_to_endpoint) - device:set_endpoint_to_component_fn(endpoint_to_component) - if device:get_field(IS_PARENT_CHILD_DEVICE) == true then - device:set_find_child(find_child) - end +function SwitchLifecycleHandlers.do_configure(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + switch_cfg.set_device_control_options(device) + device_cfg.match_profile(driver, device) + elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then + device_cfg.match_child_profile(driver, device) + -- because get_parent_device() may cause race conditions if used in init, an initial child subscribe is handled in doConfigure. + -- all future calls to subscribe will be handled by the parent device in init device:subscribe() end end -local function device_removed(driver, device) - log.info("device removed") -end - -local function handle_switch_on(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - --TODO use OnWithRecallGlobalScene for devices with the LT feature - local req = clusters.OnOff.server.commands.On(device, endpoint_id) - device:send(req) -end - -local function handle_switch_off(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.OnOff.server.commands.Off(device, endpoint_id) - device:send(req) -end - -local function handle_set_level(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local level = math.floor(cmd.args.level/100.0 * 254) - local req = clusters.LevelControl.server.commands.MoveToLevelWithOnOff(device, endpoint_id, level, cmd.args.rate or 0, 0 ,0) - device:send(req) -end - ---TODO could be moved to st.utils if made more generally useful -local tbl_contains = function(t, val) - for _, v in pairs(t) do - if v == val then - return true - end - end - return false -end - -local TRANSITION_TIME = 0 --1/10ths of a second --- When sent with a command, these options mask and override bitmaps cause the command --- to take effect when the switch/light is off. -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 - -local function handle_set_color(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local req - local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) - if tbl_contains(huesat_endpoints, endpoint_id) then - local hue = convert_huesat_st_to_matter(cmd.args.color.hue) - local sat = convert_huesat_st_to_matter(cmd.args.color.saturation) - req = clusters.ColorControl.server.commands.MoveToHueAndSaturation(device, endpoint_id, hue, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - else - local x, y, _ = utils.safe_hsv_to_xy(cmd.args.color.hue, cmd.args.color.saturation) - req = clusters.ColorControl.server.commands.MoveToColor(device, endpoint_id, x, y, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - end - device:send(req) -end - -local function handle_set_hue(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) - if tbl_contains(huesat_endpoints, endpoint_id) then - local hue = convert_huesat_st_to_matter(cmd.args.hue) - local req = clusters.ColorControl.server.commands.MoveToHue(device, endpoint_id, hue, 0, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - device:send(req) - else - log.warn("Device does not support huesat features on its color control cluster") - end -end - -local function handle_set_saturation(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) - if tbl_contains(huesat_endpoints, endpoint_id) then - local sat = convert_huesat_st_to_matter(cmd.args.saturation) - local req = clusters.ColorControl.server.commands.MoveToSaturation(device, endpoint_id, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - device:send(req) - else - log.warn("Device does not support huesat features on its color control cluster") - end -end - -local function handle_set_color_temperature(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local temp_in_mired = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/cmd.args.temperature) - local req = clusters.ColorControl.server.commands.MoveToColorTemperature(device, endpoint_id, temp_in_mired, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - device:set_field(MOST_RECENT_TEMP, cmd.args.temperature) - device:send(req) -end - -local function handle_refresh(driver, device, cmd) - --Note: no endpoint specified indicates a wildcard endpoint - local req = clusters.OnOff.attributes.OnOff:read(device) - device:send(req) -end - --- Fallback handler for responses that dont have their own handler -local function matter_handler(driver, device, response_block) - log.info(string.format("Fallback handler for %s", response_block)) -end - -local function on_off_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) - end -end - -local function level_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - local level = math.floor((ib.data.value / 254.0 * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.level(level)) - end -end - -local function hue_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - local hue = math.floor((ib.data.value / 0xFE * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(hue)) - end -end - -local function sat_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - local sat = math.floor((ib.data.value / 0xFE * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(sat)) +function SwitchLifecycleHandlers.driver_switched(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + device_cfg.match_profile(driver, device) end -end - -local function temp_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - if (ib.data.value < COLOR_TEMPERATURE_MIRED_MIN or ib.data.value > COLOR_TEMPERATURE_MIRED_MAX) then - device.log.warn_with({hub_logs = true}, string.format("Device reported color temperature %d mired outside of sane range of %.2f-%.2f", ib.data.value, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) - return - end - local temp = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/ib.data.value) - local temp_device = find_child(device, ib.endpoint_id) or device - local most_recent_temp = temp_device:get_field(MOST_RECENT_TEMP) - -- this is to avoid rounding errors from the round-trip conversion of Kelvin to mireds - if most_recent_temp ~= nil and - most_recent_temp <= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(ib.data.value - 1)) and - most_recent_temp >= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(ib.data.value + 1)) then - temp = most_recent_temp + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +function SwitchLifecycleHandlers.info_changed(driver, device, event, args) + if not switch_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + device:subscribe() + button_cfg.configure_buttons(device, + device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + ) + elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then + device:get_parent_device():subscribe() -- parent device required to send subscription requests end - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperature(temp)) end -end -local mired_bounds_handler_factory = function(minOrMax) - return function(driver, device, ib, response) - if ib.data.value == nil then - return - end - if (ib.data.value < COLOR_TEMPERATURE_MIRED_MIN or ib.data.value > COLOR_TEMPERATURE_MIRED_MAX) then - device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", ib.data.value, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) - return - end - local temp_in_kelvin = mired_to_kelvin(ib.data.value) - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..minOrMax, ib.endpoint_id, temp_in_kelvin) - local min = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MIN, ib.endpoint_id) - local max = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MAX, ib.endpoint_id) - if min ~= nil and max ~= nil then - if min < max then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min, maximum = max} })) - else - device.log.warn_with({hub_logs = true}, string.format("Device reported a min color temperature %d K that is not lower than the reported max color temperature %d K", min, max)) - end - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MAX, ib.endpoint_id, nil) - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MIN, ib.endpoint_id, nil) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + if device.matter_version.software ~= args.old_st_store.matter_version.software then + device_cfg.match_profile(driver, device) end end -end -local level_bounds_handler_factory = function(minOrMax) - return function(driver, device, ib, response) - if ib.data.value == nil then - return - end - local lighting_endpoints = device:get_endpoints(clusters.LevelControl.ID, {feature_bitmap = clusters.LevelControl.FeatureMap.LIGHTING}) - local lighting_support = tbl_contains(lighting_endpoints, ib.endpoint_id) - -- If the lighting feature is supported then we should check if the reported level is at least 1. - if lighting_support and ib.data.value < SWITCH_LEVEL_LIGHTING_MIN then - device.log.warn_with({hub_logs = true}, string.format("Lighting device reported a switch level %d outside of supported capability range", ib.data.value)) - return - end - -- Convert level from given range of 0-254 to range of 0-100. - local level = utils.round(ib.data.value / 254.0 * 100) - -- If the device supports the lighting feature, the minimum capability level should be 1 so we do not send a 0 value for the level attribute - if lighting_support and level == 0 then - level = 1 - end - set_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..minOrMax, ib.endpoint_id, level) - local min = get_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MIN, ib.endpoint_id) - local max = get_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MAX, ib.endpoint_id) - if min ~= nil and max ~= nil then - if min < max then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.levelRange({ value = {minimum = min, maximum = max} })) - else - device.log.warn_with({hub_logs = true}, string.format("Device reported a min level value %d that is not lower than the reported max level value %d", min, max)) + -- instant update of values after offset preference change + for name, info in pairs(device.preferences or {}) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if name == "tempOffset" then + device:send(clusters.TemperatureMeasurement.attributes.MeasuredValue:read(device)) + elseif name == "humidityOffset" then + device:send(clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(device)) end - set_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MAX, ib.endpoint_id, nil) - set_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MIN, ib.endpoint_id, nil) end end -end -local color_utils = require "color_utils" - -local function x_attr_handler(driver, device, ib, response) - local y = device:get_field(RECEIVED_Y) - --TODO it is likely that both x and y attributes are in the response (not guaranteed though) - -- if they are we can avoid setting fields on the device. - if y == nil then - device:set_field(RECEIVED_X, ib.data.value) - else - local x = ib.data.value - local h, s, _ = color_utils.safe_xy_to_hsv(x, y) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) - device:set_field(RECEIVED_Y, nil) - end end -local function y_attr_handler(driver, device, ib, response) - local x = device:get_field(RECEIVED_X) - if x == nil then - device:set_field(RECEIVED_Y, ib.data.value) - else - local y = ib.data.value - local h, s, _ = color_utils.safe_xy_to_hsv(x, y) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) - device:set_field(RECEIVED_X, nil) - end -end - ---TODO setup configure handler to read this attribute. -local function color_cap_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - if ib.data.value & 0x1 then - device:set_field(HUESAT_SUPPORT, true) +function SwitchLifecycleHandlers.device_init(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + switch_utils.check_field_name_updates(device) + device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then + device:set_find_child(switch_utils.find_child) end - end -end - - -local function illuminance_attr_handler(driver, device, ib, response) - local lux = math.floor(10 ^ ((ib.data.value - 1) / 10000)) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.illuminanceMeasurement.illuminance(lux)) -end - -local function occupancy_attr_handler(driver, device, ib, response) - device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) -end - -local function info_changed(driver, device, event, args) - if device.profile.id ~= args.old_st_store.profile.id then + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) == 0 then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + end + switch_utils.handle_electrical_sensor_info(device) + device:extend_device("subscribe", switch_utils.subscribe) device:subscribe() end end -local function device_added(driver, device) - -- refresh child devices to get initial attribute state in case child device - -- was created after the initial subscription report - if device.network_type == device_lib.NETWORK_TYPE_CHILD then - handle_refresh(driver, device) - end +function SwitchLifecycleHandlers.device_removed(driver, device) + device.log.info("device removed") end local matter_driver_template = { lifecycle_handlers = { - init = device_init, - added = device_added, - removed = device_removed, - infoChanged = info_changed + added = SwitchLifecycleHandlers.device_added, + doConfigure = SwitchLifecycleHandlers.do_configure, + driverSwitched = SwitchLifecycleHandlers.driver_switched, + infoChanged = SwitchLifecycleHandlers.info_changed, + init = SwitchLifecycleHandlers.device_init, + removed = SwitchLifecycleHandlers.device_removed, }, matter_handlers = { attr = { - [clusters.OnOff.ID] = { - [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, + [clusters.ColorControl.ID] = { + [clusters.ColorControl.attributes.ColorCapabilities.ID] = attribute_handlers.color_capabilities_handler, + [clusters.ColorControl.attributes.ColorMode.ID] = attribute_handlers.color_mode_handler, + [clusters.ColorControl.attributes.ColorTemperatureMireds.ID] = attribute_handlers.color_temperature_mireds_handler, + [clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds.ID] = attribute_handlers.color_temp_physical_mireds_bounds_factory(fields.COLOR_TEMP_MIN), -- max mireds = min kelvin + [clusters.ColorControl.attributes.ColorTempPhysicalMinMireds.ID] = attribute_handlers.color_temp_physical_mireds_bounds_factory(fields.COLOR_TEMP_MAX), -- min mireds = max kelvin + [clusters.ColorControl.attributes.CurrentHue.ID] = attribute_handlers.current_hue_handler, + [clusters.ColorControl.attributes.CurrentSaturation.ID] = attribute_handlers.current_saturation_handler, + [clusters.ColorControl.attributes.CurrentX.ID] = attribute_handlers.current_x_handler, + [clusters.ColorControl.attributes.CurrentY.ID] = attribute_handlers.current_y_handler, }, - [clusters.LevelControl.ID] = { - [clusters.LevelControl.attributes.CurrentLevel.ID] = level_attr_handler, - [clusters.LevelControl.attributes.MaxLevel.ID] = level_bounds_handler_factory(LEVEL_MAX), - [clusters.LevelControl.attributes.MinLevel.ID] = level_bounds_handler_factory(LEVEL_MIN), + [clusters.Descriptor.ID] = { + [clusters.Descriptor.attributes.PartsList.ID] = attribute_handlers.parts_list_handler, }, - [clusters.ColorControl.ID] = { - [clusters.ColorControl.attributes.CurrentHue.ID] = hue_attr_handler, - [clusters.ColorControl.attributes.CurrentSaturation.ID] = sat_attr_handler, - [clusters.ColorControl.attributes.ColorTemperatureMireds.ID] = temp_attr_handler, - [clusters.ColorControl.attributes.CurrentX.ID] = x_attr_handler, - [clusters.ColorControl.attributes.CurrentY.ID] = y_attr_handler, - [clusters.ColorControl.attributes.ColorCapabilities.ID] = color_cap_attr_handler, - [clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds.ID] = mired_bounds_handler_factory(COLOR_TEMP_MIN), -- max mireds = min kelvin - [clusters.ColorControl.attributes.ColorTempPhysicalMinMireds.ID] = mired_bounds_handler_factory(COLOR_TEMP_MAX), -- min mireds = max kelvin + [clusters.ElectricalEnergyMeasurement.ID] = { + [clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported.ID] = attribute_handlers.energy_imported_factory(false), + [clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported.ID] = attribute_handlers.energy_imported_factory(true), + }, + [clusters.ElectricalPowerMeasurement.ID] = { + [clusters.ElectricalPowerMeasurement.attributes.ActivePower.ID] = attribute_handlers.active_power_handler, + }, + [clusters.FanControl.ID] = { + [clusters.FanControl.attributes.FanMode.ID] = attribute_handlers.fan_mode_handler, + [clusters.FanControl.attributes.FanModeSequence.ID] = attribute_handlers.fan_mode_sequence_handler, + [clusters.FanControl.attributes.PercentCurrent.ID] = attribute_handlers.percent_current_handler + }, + [clusters.FlowMeasurement.ID] = { + [clusters.FlowMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.flow_attr_handler, + [clusters.FlowMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(fields.FLOW_MIN), + [clusters.FlowMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(fields.FLOW_MAX) }, [clusters.IlluminanceMeasurement.ID] = { - [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = illuminance_attr_handler + [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.illuminance_measured_value_handler + }, + [clusters.LevelControl.ID] = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = attribute_handlers.level_control_current_level_handler, + [clusters.LevelControl.attributes.MaxLevel.ID] = attribute_handlers.level_bounds_handler_factory(fields.LEVEL_MAX), + [clusters.LevelControl.attributes.MinLevel.ID] = attribute_handlers.level_bounds_handler_factory(fields.LEVEL_MIN), }, [clusters.OccupancySensing.ID] = { - [clusters.OccupancySensing.attributes.Occupancy.ID] = occupancy_attr_handler, + [clusters.OccupancySensing.attributes.Occupancy.ID] = attribute_handlers.occupancy_handler, + }, + [clusters.OperationalState.ID] = { + [clusters.OperationalState.attributes.AcceptedCommandList.ID] = attribute_handlers.operational_state_accepted_command_list_attr_handler, + [clusters.OperationalState.attributes.OperationalState.ID] = attribute_handlers.operational_state_attr_handler, + [clusters.OperationalState.attributes.OperationalError.ID] = attribute_handlers.operational_error_attr_handler + }, + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = attribute_handlers.on_off_attr_handler, + }, + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler, + [clusters.PowerSource.attributes.BatChargeLevel.ID] = attribute_handlers.bat_charge_level_handler, + [clusters.PowerSource.attributes.BatPercentRemaining.ID] = attribute_handlers.bat_percent_remaining_handler, + }, + [clusters.PowerTopology.ID] = { + [clusters.PowerTopology.attributes.AvailableEndpoints.ID] = attribute_handlers.available_endpoints_handler, + }, + [clusters.RelativeHumidityMeasurement.ID] = { + [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.relative_humidity_measured_value_handler + }, + [clusters.Switch.ID] = { + [clusters.Switch.attributes.MultiPressMax.ID] = attribute_handlers.multi_press_max_handler + }, + [clusters.TemperatureMeasurement.ID] = { + [clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.TEMP_MAX), + [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.temperature_measured_value_handler, + [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.TEMP_MIN), + }, + [clusters.ValveConfigurationAndControl.ID] = { + [clusters.ValveConfigurationAndControl.attributes.CurrentLevel.ID] = attribute_handlers.valve_configuration_current_level_handler, + [clusters.ValveConfigurationAndControl.attributes.CurrentState.ID] = attribute_handlers.valve_configuration_current_state_handler, + }, + }, + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = event_handlers.initial_press_handler, + [clusters.Switch.events.LongPress.ID] = event_handlers.long_press_handler, + [clusters.Switch.events.MultiPressComplete.ID] = event_handlers.multi_press_complete_handler, + [clusters.Switch.events.ShortRelease.ID] = event_handlers.short_release_handler, } }, - fallback = matter_handler, + fallback = switch_utils.matter_handler, }, subscribed_attributes = { - [capabilities.switch.ID] = { - clusters.OnOff.attributes.OnOff + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining, }, - [capabilities.switchLevel.ID] = { - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel, }, [capabilities.colorControl.ID] = { + clusters.ColorControl.attributes.ColorMode, clusters.ColorControl.attributes.CurrentHue, clusters.ColorControl.attributes.CurrentSaturation, clusters.ColorControl.attributes.CurrentX, @@ -547,55 +235,158 @@ local matter_driver_template = { clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, }, + [capabilities.energyMeter.ID] = { + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported + }, + [capabilities.fanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.flowMeasurement.ID] = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue + }, [capabilities.illuminanceMeasurement.ID] = { clusters.IlluminanceMeasurement.attributes.MeasuredValue }, + [capabilities.level.ID] = { + clusters.ValveConfigurationAndControl.attributes.CurrentLevel + }, [capabilities.motionSensor.ID] = { clusters.OccupancySensing.attributes.Occupancy - } - }, - capability_handlers = { + }, + [capabilities.operationalState.ID] = { + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError + }, + [capabilities.powerMeter.ID] = { + clusters.ElectricalPowerMeasurement.attributes.ActivePower + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, [capabilities.switch.ID] = { - [capabilities.switch.commands.on.NAME] = handle_switch_on, - [capabilities.switch.commands.off.NAME] = handle_switch_off, + clusters.OnOff.attributes.OnOff }, [capabilities.switchLevel.ID] = { - [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue }, - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = handle_refresh, + [capabilities.valve.ID] = { + clusters.ValveConfigurationAndControl.attributes.CurrentState }, + }, + subscribed_events = { + [capabilities.button.ID] = { + clusters.Switch.events.InitialPress, + clusters.Switch.events.LongPress, + clusters.Switch.events.ShortRelease, + clusters.Switch.events.MultiPressComplete, + }, + }, + capability_handlers = { [capabilities.colorControl.ID] = { - [capabilities.colorControl.commands.setColor.NAME] = handle_set_color, - [capabilities.colorControl.commands.setHue.NAME] = handle_set_hue, - [capabilities.colorControl.commands.setSaturation.NAME] = handle_set_saturation, + [capabilities.colorControl.commands.setColor.NAME] = capability_handlers.handle_set_color, + [capabilities.colorControl.commands.setHue.NAME] = capability_handlers.handle_set_hue, + [capabilities.colorControl.commands.setSaturation.NAME] = capability_handlers.handle_set_saturation, }, [capabilities.colorTemperature.ID] = { - [capabilities.colorTemperature.commands.setColorTemperature.NAME] = handle_set_color_temperature, + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = capability_handlers.handle_set_color_temperature, + }, + [capabilities.energyMeter.ID] = { + [capabilities.energyMeter.commands.resetEnergyMeter.NAME] = capability_handlers.handle_reset_energy_meter, + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = capability_handlers.handle_set_fan_mode + }, + [capabilities.fanSpeedPercent.ID] = { + [capabilities.fanSpeedPercent.commands.setPercent.NAME] = capability_handlers.handle_fan_speed_set_percent + }, + [capabilities.level.ID] = { + [capabilities.level.commands.setLevel.NAME] = capability_handlers.handle_set_level + }, + [capabilities.operationalState.ID] = { + [capabilities.operationalState.commands.pause.NAME] = capability_handlers.handle_operational_state_pause, + [capabilities.operationalState.commands.resume.NAME] = capability_handlers.handle_operational_state_resume + }, + [capabilities.statelessColorTemperatureStep.ID] = { + [capabilities.statelessColorTemperatureStep.commands.stepColorTemperatureByPercent.NAME] = capability_handlers.handle_step_color_temperature_by_percent, + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = capability_handlers.handle_step_level, + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.off.NAME] = capability_handlers.handle_switch_off, + [capabilities.switch.commands.on.NAME] = capability_handlers.handle_switch_on, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = capability_handlers.handle_switch_set_level + }, + [capabilities.valve.ID] = { + [capabilities.valve.commands.close.NAME] = capability_handlers.handle_valve_close, + [capabilities.valve.commands.open.NAME] = capability_handlers.handle_valve_open, }, }, supported_capabilities = { - capabilities.switch, - capabilities.switchLevel, + capabilities.audioMute, + capabilities.audioRecording, + capabilities.audioVolume, + capabilities.battery, + capabilities.batteryLevel, + capabilities.button, + capabilities.cameraPrivacyMode, + capabilities.cameraViewportSettings, capabilities.colorControl, capabilities.colorTemperature, + capabilities.energyMeter, + capabilities.fanMode, + capabilities.fanSpeedPercent, + capabilities.flowMeasurement, + capabilities.hdr, + capabilities.illuminanceMeasurement, + capabilities.imageControl, + capabilities.level, + capabilities.localMediaStorage, + capabilities.mechanicalPanTiltZoom, capabilities.motionSensor, - capabilities.illuminanceMeasurement + capabilities.nightVision, + capabilities.operationalState, + capabilities.powerMeter, + capabilities.powerConsumptionReport, + capabilities.relativeHumidityMeasurement, + capabilities.sounds, + capabilities.switch, + capabilities.switchLevel, + capabilities.temperatureMeasurement, + capabilities.valve, + capabilities.videoStreamSettings, + capabilities.webrtc, + capabilities.zoneManagement }, - sub_drivers = { - require("eve-energy"), - } + sub_drivers = { + switch_utils.lazy_load_if_possible("sub_drivers.aqara_cube"), + switch_utils.lazy_load("sub_drivers.camera"), + switch_utils.lazy_load_if_possible("sub_drivers.eve_energy"), + switch_utils.lazy_load_if_possible("sub_drivers.hager"), + switch_utils.lazy_load_if_possible("sub_drivers.ikea_scroll"), + switch_utils.lazy_load_if_possible("sub_drivers.third_reality_garage_door"), + switch_utils.lazy_load_if_possible("sub_drivers.third_reality_mk1") + }, + shared_device_thread_enabled = true, } -function detect_matter_thing(device) - for _, capability in ipairs(matter_driver_template.supported_capabilities) do - if device:supports_capability(capability) then - return false - end - end - return device:supports_capability(capabilities.refresh) -end - local matter_driver = MatterDriver("matter-switch", matter_driver_template) log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) matter_driver:run() diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/can_handle.lua new file mode 100644 index 0000000000..dcecbc86ee --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local device_lib = require "st.device" + +return function(opts, driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + local name = string.format("%s", device.manufacturer_info.product_name) + if string.find(name, "Aqara Cube T1 Pro") then + return true, require("sub_drivers.aqara_cube") + end + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua new file mode 100644 index 0000000000..ca9dfa76cf --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua @@ -0,0 +1,239 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local device_lib = require "st.device" +local log = require "log" + +local cubeAction = capabilities["stse.cubeAction"] +local cubeFace = capabilities["stse.cubeFace"] + +local COMPONENT_TO_ENDPOINT_MAP_BUTTON = "__component_to_endpoint_map_button" +local DEFERRED_CONFIGURE = "__DEFERRED_CONFIGURE" + +-- used in unit testing, since device.profile.id and args.old_st_store.profile.id are always the same +-- and this is to avoid the crash of the test case that occurs when try_update_metadata is performed in the device_init stage. +local TEST_CONFIGURE = "__test_configure" +local INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) + +-- after 3 seconds of cubeAction, to automatically change the action status of Plugin UI or Device Card to noAction +local CUBEACTION_TIMER = "__cubeAction_timer" +local CUBEACTION_TIME = 3 + +local callback_timer = function(device) + return function() + device:emit_event(cubeAction.cubeAction("noAction")) + end +end + +local function reset_thread(device) + local timer = device:get_field(CUBEACTION_TIMER) + if timer then + device.thread:cancel_timer(timer) + device:set_field(CUBEACTION_TIMER, nil) + end + device:set_field(CUBEACTION_TIMER, device.thread:call_with_delay(CUBEACTION_TIME, callback_timer(device))) +end + +local function get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +local function set_field_for_endpoint(device, field, endpoint, value, persist) + device:set_field(string.format("%s_%d", field, endpoint), value, {persist = persist}) +end + +-- The endpoints of each face may increase sequentially, but may increase as in [250, 251, 2, 3, 4, 5] +-- and the current device:get_endpoints function is valid only for the former so, adds this function. +local function get_reordered_endpoints(driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + local MS = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + -- find the default/main endpoint, the device with the lowest EP that supports MS + table.sort(MS) + if MS[6] < (MS[1] + 150) then + -- When the endpoints of each face increase sequentially + -- The lowest EP is the main endpoint + -- as a workaround, it is assumed that the first endpoint number and the last endpoint number are not larger than 150. + return MS + else + -- When the endpoints of each face do not increase sequentially... [250, 251, 2, 3, 4, 5] 250 is the main endpoint. + -- For the situation where a node following these mechanisms has exhausted all available 65535 endpoint addresses for exposed entities, + -- it MAY wrap around to the lowest unused endpoint address (refter to Matter Core Spec 9.2.4. Dynamic Endpoint Allocation) + local ept1 = {} -- First consecutive end points + local ept2 = {} -- Second consecutive end points + local idx1 = 1 + local idx2 = 1 + local flag = 0 + local previous = 0 + for _, ep in ipairs(MS) do + if idx1 == 1 then + ept1[idx1] = ep + else + if flag == 0 + and ep <= (previous + 15) then + -- the endpoint number does not always increase by 1 + -- as a workaround, assume that the next endpoint number is not greater than 15 + ept1[idx1] = ep + else + ept2[idx2] = ep + idx2 = idx2 + 1 + if flag ~= 1 then + flag = 1 + end + end + end + idx1 = idx1 + 1 + previous = ep + end + + local start = #ept2 + 1 + idx1 = 1 + idx2 = start + for i=start, 6 do + ept2[idx2] = ept1[idx1] + idx1 = idx1 + 1 + idx2 = idx2 + 1 + end + return ept2 + end + end +end + +local function endpoint_to_component(device, endpoint) + return "main" +end + +-- This is called either on add for parent/child devices, or after the device profile changes for components +local function configure_buttons(device) + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + local MS = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + device.log.debug(#MS.." momentary switch endpoints") + for _, ep in ipairs(MS) do + -- device only supports momentary switch, no release events + device.log.debug("configuring for press event only") + set_field_for_endpoint(device, INITIAL_PRESS_ONLY, ep, true, true) + end + end +end + +local function set_configure(driver, device) + local MS = get_reordered_endpoints(driver, device) + local main_endpoint + if #MS > 0 and MS[1] == 0 then -- we shouldn't hit this, but just in case + main_endpoint = MS[2] + elseif #MS > 0 then + main_endpoint = MS[1] -- matches to the non-child device + else + main_endpoint = device.MATTER_DEFAULT_ENDPOINT + end + device.log.debug_with({hub_logs = true}, "The main button endpoint for the Aqara T1 Pro is " .. main_endpoint) + + -- At the moment, we're taking it for granted that all momentary switches only have 2 positions + local current_component_number = 1 + local component_map = {} + for _, ep in ipairs(MS) do -- for each momentary switch endpoint (including main) + log.debug_with({hub_logs = true}, "Configuring endpoint: " .. ep) + -- build the mapping of endpoints to components + component_map[string.format("%d", current_component_number)] = ep + current_component_number = current_component_number + 1 + end + + device:set_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON, component_map, {persist = true}) + device:try_update_metadata({profile = "cube-t1-pro"}) + configure_buttons(device) +end + +local function device_init(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + device:subscribe() + device:set_endpoint_to_component_fn(endpoint_to_component) + + -- when unit testing, call set_configure elsewhere + if not device:get_field(TEST_CONFIGURE) then + set_configure(driver, device) + end + end +end + +local function device_added(driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + device:set_field(DEFERRED_CONFIGURE, true) + end +end + +local function info_changed(driver, device, event, args) + -- for unit testing + if device:get_field(TEST_CONFIGURE) then + set_configure(driver, device) + end + + if (device.profile.id ~= args.old_st_store.profile.id or device:get_field(TEST_CONFIGURE)) + and device:get_field(DEFERRED_CONFIGURE) + and device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + + reset_thread(device) + device:emit_event(cubeAction.cubeAction("flipToSide1")) + device:emit_event(cubeFace.cubeFace("face1Up")) + + device:set_field(DEFERRED_CONFIGURE, nil) + end +end + +-- override do_configure to prevent it running in the main driver +local function do_configure(driver, device) end + +-- override driver_switched to prevent it running in the main driver +local function driver_switched(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +local function initial_press_event_handler(driver, device, ib, response) + if get_field_for_endpoint(device, INITIAL_PRESS_ONLY, ib.endpoint_id) then + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON) or {} + local face = 1 + for component, ep in pairs(map) do + if map[component] == ib.endpoint_id then + face = component + break + end + end + + reset_thread(device) + device:emit_event(cubeAction.cubeAction(string.format("flipToSide%d", face))) + device:emit_event(cubeFace.cubeFace(string.format("face%dUp", face))) + end +end + +local function battery_percent_remaining_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +local aqara_cube_handler = { + NAME = "Aqara Cube Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed, + doConfigure = do_configure, + driverSwitched = driver_switched + }, + matter_handlers = { + attr = { + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.BatPercentRemaining.ID] = battery_percent_remaining_attr_handler + } + }, + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = initial_press_event_handler + } + }, + }, + can_handle = require("sub_drivers.aqara_cube.can_handle") +} + +return aqara_cube_handler + diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..53b35712e7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua @@ -0,0 +1,478 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local camera_utils = require "sub_drivers.camera.camera_utils.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" +local fields = require "switch_utils.fields" +local utils = require "st.utils" + +local CameraAttributeHandlers = {} + +CameraAttributeHandlers.enabled_state_factory = function(attribute) + return function(driver, device, ib, response) + device:emit_event_for_endpoint(ib, attribute(ib.data.value and "enabled" or "disabled")) + if attribute == capabilities.imageControl.imageFlipHorizontal then + camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageFlipHorizontal") + elseif attribute == capabilities.imageControl.imageFlipVertical then + camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageFlipVertical") + elseif attribute == capabilities.cameraPrivacyMode.hardPrivacyMode then + camera_utils.update_supported_attributes(device, ib, capabilities.cameraPrivacyMode, "hardPrivacyMode") + end + end +end + +CameraAttributeHandlers.night_vision_factory = function(attribute) + return function(driver, device, ib, response) + if camera_fields.tri_state_map[ib.data.value] then + device:emit_event_for_endpoint(ib, attribute(camera_fields.tri_state_map[ib.data.value])) + if attribute == capabilities.nightVision.illumination then + local _ = device:get_latest_state(camera_fields.profile_components.main, capabilities.nightVision.ID, capabilities.nightVision.supportedAttributes.NAME) or + device:emit_event_for_endpoint(ib, capabilities.nightVision.supportedAttributes({"illumination"})) + end + end + end +end + +function CameraAttributeHandlers.image_rotation_handler(driver, device, ib, response) + local degrees = utils.clamp_value(ib.data.value, 0, 359) + device:emit_event_for_endpoint(ib, capabilities.imageControl.imageRotation(degrees)) + camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageRotation") +end + +function CameraAttributeHandlers.two_way_talk_support_handler(driver, device, ib, response) + local two_way_talk_supported = ib.data.value == clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.HALF_DUPLEX or + ib.data.value == clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.FULL_DUPLEX + device:emit_event_for_endpoint(ib, capabilities.webrtc.talkback(two_way_talk_supported)) + if two_way_talk_supported then + device:emit_event_for_endpoint(ib, capabilities.webrtc.talkbackDuplex( + ib.data.value == clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.HALF_DUPLEX and "halfDuplex" or "fullDuplex" + )) + end +end + +function CameraAttributeHandlers.muted_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, capabilities.audioMute.mute(ib.data.value and "muted" or "unmuted")) +end + +function CameraAttributeHandlers.volume_level_handler(driver, device, ib, response) + local component = device:endpoint_to_component(ib) + local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MAX + local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MIN + -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability + local limited_range = max_volume - min_volume + local normalized_volume = utils.round((ib.data.value - min_volume) * 100.0 / limited_range) + device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(normalized_volume)) +end + +function CameraAttributeHandlers.max_volume_level_handler(driver, device, ib, response) + local component = device:endpoint_to_component(ib) + local max_volume = ib.data.value + local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) + if max_volume > camera_fields.ABS_VOL_MAX or (min_volume and max_volume <= min_volume) then + device.log.warn(string.format("Device reported invalid maximum (%d) %s volume level range value", ib.data.value, component)) + max_volume = camera_fields.ABS_VOL_MAX + end + device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component, max_volume) +end + +function CameraAttributeHandlers.min_volume_level_handler(driver, device, ib, response) + local component = device:endpoint_to_component(ib) + local min_volume = ib.data.value + local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) + if min_volume < camera_fields.ABS_VOL_MIN or (max_volume and min_volume >= max_volume) then + device.log.warn(string.format("Device reported invalid minimum (%d) %s volume level range value", ib.data.value, component)) + min_volume = camera_fields.ABS_VOL_MIN + end + device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component, min_volume) +end + +function CameraAttributeHandlers.status_light_enabled_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, ib.data.value and capabilities.switch.switch.on() or capabilities.switch.switch.off()) +end + +function CameraAttributeHandlers.status_light_brightness_handler(driver, device, ib, response) + local component = device:endpoint_to_component(ib) + local _ = device:get_latest_state(component, capabilities.mode.ID, capabilities.mode.supportedModes.NAME) or + device:emit_event_for_endpoint(ib, capabilities.mode.supportedModes({"low", "medium", "high", "auto"}, {visibility = {displayed = false}})) + local _ = device:get_latest_state(component, capabilities.mode.ID, capabilities.mode.supportedArguments.NAME) or + device:emit_event_for_endpoint(ib, capabilities.mode.supportedArguments({"low", "medium", "high", "auto"}, {visibility = {displayed = false}})) + local mode = "auto" + if ib.data.value == clusters.Global.types.ThreeLevelAutoEnum.LOW then + mode = "low" + elseif ib.data.value == clusters.Global.types.ThreeLevelAutoEnum.MEDIUM then + mode = "medium" + elseif ib.data.value == clusters.Global.types.ThreeLevelAutoEnum.HIGH then + mode = "high" + end + device:emit_event_for_endpoint(ib, capabilities.mode.mode(mode)) +end + +function CameraAttributeHandlers.rate_distortion_trade_off_points_handler(driver, device, ib, response) + if not ib.data.elements then return end + local resolutions = {} + for _, v in ipairs(ib.data.elements) do + local rate_distortion_trade_off_points = v.elements + local width = rate_distortion_trade_off_points.resolution.elements.width.value + local height = rate_distortion_trade_off_points.resolution.elements.height.value + table.insert(resolutions, { + width = width, + height = height + }) + end + device:set_field(camera_fields.SUPPORTED_RESOLUTIONS, resolutions) + local max_encoded_pixel_rate = device:get_field(camera_fields.MAX_ENCODED_PIXEL_RATE) + local max_fps = device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) + if max_encoded_pixel_rate and max_fps and device:get_field(camera_fields.MAX_RESOLUTION) and device:get_field(camera_fields.MIN_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) + end +end + +function CameraAttributeHandlers.max_encoded_pixel_rate_handler(driver, device, ib, response) + device:set_field(camera_fields.MAX_ENCODED_PIXEL_RATE, ib.data.value) + local max_fps = device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) + if max_fps and device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) and device:get_field(camera_fields.MAX_RESOLUTION) and device:get_field(camera_fields.MIN_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, ib.data.value, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) + end +end + +function CameraAttributeHandlers.video_sensor_parameters_handler(driver, device, ib, response) + if not ib.data.elements then return end + local sensor_width = ib.data.elements.sensor_width.value + local sensor_height = ib.data.elements.sensor_height.value + local max_fps = ib.data.elements.max_fps.value + device:set_field(camera_fields.MAX_RESOLUTION, { + width = sensor_width, + height = sensor_height + }) + device:set_field(camera_fields.MAX_FRAMES_PER_SECOND, max_fps) + device:emit_event_for_endpoint(ib, capabilities.cameraViewportSettings.videoSensorParameters({ + width = sensor_width, + height = sensor_height, + maxFPS = max_fps + })) + local max_encoded_pixel_rate = device:get_field(camera_fields.MAX_ENCODED_PIXEL_RATE) + if max_encoded_pixel_rate and max_fps and device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) and device:get_field(camera_fields.MIN_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) + end +end + +function CameraAttributeHandlers.min_viewport_handler(driver, device, ib, response) + if not ib.data.elements then return end + device:emit_event_for_endpoint(ib, capabilities.cameraViewportSettings.minViewportResolution({ + width = ib.data.elements.width.value, + height = ib.data.elements.height.value + })) + device:set_field(camera_fields.MIN_RESOLUTION, { + width = ib.data.elements.width.value, + height = ib.data.elements.height.value + }) + local max_encoded_pixel_rate = device:get_field(camera_fields.MAX_ENCODED_PIXEL_RATE) + local max_fps = device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) + if max_encoded_pixel_rate and max_fps and device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) and device:get_field(camera_fields.MAX_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) + end +end + +function CameraAttributeHandlers.allocated_video_streams_handler(driver, device, ib, response) + if not ib.data.elements then return end + + local dptz_viewports = device:get_field(camera_fields.DPTZ_VIEWPORTS) or {} + local streams = {} + + local previous_streams = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.videoStreamSettings.ID, + capabilities.videoStreamSettings.videoStreams.NAME + ) or {} + + local previous_stream_labels = {} + + for _, stream in pairs(previous_streams) do + previous_stream_labels[stream.streamId] = stream.data.label + end + + for i, v in ipairs(ib.data.elements) do + local stream = v.elements + local stream_id = stream.video_stream_id.value + + -- Use label from existing capability state, if available + local capability_label = previous_stream_labels[stream_id] + + local video_stream = { + streamId = stream_id, + data = { + label = capability_label or "Stream " .. i, + type = stream.stream_usage.value == + clusters.Global.types.StreamUsageEnum.LIVE_VIEW and "liveStream" or "clipRecording", + resolution = { + width = stream.min_resolution.elements.width.value, + height = stream.min_resolution.elements.height.value, + fps = stream.min_frame_rate.value + } + } + } + + if dptz_viewports[stream_id] ~= nil then + video_stream.data.viewport = dptz_viewports[stream_id] + else + video_stream.data.viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { + x = stream.min_resolution.elements.width.value, + y = stream.min_resolution.elements.height.value + } + } + end + + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then + video_stream.data.watermark = stream.watermark_enabled.value and "enabled" or "disabled" + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then + video_stream.data.onScreenDisplay = stream.osd_enabled.value and "enabled" or "disabled" + end + table.insert(streams, video_stream) + end + + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.videoStreams(streams)) +end + +function CameraAttributeHandlers.viewport_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, capabilities.cameraViewportSettings.defaultViewport({ + upperLeftVertex = { x = ib.data.elements.x1.value, y = ib.data.elements.y1.value }, + lowerRightVertex = { x = ib.data.elements.x2.value, y = ib.data.elements.y2.value }, + })) +end + +function CameraAttributeHandlers.dptz_streams_handler(driver, device, ib, response) + if not ib.data.elements then return end + + local dptz_viewports = {} + for _, v in ipairs(ib.data.elements) do + local dptz_struct = v.elements + local stream_id = dptz_struct.video_stream_id.value + local viewport = dptz_struct.viewport.elements + + dptz_viewports[stream_id] = { + upperLeftVertex = { x = viewport.x1.value, y = viewport.y1.value }, + lowerRightVertex = { x = viewport.x2.value, y = viewport.y2.value } + } + end + + device:set_field(camera_fields.DPTZ_VIEWPORTS, dptz_viewports) + + local current_streams = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.videoStreamSettings.ID, + capabilities.videoStreamSettings.videoStreams.NAME + ) or {} + local updated_streams = {} + for _, stream in pairs(current_streams) do + local updated_stream = { + streamId = stream.streamId, + data = stream.data + } + if dptz_viewports[stream.streamId] ~= nil then + updated_stream.data.viewport = dptz_viewports[stream.streamId] + end + table.insert(updated_streams, updated_stream) + end + + if #updated_streams > 0 then + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.videoStreams(updated_streams)) + end +end + +function CameraAttributeHandlers.ptz_position_handler(driver, device, ib, response) + local ptz_map = camera_utils.get_ptz_map(device) + local emit_event = function(idx, value) + if value ~= ptz_map[idx].current then + device:emit_event_for_endpoint(ib, ptz_map[idx].attribute( + utils.clamp_value(value, ptz_map[idx].range.minimum, ptz_map[idx].range.maximum) + )) + end + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + emit_event(camera_fields.PAN_IDX, ib.data.elements.pan.value) + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + emit_event(camera_fields.TILT_IDX, ib.data.elements.tilt.value) + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + emit_event(camera_fields.ZOOM_IDX, ib.data.elements.zoom.value) + end +end + +function CameraAttributeHandlers.ptz_presets_handler(driver, device, ib, response) + if not ib.data.elements then return end + local presets = {} + for _, v in ipairs(ib.data.elements) do + local preset = v.elements + local pan, tilt, zoom = 0, 0, 1 + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + pan = preset.settings.elements.pan.value + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + tilt = preset.settings.elements.tilt.value + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + zoom = preset.settings.elements.zoom.value + end + table.insert(presets, { id = preset.preset_id.value, label = preset.name.value, pan = pan, tilt = tilt, zoom = zoom }) + end + device:emit_event_for_endpoint(ib, capabilities.mechanicalPanTiltZoom.presets(presets)) +end + +function CameraAttributeHandlers.max_presets_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, capabilities.mechanicalPanTiltZoom.maxPresets(ib.data.value)) +end + +function CameraAttributeHandlers.zoom_max_handler(driver, device, ib, response) + if ib.data.value <= camera_fields.ABS_ZOOM_MAX then + device:emit_event_for_endpoint(ib, capabilities.mechanicalPanTiltZoom.zoomRange({ value = { minimum = 1, maximum = ib.data.value } })) + else + device.log.warn(string.format("Device reported invalid maximum zoom (%d)", ib.data.value)) + end +end + +CameraAttributeHandlers.pt_range_handler_factory = function(attribute, limit_field) + return function(driver, device, ib, response) + device:set_field(limit_field, ib.data.value) + local field = string.find(limit_field, "PAN") and "PAN" or "TILT" + local min = device:get_field(camera_fields.pt_range_fields[field].min) + local max = device:get_field(camera_fields.pt_range_fields[field].max) + if min ~= nil and max ~= nil then + local abs_min = field == "PAN" and camera_fields.ABS_PAN_MIN or camera_fields.ABS_TILT_MIN + local abs_max = field == "PAN" and camera_fields.ABS_PAN_MAX or camera_fields.ABS_TILT_MAX + if min < max and min >= abs_min and max <= abs_max then + device:emit_event_for_endpoint(ib, attribute({ value = { minimum = min, maximum = max } })) + device:set_field(camera_fields.pt_range_fields[field].min, nil) + device:set_field(camera_fields.pt_range_fields[field].max, nil) + else + device.log.warn(string.format("Device reported invalid minimum (%d) and maximum (%d) %s " .. + "range values (should be between %d and %d)", min, max, string.lower(field), abs_min, abs_max)) + end + end + end +end + +function CameraAttributeHandlers.max_zones_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.maxZones(ib.data.value)) +end + +function CameraAttributeHandlers.zones_handler(driver, device, ib, response) + if not ib.data.elements then return end + local zones = {} + for _, v in ipairs(ib.data.elements) do + local zone = v.elements + local zone_id = zone.zone_id.value + local zone_type = zone.zone_type.value + local zone_source = zone.zone_source.value + local zone_vertices = {} + if camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE) and + zone_type == clusters.ZoneManagement.types.ZoneTypeEnum.TWODCART_ZONE then + local zone_name = zone.two_d_cartesian_zone.elements.name.value + local zone_use = zone.two_d_cartesian_zone.elements.use.value + for _, vertex in pairs(zone.two_d_cartesian_zone.elements.vertices.elements or {}) do + table.insert(zone_vertices, {vertex = {x = vertex.elements.x.value, y = vertex.elements.y.value}}) + end + local zone_uses = { + [clusters.ZoneManagement.types.ZoneUseEnum.MOTION] = "motion", + [clusters.ZoneManagement.types.ZoneUseEnum.FOCUS] = "focus", + [clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY] = "privacy" + } + local zone_color = zone.two_d_cartesian_zone.elements.color and zone.two_d_cartesian_zone.elements.color.value or nil + table.insert(zones, { + id = zone_id, + name = zone_name, + type = "2DCartesian", + polygonVertices = zone_vertices, + source = zone_source == clusters.ZoneManagement.types.ZoneSourceEnum.MFG and "manufacturer" or "user", + use = zone_uses[zone_use], + color = zone_color + }) + else + device.log.warn(string.format("Zone type not currently supported: (%s)", zone_type)) + end + end + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.zones({value = zones})) +end + +function CameraAttributeHandlers.triggers_handler(driver, device, ib, response) + if not ib.data.elements then return end + local triggers = {} + for _, v in ipairs(ib.data.elements) do + local trigger = v.elements + table.insert(triggers, { + zoneId = trigger.zone_id.value, + initialDuration = trigger.initial_duration.value, + augmentationDuration = trigger.augmentation_duration.value, + maxDuration = trigger.max_duration.value, + blindDuration = trigger.blind_duration.value, + sensitivity = camera_utils.feature_supported(device, clusters.ZoneManagement.ID, + clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) and trigger.sensitivity.value or nil + }) + end + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggers(triggers)) +end + +function CameraAttributeHandlers.sensitivity_max_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.sensitivityRange({minimum = 1, maximum = ib.data.value}, + {visibility = {displayed = false}})) +end + +function CameraAttributeHandlers.sensitivity_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.sensitivity(ib.data.value, {visibility = {displayed = false}})) +end + +function CameraAttributeHandlers.installed_chime_sounds_handler(driver, device, ib, response) + if not ib.data.elements then return end + local installed_chimes = {} + for _, v in ipairs(ib.data.elements) do + local chime = v.elements + table.insert(installed_chimes, {id = chime.chime_id.value, label = chime.name.value}) + end + device:emit_event_for_endpoint(ib, capabilities.sounds.supportedSounds(installed_chimes, {visibility = {displayed = false}})) +end + +function CameraAttributeHandlers.selected_chime_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib, capabilities.sounds.selectedSound(ib.data.value)) +end + +function CameraAttributeHandlers.camera_av_stream_management_attribute_list_handler(driver, device, ib, response) + if not ib.data.elements then return end + local status_light_enabled_present, status_light_brightness_present = false, false + local attribute_ids = {} + for _, attr in ipairs(ib.data.elements) do + if attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID then + status_light_enabled_present = true + table.insert(attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID) + elseif attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID then + status_light_brightness_present = true + table.insert(attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + end + end + local component_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} + component_map.statusLed = { + endpoint_id = ib.endpoint_id, + cluster_id = ib.cluster_id, + attribute_ids = attribute_ids, + } + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist=true}) + camera_cfg.update_status_light_attribute_presence(device, status_light_enabled_present, status_light_brightness_present) + camera_cfg.reconcile_profile_and_capabilities(device) +end + +function CameraAttributeHandlers.camera_feature_map_handler(driver, device, ib, response) + camera_cfg.reconcile_profile_and_capabilities(device) +end + +return CameraAttributeHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua new file mode 100644 index 0000000000..a26afd0ca7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua @@ -0,0 +1,502 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local camera_utils = require "sub_drivers.camera.camera_utils.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local utils = require "st.utils" + +local CameraCapabilityHandlers = {} + +CameraCapabilityHandlers.set_enabled_factory = function(attribute) + return function(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(attribute:write(device, endpoint_id, cmd.args.state == "enabled")) + end +end + +CameraCapabilityHandlers.set_night_vision_factory = function(attribute) + return function(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + for i, v in pairs(camera_fields.tri_state_map) do + if v == cmd.args.mode then + device:send(attribute:write(device, endpoint_id, i)) + return + end + end + device.log.warn(string.format("Capability command sent with unknown value: (%s)", cmd.args.mode)) + end +end + +function CameraCapabilityHandlers.handle_set_image_rotation(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local degrees = utils.clamp_value(cmd.args.rotation, 0, 359) + device:send(clusters.CameraAvStreamManagement.attributes.ImageRotation:write(device, endpoint_id, degrees)) +end + +CameraCapabilityHandlers.handle_mute_commands_factory = function(command) + return function(driver, device, cmd) + local attr + if cmd.component == camera_fields.profile_components.speaker then + attr = clusters.CameraAvStreamManagement.attributes.SpeakerMuted + elseif cmd.component == camera_fields.profile_components.microphone then + attr = clusters.CameraAvStreamManagement.attributes.MicrophoneMuted + else + device.log.warn(string.format("Capability command sent from unknown component: (%s)", cmd.component)) + return + end + local endpoint_id = device:component_to_endpoint(cmd.component) + local mute_state = false + if command == capabilities.audioMute.commands.setMute.NAME then + mute_state = cmd.args.state == "muted" + elseif command == capabilities.audioMute.commands.mute.NAME then + mute_state = true + end + device:send(attr:write(device, endpoint_id, mute_state)) + end +end + +function CameraCapabilityHandlers.handle_set_volume(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute + local volume_range = max_volume - min_volume + local volume = utils.round(cmd.args.volume * volume_range / 100.0 + min_volume) + if cmd.component == camera_fields.profile_components.speaker then + device:send(clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(device, endpoint_id, volume)) + elseif cmd.component == camera_fields.profile_components.microphone then + device:send(clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(device, endpoint_id, volume)) + else + device.log.warn(string.format("Capability command sent from unknown component: (%s)", cmd.component)) + end +end + +function CameraCapabilityHandlers.handle_volume_up(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) + if not volume or volume >= max_volume then return end + -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute + local volume_range = max_volume - min_volume + local converted_volume = utils.round((volume + 1) * volume_range / 100.0 + min_volume) + if cmd.component == camera_fields.profile_components.speaker then + device:send(clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(device, endpoint_id, converted_volume)) + elseif cmd.component == camera_fields.profile_components.microphone then + device:send(clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(device, endpoint_id, converted_volume)) + end +end + +function CameraCapabilityHandlers.handle_volume_down(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) + if not volume or volume <= min_volume then return end + -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute + local volume_range = max_volume - min_volume + local converted_volume = utils.round((volume - 1) * volume_range / 100.0 + min_volume) + if cmd.component == camera_fields.profile_components.speaker then + device:send(clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(device, endpoint_id, converted_volume)) + elseif cmd.component == camera_fields.profile_components.microphone then + device:send(clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(device, endpoint_id, converted_volume)) + end +end + +function CameraCapabilityHandlers.handle_set_status_light_mode(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local level_auto_value + if cmd.args.mode == "low" then level_auto_value = "LOW" + elseif cmd.args.mode == "medium" then level_auto_value = "MEDIUM" + elseif cmd.args.mode == "high" then level_auto_value = "HIGH" + elseif cmd.args.mode == "auto" then level_auto_value = "AUTO" end + if not level_auto_value then + device.log.warn(string.format("Invalid mode received from setMode command: %s", cmd.args.mode)) + return + end + device:send(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:write(device, endpoint_id, + clusters.Global.types.ThreeLevelAutoEnum[level_auto_value])) +end + +function CameraCapabilityHandlers.handle_status_led_on(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(device, endpoint_id, true)) +end + +function CameraCapabilityHandlers.handle_status_led_off(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(device, endpoint_id, false)) +end + +function CameraCapabilityHandlers.handle_audio_recording(driver, device, cmd) + -- TODO: Allocate audio stream if it doesn't exist + local component = device.profile.components[cmd.component] + device:emit_component_event(component, capabilities.audioRecording.audioRecording(cmd.args.state)) +end + +CameraCapabilityHandlers.ptz_relative_move_factory = function(index) + return function (driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local pan_delta = index == camera_fields.PAN_IDX and cmd.args.delta or 0 + local tilt_delta = index == camera_fields.TILT_IDX and cmd.args.delta or 0 + local zoom_delta = index == camera_fields.ZOOM_IDX and cmd.args.delta or 0 + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove( + device, endpoint_id, pan_delta, tilt_delta, zoom_delta + )) + end +end + +CameraCapabilityHandlers.ptz_set_position_factory = function(command) + return function (driver, device, cmd) + local ptz_map = camera_utils.get_ptz_map(device) + if command == capabilities.mechanicalPanTiltZoom.commands.setPanTiltZoom then + ptz_map[camera_fields.PAN_IDX].current = cmd.args.pan + ptz_map[camera_fields.TILT_IDX].current = cmd.args.tilt + ptz_map[camera_fields.ZOOM_IDX].current = cmd.args.zoom + elseif command == capabilities.mechanicalPanTiltZoom.commands.setPan then + ptz_map[camera_fields.PAN_IDX].current = cmd.args.pan + elseif command == capabilities.mechanicalPanTiltZoom.commands.setTilt then + ptz_map[camera_fields.TILT_IDX].current = cmd.args.tilt + else + ptz_map[camera_fields.ZOOM_IDX].current = cmd.args.zoom + end + for _, v in pairs(ptz_map) do + v.current = utils.clamp_value(v.current, v.range.minimum, v.range.maximum) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(device, endpoint_id, + ptz_map[camera_fields.PAN_IDX].current, ptz_map[camera_fields.TILT_IDX].current, ptz_map[camera_fields.ZOOM_IDX].current + )) + end +end + +function CameraCapabilityHandlers.handle_save_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSavePreset( + device, endpoint_id, cmd.args.id, cmd.args.label + )) +end + +function CameraCapabilityHandlers.handle_remove_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRemovePreset(device, endpoint_id, cmd.args.id)) +end + +function CameraCapabilityHandlers.handle_move_to_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZMoveToPreset(device, endpoint_id, cmd.args.id)) +end + +function CameraCapabilityHandlers.handle_new_zone(driver, device, cmd) + local zone_uses = { + ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + ["focus"] = camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.FOCUSZONES) and + clusters.ZoneManagement.types.ZoneUseEnum.FOCUS or clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY, + ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + } + local vertices = {} + for _, v in pairs(cmd.args.polygonVertices or {}) do + table.insert(vertices, clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = v.value.x, y = v.value.y})) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone( + device, endpoint_id, clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = cmd.args.name, + use = zone_uses[cmd.args.use], + vertices = vertices, + color = cmd.args.color + } + ) + )) +end + +function CameraCapabilityHandlers.handle_update_zone(driver, device, cmd) + local zone_uses = { + ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + ["focus"] = camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.FOCUSZONES) and + clusters.ZoneManagement.types.ZoneUseEnum.FOCUS or clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY, + ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + } + if not cmd.args.name or not cmd.args.polygonVertices or not cmd.args.use or not cmd.args.color then + local zones = device:get_latest_state( + camera_fields.profile_components.main, capabilities.zoneManagement.ID, capabilities.zoneManagement.zones.NAME + ) or {} + local found_zone = false + for _, v in pairs(zones) do + if v.id == cmd.args.zoneId then + if not cmd.args.name then cmd.args.name = v.name end + if not cmd.args.polygonVertices then cmd.args.polygonVertices = v.polygonVertices end + if not cmd.args.use then cmd.args.use = v.use end + if not cmd.args.color then cmd.args.color = v.color end -- color may be nil, but it is optional in TwoDCartesianZoneStruct + found_zone = true + break + end + end + if not found_zone then + device.log.warn_with({hub_logs = true}, string.format("Zone does not exist, cannot update the zone.")) + return + end + end + local vertices = {} + for _, v in pairs(cmd.args.polygonVertices or {}) do + table.insert(vertices, clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = v.value.x, y = v.value.y})) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ZoneManagement.server.commands.UpdateTwoDCartesianZone( + device, endpoint_id, cmd.args.zoneId, clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = cmd.args.name, + use = zone_uses[cmd.args.use], + vertices = vertices, + color = cmd.args.color + } + ) + )) +end + +function CameraCapabilityHandlers.handle_remove_zone(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local triggers = device:get_latest_state( + camera_fields.profile_components.main, capabilities.zoneManagement.ID, capabilities.zoneManagement.triggers.NAME + ) or {} + for _, v in pairs(triggers) do + if v.zoneId == cmd.args.zoneId then + device:send(clusters.ZoneManagement.server.commands.RemoveTrigger(device, endpoint_id, cmd.args.zoneId)) + break + end + end + device:send(clusters.ZoneManagement.server.commands.RemoveZone(device, endpoint_id, cmd.args.zoneId)) +end + +function CameraCapabilityHandlers.handle_create_or_update_trigger(driver, device, cmd) + local per_zone_sensitivity_supported = camera_utils.feature_supported( + device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY + ) + if not cmd.args.augmentationDuration or not cmd.args.maxDuration or not cmd.args.blindDuration or + (per_zone_sensitivity_supported and not cmd.args.sensitivity) then + local triggers = device:get_latest_state( + camera_fields.profile_components.main, capabilities.zoneManagement.ID, capabilities.zoneManagement.triggers.NAME + ) or {} + local found_trigger = false + for _, v in pairs(triggers) do + if v.zoneId == cmd.args.zoneId then + if not cmd.args.augmentationDuration then cmd.args.augmentationDuration = v.augmentationDuration end + if not cmd.args.maxDuration then cmd.args.maxDuration = v.maxDuration end + if not cmd.args.blindDuration then cmd.args.blindDuration = v.blindDuration end + if per_zone_sensitivity_supported and not cmd.args.sensitivity then + cmd.args.sensitivity = v.sensitivity + end + found_trigger = true + break + end + end + if not found_trigger then + device.log.warn_with({hub_logs = true}, string.format("Missing fields needed to create trigger.")) + return + end + end + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger( + device, endpoint_id, clusters.ZoneManagement.types.ZoneTriggerControlStruct( + { + zone_id = cmd.args.zoneId, + initial_duration = cmd.args.initialDuration, + augmentation_duration = cmd.args.augmentationDuration, + max_duration = cmd.args.maxDuration, + blind_duration = cmd.args.blindDuration, + sensitivity = per_zone_sensitivity_supported and cmd.args.sensitivity or nil -- omit even if provided by client if per-zone sensitivity is not supported + } + ) + )) +end + +function CameraCapabilityHandlers.handle_remove_trigger(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ZoneManagement.server.commands.RemoveTrigger(device, endpoint_id, cmd.args.zoneId)) +end + +function CameraCapabilityHandlers.handle_set_sensitivity(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + if not camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) then + device:send(clusters.ZoneManagement.attributes.Sensitivity:write(device, endpoint_id, cmd.args.sensitivity)) + else + device.log.warn(string.format("Can't set global zone sensitivity setting, per zone sensitivity enabled.")) + end +end + +function CameraCapabilityHandlers.handle_play_sound(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.Chime.server.commands.PlayChimeSound(device, endpoint_id)) +end + +function CameraCapabilityHandlers.handle_set_selected_sound(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.Chime.attributes.SelectedChime:write(device, endpoint_id, cmd.args.id)) +end + +local function update_viewport_or_label(device, current_streams, streamId, label, viewport) + current_streams = current_streams or {} + for _, stream in ipairs(current_streams) do + if stream.streamId == streamId then + if label ~= nil then stream.data.label = label end + if viewport ~= nil then stream.data.viewport = viewport end + break + end + end + local endpoint_id = device:component_to_endpoint() + device:emit_event_for_endpoint(endpoint_id, capabilities.videoStreamSettings.videoStreams(current_streams)) +end + +function CameraCapabilityHandlers.handle_set_stream(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + + local watermark_enabled, on_screen_display_enabled + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then + if cmd.args.watermark ~= nil then + watermark_enabled = cmd.args.watermark == "enabled" + end + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then + if cmd.args.onScreenDisplay ~= nil then + on_screen_display_enabled = cmd.args.onScreenDisplay == "enabled" + end + end + + local current_streams = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.videoStreamSettings.ID, + capabilities.videoStreamSettings.videoStreams.NAME + ) or {} + local current_stream + for _, stream in pairs(current_streams) do + if stream.streamId == cmd.args.streamId then + current_stream = stream.data + break + end + end + + local needs_reallocation = false + if current_stream ~= nil and current_stream.type ~= cmd.args.type then + needs_reallocation = true + elseif current_stream ~= nil and cmd.args.resolution ~= nil then + if current_stream.resolution.width ~= cmd.args.resolution.width or + current_stream.resolution.height ~= cmd.args.resolution.height or + current_stream.resolution.fps ~= cmd.args.resolution.fps then + needs_reallocation = true + end + elseif current_stream == nil and (cmd.args.type ~= nil or cmd.args.resolution ~= nil) then + needs_reallocation = true + end + + local viewport_changed = false + if cmd.args.viewport ~= nil and + camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, + clusters.CameraAvSettingsUserLevelManagement.types.Feature.DIGITALPTZ) then + if current_stream ~= nil and current_stream.viewport ~= nil then + if current_stream.viewport.upperLeftVertex.x ~= cmd.args.viewport.upperLeftVertex.x or + current_stream.viewport.upperLeftVertex.y ~= cmd.args.viewport.upperLeftVertex.y or + current_stream.viewport.lowerRightVertex.x ~= cmd.args.viewport.lowerRightVertex.x or + current_stream.viewport.lowerRightVertex.y ~= cmd.args.viewport.lowerRightVertex.y then + viewport_changed = true + end + elseif current_stream == nil or current_stream.viewport == nil then + viewport_changed = true + end + + if viewport_changed then + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.DPTZSetViewport(device, endpoint_id, + cmd.args.streamId, + clusters.Global.types.ViewportStruct({ + x1 = cmd.args.viewport.upperLeftVertex.x, + x2 = cmd.args.viewport.lowerRightVertex.x, + y1 = cmd.args.viewport.upperLeftVertex.y, + y2 = cmd.args.viewport.lowerRightVertex.y + }) + )) + end + end + + local label_changed = cmd.args.label ~= nil and cmd.args.label ~= current_stream.label + + if viewport_changed or label_changed then + update_viewport_or_label(device, current_streams, cmd.args.streamId, cmd.args.label, cmd.args.viewport) + end + + if not needs_reallocation then + local watermark_changed = watermark_enabled ~= nil and current_stream.watermark ~= nil and + ((watermark_enabled and current_stream.watermark == "disabled") or + (not watermark_enabled and current_stream.watermark == "enabled")) + local on_screen_display_changed = on_screen_display_enabled ~= nil and current_stream.onScreenDisplay ~= nil and + ((on_screen_display_enabled and current_stream.onScreenDisplay == "disabled") or + (not on_screen_display_enabled and current_stream.onScreenDisplay == "enabled")) + if watermark_changed or on_screen_display_changed then + device:send(clusters.CameraAvStreamManagement.server.commands.VideoStreamModify( + device, endpoint_id, cmd.args.streamId, watermark_enabled, on_screen_display_enabled + )) + end + return + end + + device:send(clusters.CameraAvStreamManagement.server.commands.VideoStreamDeallocate( + device, endpoint_id, cmd.args.streamId + )) + + local stream_usage = cmd.args.type == "liveStream" and + clusters.Global.types.StreamUsageEnum.LIVE_VIEW or clusters.Global.types.StreamUsageEnum.RECORDING + + local min_resolution, max_resolution + if cmd.args.resolution ~= nil then + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = cmd.args.resolution.width, + height = cmd.args.resolution.height + }) + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = cmd.args.resolution.width, + height = cmd.args.resolution.height + }) + end + + if watermark_enabled == nil then + watermark_enabled = camera_fields.video_stream_defaults.watermark_enabled + end + + if on_screen_display_enabled == nil then + on_screen_display_enabled = camera_fields.video_stream_defaults.on_screen_display_enabled + end + + -- Use the same resolution (if available) for MinResolution and MaxResolution to force the server to allocate the + -- stream with the desired resolution. + device:send(clusters.CameraAvStreamManagement.server.commands.VideoStreamAllocate(device, endpoint_id, + stream_usage, + camera_fields.video_stream_defaults.codec, + camera_fields.video_stream_defaults.min_frame_rate, + math.min(device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) or camera_fields.video_stream_defaults.max_frame_rate, + camera_fields.video_stream_defaults.max_frame_rate), + min_resolution or device:get_field(camera_fields.MIN_RESOLUTION) or camera_fields.video_stream_defaults.min_resolution, + max_resolution or device:get_field(camera_fields.MAX_RESOLUTION) or camera_fields.video_stream_defaults.max_resolution, + camera_fields.video_stream_defaults.min_bitrate, + camera_fields.video_stream_defaults.max_bitrate, + camera_fields.video_stream_defaults.key_frame_interval, + watermark_enabled, + on_screen_display_enabled + )) +end + +function CameraCapabilityHandlers.handle_set_default_viewport(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.CameraAvStreamManagement.attributes.Viewport:write( + device, endpoint_id, clusters.Global.types.ViewportStruct({ + x1 = cmd.args.upperLeftVertex.x, + x2 = cmd.args.lowerRightVertex.x, + y1 = cmd.args.upperLeftVertex.y, + y2 = cmd.args.lowerRightVertex.y + }) + )) +end + +return CameraCapabilityHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua new file mode 100644 index 0000000000..8549b02d18 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local capabilities = require "st.capabilities" + +local CameraEventHandlers = {} + +local function has_triggered_zone(triggered_zones, zone_id) + for _, zone in ipairs(triggered_zones or {}) do + if zone.zoneId == zone_id then + return true + end + end + return false +end + +function CameraEventHandlers.zone_triggered_handler(driver, device, ib, response) + local triggered_zones = device:get_field(camera_fields.TRIGGERED_ZONES) or {} + local zone_id = ib.data.elements.zone.value + if not has_triggered_zone(triggered_zones, zone_id) then + table.insert(triggered_zones, { zoneId = zone_id }) + device:set_field(camera_fields.TRIGGERED_ZONES, triggered_zones) + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(triggered_zones)) + end +end + +function CameraEventHandlers.zone_stopped_handler(driver, device, ib, response) + local triggered_zones = device:get_field(camera_fields.TRIGGERED_ZONES) or {} + local zone_id = ib.data.elements.zone.value + local updated_triggered_zones = {} + local zone_removed = false + + for _, zone in ipairs(triggered_zones) do + if zone.zoneId ~= zone_id then + table.insert(updated_triggered_zones, zone) + else + zone_removed = true + end + end + + if zone_removed then + device:set_field(camera_fields.TRIGGERED_ZONES, updated_triggered_zones) + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(updated_triggered_zones)) + end +end + +return CameraEventHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua new file mode 100644 index 0000000000..0619194926 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua @@ -0,0 +1,460 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local button_cfg = require("switch_utils.device_configuration").ButtonCfg +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local camera_utils = require "sub_drivers.camera.camera_utils.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local st_utils = require "st.utils" +local device_cfg = require "switch_utils.device_configuration" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" + +local CameraDeviceConfiguration = {} + +local managed_capability_map = { + { key = "webrtc", capability = capabilities.webrtc }, + { key = "ptz", capability = capabilities.mechanicalPanTiltZoom }, + { key = "zone_management", capability = capabilities.zoneManagement }, + { key = "local_media_storage", capability = capabilities.localMediaStorage }, + { key = "audio_recording", capability = capabilities.audioRecording }, + { key = "video_stream_settings", capability = capabilities.videoStreamSettings }, + { key = "camera_privacy_mode", capability = capabilities.cameraPrivacyMode }, +} + +local function get_status_light_presence(device) + return device:get_field(camera_fields.STATUS_LIGHT_ENABLED_PRESENT), + device:get_field(camera_fields.STATUS_LIGHT_BRIGHTNESS_PRESENT) +end + +local function set_status_light_presence(device, status_light_enabled_present, status_light_brightness_present) + device:set_field(camera_fields.STATUS_LIGHT_ENABLED_PRESENT, status_light_enabled_present == true, { persist = true }) + device:set_field(camera_fields.STATUS_LIGHT_BRIGHTNESS_PRESENT, status_light_brightness_present == true, { persist = true }) +end + +local function build_webrtc_supported_features() + return { + bundle = true, + order = "audio/video", + audio = "sendrecv", + video = "recvonly", + turnSource = "player", + supportTrickleICE = true + } +end + +local function build_ptz_supported_attributes(device) + local supported_attributes = {} + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + table.insert(supported_attributes, "pan") + table.insert(supported_attributes, "panRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + table.insert(supported_attributes, "tilt") + table.insert(supported_attributes, "tiltRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + table.insert(supported_attributes, "zoom") + table.insert(supported_attributes, "zoomRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPRESETS) then + table.insert(supported_attributes, "presets") + table.insert(supported_attributes, "maxPresets") + end + return supported_attributes +end + +local function build_zone_management_supported_features(device) + local supported_features = { "triggerAugmentation" } + if camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) then + table.insert(supported_features, "perZoneSensitivity") + end + return supported_features +end + +local function build_local_media_storage_supported_attributes(device) + local supported_attributes = {} + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + table.insert(supported_attributes, "localVideoRecording") + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then + table.insert(supported_attributes, "localSnapshotRecording") + end + return supported_attributes +end + +local function build_video_stream_settings_supported_features(device) + local supported_features = {} + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + table.insert(supported_features, "liveStreaming") + table.insert(supported_features, "clipRecording") + table.insert(supported_features, "perStreamViewports") + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then + table.insert(supported_features, "watermark") + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then + table.insert(supported_features, "onScreenDisplay") + end + return supported_features +end + +local function build_camera_privacy_supported_attributes() + return { "softRecordingPrivacyMode", "softLivestreamPrivacyMode" } +end + +local function build_camera_privacy_supported_commands() + return { "setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode" } +end + +local function capabilities_needing_reinit(device) + local capabilities_to_reinit = {} + + local function should_init(capability, attribute, expected) + if device:supports_capability(capability) then + local current = st_utils.deep_copy(device:get_latest_state( + camera_fields.profile_components.main, + capability.ID, + attribute.NAME, + {} + )) + return not switch_utils.deep_equals(current, expected) + end + return false + end + + if should_init(capabilities.webrtc, capabilities.webrtc.supportedFeatures, build_webrtc_supported_features()) then + capabilities_to_reinit.webrtc = true + end + + if should_init(capabilities.mechanicalPanTiltZoom, capabilities.mechanicalPanTiltZoom.supportedAttributes, build_ptz_supported_attributes(device)) then + capabilities_to_reinit.ptz = true + end + + if should_init(capabilities.zoneManagement, capabilities.zoneManagement.supportedFeatures, build_zone_management_supported_features(device)) then + capabilities_to_reinit.zone_management = true + end + + if should_init(capabilities.localMediaStorage, capabilities.localMediaStorage.supportedAttributes, build_local_media_storage_supported_attributes(device)) then + capabilities_to_reinit.local_media_storage = true + end + + if device:supports_capability(capabilities.audioRecording) then + local audio_enabled_state = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.audioRecording.ID, + capabilities.audioRecording.audioRecording.NAME + ) + if audio_enabled_state == nil then + capabilities_to_reinit.audio_recording = true + end + end + + if should_init(capabilities.videoStreamSettings, capabilities.videoStreamSettings.supportedFeatures, build_video_stream_settings_supported_features(device)) then + capabilities_to_reinit.video_stream_settings = true + end + + if should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedAttributes, build_camera_privacy_supported_attributes()) or + should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedCommands, build_camera_privacy_supported_commands()) then + capabilities_to_reinit.camera_privacy_mode = true + end + + return capabilities_to_reinit +end + +function CameraDeviceConfiguration.create_child_devices(driver, device) + local num_floodlight_eps = 0 + local parent_child_device = false + for _, ep in ipairs(device.endpoints or {}) do + if device:supports_server_cluster(clusters.OnOff.ID, ep.endpoint_id) then + local child_profile = device_cfg.SwitchCfg.assign_profile_for_onoff_ep(device, ep.endpoint_id) + if child_profile then + num_floodlight_eps = num_floodlight_eps + 1 + local name = string.format("%s %d", "Floodlight", num_floodlight_eps) + driver:try_create_device( + { + type = "EDGE_CHILD", + label = name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%d", ep.endpoint_id), + vendor_provided_label = name + } + ) + parent_child_device = true + end + end + end + if parent_child_device then + device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) + device:set_find_child(switch_utils.find_child) + end +end + +function CameraDeviceConfiguration.match_profile(device) + local status_light_enabled_present, status_light_brightness_present = get_status_light_presence(device) + local profile_update_requested = false + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local status_led_component_capabilities = {} + local speaker_component_capabilities = {} + local microphone_component_capabilities = {} + local doorbell_component_capabilities = {} + + local function has_server_cluster_type(cluster) + return cluster.cluster_type == "SERVER" or cluster.cluster_type == "BOTH" + end + + local camera_endpoints = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.CAMERA) + if #camera_endpoints > 0 then + local camera_ep = switch_utils.get_endpoint_info(device, camera_endpoints[1]) + for _, ep_cluster in pairs(camera_ep.clusters or {}) do + if ep_cluster.cluster_id == clusters.CameraAvStreamManagement.ID and has_server_cluster_type(ep_cluster) then + local clus_has_feature = function(feature_bitmap) + return clusters.CameraAvStreamManagement.are_features_supported(feature_bitmap, ep_cluster.feature_map) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + if switch_utils.find_cluster_on_ep(camera_ep, clusters.PushAvStreamTransport.ID, "SERVER") then + table.insert(main_component_capabilities, capabilities.videoCapture2.ID) + end + table.insert(main_component_capabilities, capabilities.cameraViewportSettings.ID) + table.insert(main_component_capabilities, capabilities.videoStreamSettings.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.LOCAL_STORAGE) then + table.insert(main_component_capabilities, capabilities.localMediaStorage.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.AUDIO) then + if switch_utils.find_cluster_on_ep(camera_ep, clusters.PushAvStreamTransport.ID, "SERVER") then + table.insert(main_component_capabilities, capabilities.audioRecording.ID) + end + table.insert(microphone_component_capabilities, capabilities.audioMute.ID) + table.insert(microphone_component_capabilities, capabilities.audioVolume.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then + table.insert(main_component_capabilities, capabilities.imageCapture.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.PRIVACY) then + table.insert(main_component_capabilities, capabilities.cameraPrivacyMode.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SPEAKER) then + table.insert(speaker_component_capabilities, capabilities.audioMute.ID) + table.insert(speaker_component_capabilities, capabilities.audioVolume.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL) then + table.insert(main_component_capabilities, capabilities.imageControl.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.HIGH_DYNAMIC_RANGE) then + table.insert(main_component_capabilities, capabilities.hdr.ID) + end + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.NIGHT_VISION) then + table.insert(main_component_capabilities, capabilities.nightVision.ID) + end + elseif ep_cluster.cluster_id == clusters.CameraAvSettingsUserLevelManagement.ID and has_server_cluster_type(ep_cluster) then + local clus_has_feature = function(feature_bitmap) + return clusters.CameraAvSettingsUserLevelManagement.are_features_supported(feature_bitmap, ep_cluster.feature_map) + end + if clus_has_feature(clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) or + clus_has_feature(clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) or + clus_has_feature(clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then + table.insert(main_component_capabilities, capabilities.mechanicalPanTiltZoom.ID) + end + elseif ep_cluster.cluster_id == clusters.ZoneManagement.ID and has_server_cluster_type(ep_cluster) and + clusters.ZoneManagement.are_features_supported(clusters.ZoneManagement.types.Feature.USER_DEFINED, ep_cluster.feature_map) then + table.insert(main_component_capabilities, capabilities.zoneManagement.ID) + elseif ep_cluster.cluster_id == clusters.OccupancySensing.ID and has_server_cluster_type(ep_cluster) then + table.insert(main_component_capabilities, capabilities.motionSensor.ID) + elseif ep_cluster.cluster_id == clusters.WebRTCTransportProvider.ID and has_server_cluster_type(ep_cluster) then + table.insert(main_component_capabilities, capabilities.webrtc.ID) + end + end + end + local chime_endpoints = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.CHIME) + if #chime_endpoints > 0 then + table.insert(main_component_capabilities, capabilities.sounds.ID) + end + local doorbell_endpoints = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) + if #doorbell_endpoints > 0 then + table.insert(doorbell_component_capabilities, capabilities.button.ID) + end + if status_light_enabled_present then + table.insert(status_led_component_capabilities, capabilities.switch.ID) + end + if status_light_brightness_present then + table.insert(status_led_component_capabilities, capabilities.mode.ID) + end + + table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.main, main_component_capabilities}) + if #status_led_component_capabilities > 0 then + table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.statusLed, status_led_component_capabilities}) + end + if #speaker_component_capabilities > 0 then + table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.speaker, speaker_component_capabilities}) + end + if #microphone_component_capabilities > 0 then + table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.microphone, microphone_component_capabilities}) + end + if #doorbell_component_capabilities > 0 then + table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.doorbell, doorbell_component_capabilities}) + end + + if camera_utils.optional_capabilities_list_changed(optional_supported_component_capabilities, device.profile.components) then + profile_update_requested = true + device:try_update_metadata({profile = "camera", optional_component_capabilities = optional_supported_component_capabilities}) + if #doorbell_endpoints > 0 then + CameraDeviceConfiguration.update_doorbell_component_map(device, doorbell_endpoints[1]) + button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) + end + end + + return profile_update_requested +end + +local function init_webrtc(device) + if device:supports_capability(capabilities.webrtc) then + local transport_provider_ep_ids = device:get_endpoints(clusters.WebRTCTransportProvider.ID) + device:emit_event_for_endpoint(transport_provider_ep_ids[1], capabilities.webrtc.supportedFeatures(build_webrtc_supported_features())) + end +end + +local function init_ptz(device) + if device:supports_capability(capabilities.mechanicalPanTiltZoom) then + local av_settings_ep_ids = device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) + device:emit_event_for_endpoint(av_settings_ep_ids[1], capabilities.mechanicalPanTiltZoom.supportedAttributes(build_ptz_supported_attributes(device))) + end +end + +local function init_zone_management(device) + if device:supports_capability(capabilities.zoneManagement) then + local zone_management_ep_ids = device:get_endpoints(clusters.ZoneManagement.ID) + device:emit_event_for_endpoint(zone_management_ep_ids[1], capabilities.zoneManagement.supportedFeatures(build_zone_management_supported_features(device))) + end +end + +local function init_local_media_storage(device) + if device:supports_capability(capabilities.localMediaStorage) then + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.localMediaStorage.supportedAttributes(build_local_media_storage_supported_attributes(device))) + end +end + +local function init_audio_recording(device) + if device:supports_capability(capabilities.audioRecording) then + local audio_enabled_state = device:get_latest_state( + camera_fields.profile_components.main, capabilities.audioRecording.ID, capabilities.audioRecording.audioRecording.NAME + ) + if audio_enabled_state == nil then + -- Initialize with enabled default if state is unset + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.audioRecording.audioRecording("enabled")) + end + end +end + +local function init_video_stream_settings(device) + if device:supports_capability(capabilities.videoStreamSettings) then + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.videoStreamSettings.supportedFeatures(build_video_stream_settings_supported_features(device))) + end +end + +local function init_camera_privacy_mode(device) + if device:supports_capability(capabilities.cameraPrivacyMode) then + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedAttributes(build_camera_privacy_supported_attributes())) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(build_camera_privacy_supported_commands())) + end +end + +function CameraDeviceConfiguration.initialize_camera_capabilities(device) + init_webrtc(device) + init_ptz(device) + init_zone_management(device) + init_local_media_storage(device) + init_audio_recording(device) + init_video_stream_settings(device) + init_camera_privacy_mode(device) +end + +local function initialize_selected_camera_capabilities(device, capabilities_to_reinit) + local reinit_targets = capabilities_to_reinit or {} + + if reinit_targets.webrtc then + init_webrtc(device) + end + if reinit_targets.ptz then + init_ptz(device) + end + if reinit_targets.zone_management then + init_zone_management(device) + end + if reinit_targets.local_media_storage then + init_local_media_storage(device) + end + if reinit_targets.audio_recording then + init_audio_recording(device) + end + if reinit_targets.video_stream_settings then + init_video_stream_settings(device) + end + if reinit_targets.camera_privacy_mode then + init_camera_privacy_mode(device) + end +end + +local function profile_capability_set(profile) + local capability_set = {} + for _, component in pairs((profile or {}).components or {}) do + for _, capability in pairs(component.capabilities or {}) do + if capability.id ~= nil then + capability_set[capability.id] = true + end + end + end + return capability_set +end + +local function changed_capabilities_from_profiles(old_profile, new_profile) + local flags = {} + local old_set = profile_capability_set(old_profile) + local new_set = profile_capability_set(new_profile) + + for _, managed in ipairs(managed_capability_map) do + local id = managed.capability.ID + if old_set[id] ~= new_set[id] and new_set[id] == true then + flags[managed.key] = true + end + end + + return flags +end + +function CameraDeviceConfiguration.reconcile_profile_and_capabilities(device) + local profile_update_requested = CameraDeviceConfiguration.match_profile(device) + if not profile_update_requested then + local capabilities_to_reinit = capabilities_needing_reinit(device) + initialize_selected_camera_capabilities(device, capabilities_to_reinit) + end + return profile_update_requested +end + +function CameraDeviceConfiguration.update_status_light_attribute_presence(device, status_light_enabled_present, status_light_brightness_present) + set_status_light_presence(device, status_light_enabled_present, status_light_brightness_present) +end + +function CameraDeviceConfiguration.reinitialize_changed_camera_capabilities_and_subscriptions(device, old_profile, new_profile) + local changed_capabilities = changed_capabilities_from_profiles(old_profile, new_profile) + initialize_selected_camera_capabilities(device, changed_capabilities) + device:subscribe() + if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) > 0 then + button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) + end +end + +function CameraDeviceConfiguration.update_doorbell_component_map(device, ep) + local component_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} + component_map.doorbell = ep + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) +end + +return CameraDeviceConfiguration diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua new file mode 100644 index 0000000000..c88f177707 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" + +local CameraFields = {} + +CameraFields.MAX_ENCODED_PIXEL_RATE = "__max_encoded_pixel_rate" +CameraFields.MAX_FRAMES_PER_SECOND = "__max_frames_per_second" +CameraFields.MAX_VOLUME_LEVEL = "__max_volume_level" +CameraFields.MIN_VOLUME_LEVEL = "__min_volume_level" +CameraFields.SUPPORTED_RESOLUTIONS = "__supported_resolutions" +CameraFields.MAX_RESOLUTION = "__max_resolution" +CameraFields.MIN_RESOLUTION = "__min_resolution" +CameraFields.TRIGGERED_ZONES = "__triggered_zones" +CameraFields.DPTZ_VIEWPORTS = "__dptz_viewports" +CameraFields.STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present" +CameraFields.STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present" + +CameraFields.CameraAVSMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvStreamManagement.ID } +CameraFields.CameraAVSULMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvSettingsUserLevelManagement.ID } +CameraFields.ZoneManagementFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.ZoneManagement.ID } + +CameraFields.PAN_IDX = "PAN" +CameraFields.TILT_IDX = "TILT" +CameraFields.ZOOM_IDX = "ZOOM" + +CameraFields.pt_range_fields = { + [CameraFields.PAN_IDX] = { max = "__MAX_PAN" , min = "__MIN_PAN" }, + [CameraFields.TILT_IDX] = { max = "__MAX_TILT" , min = "__MIN_TILT" } +} + +CameraFields.profile_components = { + main = "main", + statusLed = "statusLed", + speaker = "speaker", + microphone = "microphone", + doorbell = "doorbell" +} + +CameraFields.tri_state_map = { + [clusters.CameraAvStreamManagement.types.TriStateAutoEnum.OFF] = "off", + [clusters.CameraAvStreamManagement.types.TriStateAutoEnum.ON] = "on", + [clusters.CameraAvStreamManagement.types.TriStateAutoEnum.AUTO] = "auto" +} + +CameraFields.ABS_PAN_MAX = 180 +CameraFields.ABS_PAN_MIN = -180 +CameraFields.ABS_TILT_MAX = 180 +CameraFields.ABS_TILT_MIN = -180 +CameraFields.ABS_ZOOM_MAX = 100 +CameraFields.ABS_ZOOM_MIN = 1 +CameraFields.ABS_VOL_MAX = 254.0 +CameraFields.ABS_VOL_MIN = 0.0 + +-- Define defaults for allocating new streams. Note that these are the same values use by the hub. +CameraFields.video_stream_defaults = { + codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 320, height = 240}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + min_bitrate = 10000, + max_bitrate = 2000000, + key_frame_interval = 4000, + watermark_enabled = false, + on_screen_display_enabled = false +} + +return CameraFields diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua new file mode 100644 index 0000000000..78792b94b3 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua @@ -0,0 +1,341 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" + +local CameraUtils = {} + +function CameraUtils.component_to_endpoint(device, component) + local camera_eps = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + table.sort(camera_eps) + for _, ep in ipairs(camera_eps or {}) do + if ep ~= 0 then -- 0 is the matter RootNode endpoint + return ep + end + end + return nil +end + +function CameraUtils.update_camera_component_map(device) + local camera_av_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + if #camera_av_ep_ids > 0 then + -- An assumption here: there is only 1 CameraAvStreamManagement cluster on the device (which is all our profile supports) + local component_map = {} + if CameraUtils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.AUDIO) then + component_map.microphone = { + endpoint_id = camera_av_ep_ids[1], + cluster_id = clusters.CameraAvStreamManagement.ID, + attribute_ids = { + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted.ID, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel.ID, + clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel.ID, + clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel.ID, + }, + } + end + if CameraUtils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + component_map.speaker = { + endpoint_id = camera_av_ep_ids[1], + cluster_id = clusters.CameraAvStreamManagement.ID, + attribute_ids = { + clusters.CameraAvStreamManagement.attributes.SpeakerMuted.ID, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel.ID, + clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel.ID, + clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel.ID, + }, + } + end + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) + end +end + +function CameraUtils.get_ptz_map(device) + local mechanicalPanTiltZoom = capabilities.mechanicalPanTiltZoom + local ptz_map = { + [camera_fields.PAN_IDX] = { + current = device:get_latest_state("main", mechanicalPanTiltZoom.ID, mechanicalPanTiltZoom.pan.NAME), + range = device:get_latest_state("main", mechanicalPanTiltZoom.ID, mechanicalPanTiltZoom.panRange.NAME) or + { minimum = camera_fields.ABS_PAN_MIN, maximum = camera_fields.ABS_PAN_MAX }, + attribute = mechanicalPanTiltZoom.pan + }, + [camera_fields.TILT_IDX] = { + current = device:get_latest_state("main", mechanicalPanTiltZoom.ID, mechanicalPanTiltZoom.tilt.NAME), + range = device:get_latest_state("main", mechanicalPanTiltZoom.ID, mechanicalPanTiltZoom.tiltRange.NAME) or + { minimum = camera_fields.ABS_TILT_MIN, maximum = camera_fields.ABS_TILT_MAX }, + attribute = mechanicalPanTiltZoom.tilt + }, + [camera_fields.ZOOM_IDX] = { + current = device:get_latest_state("main", mechanicalPanTiltZoom.ID, mechanicalPanTiltZoom.zoom.NAME), + range = device:get_latest_state("main", mechanicalPanTiltZoom.ID, mechanicalPanTiltZoom.zoomRange.NAME) or + { minimum = camera_fields.ABS_ZOOM_MIN, maximum = camera_fields.ABS_ZOOM_MAX }, + attribute = mechanicalPanTiltZoom.zoom + } + } + return ptz_map +end + +function CameraUtils.feature_supported(device, cluster_id, feature_flag) + return #device:get_endpoints(cluster_id, { feature_bitmap = feature_flag }) > 0 +end + +function CameraUtils.update_supported_attributes(device, ib, capability, attribute) + local attribute_set = device:get_latest_state( + camera_fields.profile_components.main, capability.ID, capability.supportedAttributes.NAME + ) or {} + if not switch_utils.tbl_contains(attribute_set, attribute) then + local updated_attribute_set = {} + for _, v in ipairs(attribute_set) do + table.insert(updated_attribute_set, v) + end + table.insert(updated_attribute_set, attribute) + device:emit_event_for_endpoint(ib, capability.supportedAttributes(updated_attribute_set)) + end +end + +function CameraUtils.compute_fps(max_encoded_pixel_rate, width, height, max_fps) + local fps_step = 15.0 + local fps = math.min(max_encoded_pixel_rate / (width * height), max_fps) + return math.tointeger(math.floor(fps / fps_step) * fps_step) +end + +function CameraUtils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + local resolutions = {} + local added_resolutions = {} + + local function add_resolution(width, height) + local key = width .. "x" .. height + if not added_resolutions[key] then + local resolution = { width = width, height = height } + resolution.fps = CameraUtils.compute_fps(max_encoded_pixel_rate, width, height, max_fps) + table.insert(resolutions, resolution) + added_resolutions[key] = true + end + end + + local min_resolution = device:get_field(camera_fields.MIN_RESOLUTION) + if min_resolution then + add_resolution(min_resolution.width, min_resolution.height) + end + + local trade_off_resolutions = device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) + for _, v in pairs(trade_off_resolutions or {}) do + add_resolution(v.width, v.height) + end + + local max_resolution = device:get_field(camera_fields.MAX_RESOLUTION) + if max_resolution then + add_resolution(max_resolution.width, max_resolution.height) + end + + return resolutions +end + +function CameraUtils.optional_capabilities_list_changed(new_component_capability_list, previous_component_capability_list) + local previous_capability_map = {} + local component_sizes = {} + + local previous_component_count = 0 + for component_name, component in pairs(previous_component_capability_list or {}) do + previous_capability_map[component_name] = {} + component_sizes[component_name] = 0 + for _, capability in pairs(component.capabilities or {}) do + if capability.id ~= "firmwareUpdate" and capability.id ~= "refresh" then + previous_capability_map[component_name][capability.id] = true + component_sizes[component_name] = component_sizes[component_name] + 1 + end + end + previous_component_count = previous_component_count + 1 + end + + local number_of_components_counted = 0 + for _, new_component_capabilities in pairs(new_component_capability_list or {}) do + local component_name = new_component_capabilities[1] + local capability_list = new_component_capabilities[2] + + number_of_components_counted = number_of_components_counted + 1 + + if previous_capability_map[component_name] == nil then + return true + end + + for _, capability in ipairs(capability_list) do + if previous_capability_map[component_name][capability] == nil then + return true + end + end + + if #capability_list ~= component_sizes[component_name] then + return true + end + end + + if number_of_components_counted ~= previous_component_count then + return true + end + + return false +end + +function CameraUtils.subscribe(device) + local camera_subscribed_attributes = { + [capabilities.hdr.ID] = { + clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, + clusters.CameraAvStreamManagement.attributes.ImageRotation + }, + [capabilities.nightVision.ID] = { + clusters.CameraAvStreamManagement.attributes.NightVision, + clusters.CameraAvStreamManagement.attributes.NightVisionIllum + }, + [capabilities.imageControl.ID] = { + clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal, + clusters.CameraAvStreamManagement.attributes.ImageFlipVertical + }, + [capabilities.cameraPrivacyMode.ID] = { + clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn + }, + [capabilities.webrtc.ID] = { + clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport + }, + [capabilities.mechanicalPanTiltZoom.ID] = { + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin + }, + [capabilities.audioMute.ID] = { + clusters.CameraAvStreamManagement.attributes.SpeakerMuted, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted + }, + [capabilities.audioVolume.ID] = { + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel + }, + [capabilities.mode.ID] = { + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness + }, + [capabilities.switch.ID] = { + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.OnOff.attributes.OnOff + }, + [capabilities.videoStreamSettings.ID] = { + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams + }, + [capabilities.zoneManagement.ID] = { + clusters.ZoneManagement.attributes.MaxZones, + clusters.ZoneManagement.attributes.Zones, + clusters.ZoneManagement.attributes.Triggers, + clusters.ZoneManagement.attributes.SensitivityMax, + clusters.ZoneManagement.attributes.Sensitivity + }, + [capabilities.sounds.ID] = { + clusters.Chime.attributes.InstalledChimeSounds, + clusters.Chime.attributes.SelectedChime + }, + [capabilities.localMediaStorage.ID] = { + clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled + }, + [capabilities.cameraViewportSettings.ID] = { + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.Viewport + }, + [capabilities.motionSensor.ID] = { + clusters.OccupancySensing.attributes.Occupancy + }, + [capabilities.switchLevel.ID] = { + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + }, + [capabilities.colorControl.ID] = { + clusters.ColorControl.attributes.ColorMode, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + }, + [capabilities.colorTemperature.ID] = { + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + }, + } + + local camera_subscribed_events = { + [capabilities.zoneManagement.ID] = { + clusters.ZoneManagement.events.ZoneTriggered, + clusters.ZoneManagement.events.ZoneStopped + }, + [capabilities.button.ID] = { + clusters.Switch.events.InitialPress, + clusters.Switch.events.LongPress, + clusters.Switch.events.ShortRelease, + clusters.Switch.events.MultiPressComplete + } + } + + local im = require "st.matter.interaction_model" + + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + local devices_seen, capabilities_seen, attributes_seen, events_seen = {}, {}, {}, {} + local additional_attributes = {} + + if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) > 0 then + table.insert(additional_attributes, clusters.CameraAvStreamManagement.attributes.AttributeList) + table.insert(additional_attributes, camera_fields.CameraAVSMFeatureMapAttr) + end + + if #device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) > 0 then + table.insert(additional_attributes, camera_fields.CameraAVSULMFeatureMapAttr) + end + + if #device:get_endpoints(clusters.ZoneManagement.ID) > 0 then + table.insert(additional_attributes, camera_fields.ZoneManagementFeatureMapAttr) + end + + for _, endpoint_info in ipairs(device.endpoints) do + local checked_device = switch_utils.find_child(device, endpoint_info.endpoint_id) or device + if not devices_seen[checked_device.id] then + switch_utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, + camera_subscribed_attributes, camera_subscribed_events + ) + devices_seen[checked_device.id] = true -- only loop through any device once + end + end + + for _, attr in ipairs(additional_attributes) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + end + + if #subscribe_request.info_blocks > 0 then + device:send(subscribe_request) + end +end + +return CameraUtils diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/can_handle.lua new file mode 100644 index 0000000000..25a441d641 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local device_lib = require "st.device" + local fields = require "switch_utils.fields" + local switch_utils = require "switch_utils.utils" + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + local version = require "version" + if version.rpc >= 10 and version.api >= 16 and + #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.CAMERA) > 0 then + return true, require("sub_drivers.camera") + end + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua new file mode 100644 index 0000000000..2aa698a08a --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua @@ -0,0 +1,213 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +------------------------------------------------------------------------------------- +-- Matter Camera Sub Driver +------------------------------------------------------------------------------------- + +local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" +local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local camera_utils = require "sub_drivers.camera.camera_utils.utils" +local capabilities = require "st.capabilities" +local capability_handlers = require "sub_drivers.camera.camera_handlers.capability_handlers" +local clusters = require "st.matter.clusters" +local event_handlers = require "sub_drivers.camera.camera_handlers.event_handlers" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" + +local CameraLifecycleHandlers = {} + +function CameraLifecycleHandlers.device_init(driver, device) + device:set_component_to_endpoint_fn(camera_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + device:extend_device("emit_event_for_endpoint", switch_utils.emit_event_for_endpoint) + if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then + device:set_find_child(switch_utils.find_child) + end + device:extend_device("subscribe", camera_utils.subscribe) + device:subscribe() +end + +function CameraLifecycleHandlers.do_configure(driver, device) + camera_utils.update_camera_component_map(device) + if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0 then + camera_cfg.match_profile(device) + end + camera_cfg.create_child_devices(driver, device) + camera_cfg.initialize_camera_capabilities(device) +end + +function CameraLifecycleHandlers.driver_switched(driver, device) + camera_utils.update_camera_component_map(device) + if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0 then + camera_cfg.match_profile(device) + end + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +function CameraLifecycleHandlers.info_changed(driver, device, event, args) + local software_version_changed = device.matter_version ~= nil and args.old_st_store.matter_version ~= nil and + device.matter_version.software ~= args.old_st_store.matter_version.software + local profile_changed = not switch_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) + + if software_version_changed then + camera_cfg.reconcile_profile_and_capabilities(device) + elseif profile_changed then + camera_cfg.reinitialize_changed_camera_capabilities_and_subscriptions(device, args.old_st_store.profile, device.profile) + end +end + +function CameraLifecycleHandlers.added() end + +local camera_handler = { + NAME = "Camera Handler", + lifecycle_handlers = { + init = CameraLifecycleHandlers.device_init, + infoChanged = CameraLifecycleHandlers.info_changed, + doConfigure = CameraLifecycleHandlers.do_configure, + driverSwitched = CameraLifecycleHandlers.driver_switched, + added = CameraLifecycleHandlers.added + }, + matter_handlers = { + attr = { + [clusters.CameraAvStreamManagement.ID] = { + [clusters.CameraAvStreamManagement.attributes.HDRModeEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.hdr.hdr), + [clusters.CameraAvStreamManagement.attributes.NightVision.ID] = attribute_handlers.night_vision_factory(capabilities.nightVision.nightVision), + [clusters.CameraAvStreamManagement.attributes.NightVisionIllum.ID] = attribute_handlers.night_vision_factory(capabilities.nightVision.illumination), + [clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal.ID] = attribute_handlers.enabled_state_factory(capabilities.imageControl.imageFlipHorizontal), + [clusters.CameraAvStreamManagement.attributes.ImageFlipVertical.ID] = attribute_handlers.enabled_state_factory(capabilities.imageControl.imageFlipVertical), + [clusters.CameraAvStreamManagement.attributes.ImageRotation.ID] = attribute_handlers.image_rotation_handler, + [clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.cameraPrivacyMode.softRecordingPrivacyMode), + [clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.cameraPrivacyMode.softLivestreamPrivacyMode), + [clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID] = attribute_handlers.enabled_state_factory(capabilities.cameraPrivacyMode.hardPrivacyMode), + [clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport.ID] = attribute_handlers.two_way_talk_support_handler, + [clusters.CameraAvStreamManagement.attributes.SpeakerMuted.ID] = attribute_handlers.muted_handler, + [clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel.ID] = attribute_handlers.volume_level_handler, + [clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel.ID] = attribute_handlers.max_volume_level_handler, + [clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel.ID] = attribute_handlers.min_volume_level_handler, + [clusters.CameraAvStreamManagement.attributes.MicrophoneMuted.ID] = attribute_handlers.muted_handler, + [clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel.ID] = attribute_handlers.volume_level_handler, + [clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel.ID] = attribute_handlers.max_volume_level_handler, + [clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel.ID] = attribute_handlers.min_volume_level_handler, + [clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID] = attribute_handlers.status_light_enabled_handler, + [clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID] = attribute_handlers.status_light_brightness_handler, + [clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints.ID] = attribute_handlers.rate_distortion_trade_off_points_handler, + [clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate.ID] = attribute_handlers.max_encoded_pixel_rate_handler, + [clusters.CameraAvStreamManagement.attributes.VideoSensorParams.ID] = attribute_handlers.video_sensor_parameters_handler, + [clusters.CameraAvStreamManagement.attributes.MinViewportResolution.ID] = attribute_handlers.min_viewport_handler, + [clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams.ID] = attribute_handlers.allocated_video_streams_handler, + [clusters.CameraAvStreamManagement.attributes.Viewport.ID] = attribute_handlers.viewport_handler, + [clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.localMediaStorage.localSnapshotRecording), + [clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.localMediaStorage.localVideoRecording), + [clusters.CameraAvStreamManagement.attributes.AttributeList.ID] = attribute_handlers.camera_av_stream_management_attribute_list_handler, + [camera_fields.CameraAVSMFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler + }, + [clusters.CameraAvSettingsUserLevelManagement.ID] = { + [clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition.ID] = attribute_handlers.ptz_position_handler, + [clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets.ID] = attribute_handlers.ptz_presets_handler, + [clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets.ID] = attribute_handlers.max_presets_handler, + [clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax.ID] = attribute_handlers.zoom_max_handler, + [clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.panRange, camera_fields.pt_range_fields[camera_fields.PAN_IDX].max), + [clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.panRange, camera_fields.pt_range_fields[camera_fields.PAN_IDX].min), + [clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.tiltRange, camera_fields.pt_range_fields[camera_fields.TILT_IDX].max), + [clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.tiltRange, camera_fields.pt_range_fields[camera_fields.TILT_IDX].min), + [clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams.ID] = attribute_handlers.dptz_streams_handler, + [camera_fields.CameraAVSULMFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler + }, + [clusters.ZoneManagement.ID] = { + [clusters.ZoneManagement.attributes.MaxZones.ID] = attribute_handlers.max_zones_handler, + [clusters.ZoneManagement.attributes.Zones.ID] = attribute_handlers.zones_handler, + [clusters.ZoneManagement.attributes.Triggers.ID] = attribute_handlers.triggers_handler, + [clusters.ZoneManagement.attributes.SensitivityMax.ID] = attribute_handlers.sensitivity_max_handler, + [clusters.ZoneManagement.attributes.Sensitivity.ID] = attribute_handlers.sensitivity_handler, + [camera_fields.ZoneManagementFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler + }, + [clusters.Chime.ID] = { + [clusters.Chime.attributes.InstalledChimeSounds.ID] = attribute_handlers.installed_chime_sounds_handler, + [clusters.Chime.attributes.SelectedChime.ID] = attribute_handlers.selected_chime_handler + } + }, + event = { + [clusters.ZoneManagement.ID] = { + [clusters.ZoneManagement.events.ZoneTriggered.ID] = event_handlers.zone_triggered_handler, + [clusters.ZoneManagement.events.ZoneStopped.ID] = event_handlers.zone_stopped_handler + } + } + }, + capability_handlers = { + [capabilities.hdr.ID] = { + [capabilities.hdr.commands.setHdr.NAME] = capability_handlers.set_enabled_factory(clusters.CameraAvStreamManagement.attributes.HDRModeEnabled) + }, + [capabilities.nightVision.ID] = { + [capabilities.nightVision.commands.setNightVision.NAME] = capability_handlers.set_night_vision_factory(clusters.CameraAvStreamManagement.attributes.NightVision), + [capabilities.nightVision.commands.setIllumination.NAME] = capability_handlers.set_night_vision_factory(clusters.CameraAvStreamManagement.attributes.NightVisionIllum) + }, + [capabilities.imageControl.ID] = { + [capabilities.imageControl.commands.setImageFlipHorizontal.NAME] = capability_handlers.set_enabled_factory(clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal), + [capabilities.imageControl.commands.setImageFlipVertical.NAME] = capability_handlers.set_enabled_factory(clusters.CameraAvStreamManagement.attributes.ImageFlipVertical), + [capabilities.imageControl.commands.setImageRotation.NAME] = capability_handlers.handle_set_image_rotation + }, + [capabilities.cameraPrivacyMode.ID] = { + [capabilities.cameraPrivacyMode.commands.setSoftLivestreamPrivacyMode.NAME] = capability_handlers.set_enabled_factory(clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled), + [capabilities.cameraPrivacyMode.commands.setSoftRecordingPrivacyMode.NAME] = capability_handlers.set_enabled_factory(clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled) + }, + [capabilities.audioMute.ID] = { + [capabilities.audioMute.commands.setMute.NAME] = capability_handlers.handle_mute_commands_factory(capabilities.audioMute.commands.setMute.NAME), + [capabilities.audioMute.commands.mute.NAME] = capability_handlers.handle_mute_commands_factory(capabilities.audioMute.commands.mute.NAME), + [capabilities.audioMute.commands.unmute.NAME] = capability_handlers.handle_mute_commands_factory(capabilities.audioMute.commands.unmute.NAME) + }, + [capabilities.audioVolume.ID] = { + [capabilities.audioVolume.commands.setVolume.NAME] = capability_handlers.handle_set_volume, + [capabilities.audioVolume.commands.volumeUp.NAME] = capability_handlers.handle_volume_up, + [capabilities.audioVolume.commands.volumeDown.NAME] = capability_handlers.handle_volume_down + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = capability_handlers.handle_set_status_light_mode + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = capability_handlers.handle_status_led_on, + [capabilities.switch.commands.off.NAME] = capability_handlers.handle_status_led_off + }, + [capabilities.audioRecording.ID] = { + [capabilities.audioRecording.commands.setAudioRecording.NAME] = capability_handlers.handle_audio_recording + }, + [capabilities.mechanicalPanTiltZoom.ID] = { + [capabilities.mechanicalPanTiltZoom.commands.panRelative.NAME] = capability_handlers.ptz_relative_move_factory(camera_fields.PAN_IDX), + [capabilities.mechanicalPanTiltZoom.commands.tiltRelative.NAME] = capability_handlers.ptz_relative_move_factory(camera_fields.TILT_IDX), + [capabilities.mechanicalPanTiltZoom.commands.zoomRelative.NAME] = capability_handlers.ptz_relative_move_factory(camera_fields.ZOOM_IDX), + [capabilities.mechanicalPanTiltZoom.commands.setPan.NAME] = capability_handlers.ptz_set_position_factory(capabilities.mechanicalPanTiltZoom.commands.setPan), + [capabilities.mechanicalPanTiltZoom.commands.setTilt.NAME] = capability_handlers.ptz_set_position_factory(capabilities.mechanicalPanTiltZoom.commands.setTilt), + [capabilities.mechanicalPanTiltZoom.commands.setZoom.NAME] = capability_handlers.ptz_set_position_factory(capabilities.mechanicalPanTiltZoom.commands.setZoom), + [capabilities.mechanicalPanTiltZoom.commands.setPanTiltZoom.NAME] = capability_handlers.ptz_set_position_factory(capabilities.mechanicalPanTiltZoom.commands.setPanTiltZoom), + [capabilities.mechanicalPanTiltZoom.commands.savePreset.NAME] = capability_handlers.handle_save_preset, + [capabilities.mechanicalPanTiltZoom.commands.removePreset.NAME] = capability_handlers.handle_remove_preset, + [capabilities.mechanicalPanTiltZoom.commands.moveToPreset.NAME] = capability_handlers.handle_move_to_preset + }, + [capabilities.zoneManagement.ID] = { + [capabilities.zoneManagement.commands.newZone.NAME] = capability_handlers.handle_new_zone, + [capabilities.zoneManagement.commands.updateZone.NAME] = capability_handlers.handle_update_zone, + [capabilities.zoneManagement.commands.removeZone.NAME] = capability_handlers.handle_remove_zone, + [capabilities.zoneManagement.commands.createOrUpdateTrigger.NAME] = capability_handlers.handle_create_or_update_trigger, + [capabilities.zoneManagement.commands.removeTrigger.NAME] = capability_handlers.handle_remove_trigger, + [capabilities.zoneManagement.commands.setSensitivity.NAME] = capability_handlers.handle_set_sensitivity + }, + [capabilities.sounds.ID] = { + [capabilities.sounds.commands.playSound.NAME] = capability_handlers.handle_play_sound, + [capabilities.sounds.commands.setSelectedSound.NAME] = capability_handlers.handle_set_selected_sound + }, + [capabilities.videoStreamSettings.ID] = { + [capabilities.videoStreamSettings.commands.setStream.NAME] = capability_handlers.handle_set_stream + }, + [capabilities.cameraViewportSettings.ID] = { + [capabilities.cameraViewportSettings.commands.setDefaultViewport.NAME] = capability_handlers.handle_set_default_viewport + }, + [capabilities.localMediaStorage.ID] = { + [capabilities.localMediaStorage.commands.setLocalSnapshotRecording.NAME] = capability_handlers.set_enabled_factory(clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled), + [capabilities.localMediaStorage.commands.setLocalVideoRecording.NAME] = capability_handlers.set_enabled_factory(clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled) + } + }, + can_handle = require("sub_drivers.camera.can_handle") +} + +return camera_handler diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua new file mode 100644 index 0000000000..02bc9e06d2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local device_lib = require "st.device" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" + +return function(opts, driver, device) + local EVE_PRIVATE_CLUSTER_ID = 0x130AFC01 + -- this sub driver loads for devices that: + -- 1. Contain the Eve Private Cluster (0x130AFC01) + -- 2. Do NOT have the Standard Electrical Sensor device type + if device.network_type == device_lib.NETWORK_TYPE_MATTER and + #device:get_endpoints(EVE_PRIVATE_CLUSTER_ID) > 0 and + #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR) == 0 then + return true, require("sub_drivers.eve_energy") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/eve-energy/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua similarity index 76% rename from drivers/SmartThings/matter-switch/src/eve-energy/init.lua rename to drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua index 9ff4b672a0..59f0cbb9c2 100644 --- a/drivers/SmartThings/matter-switch/src/eve-energy/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 ------------------------------------------------------------------------------------- -- Definitions @@ -19,7 +8,7 @@ local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local cluster_base = require "st.matter.cluster_base" -local utils = require "st.utils" +local st_utils = require "st.utils" local data_types = require "st.matter.data_types" local device_lib = require "st.device" @@ -27,7 +16,6 @@ local SWITCH_INITIALIZED = "__switch_intialized" local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" local ON_OFF_STATES = "ON_OFF_STATES" -local EVE_MANUFACTURER_ID = 0x130A local PRIVATE_CLUSTER_ID = 0x130AFC01 local PRIVATE_ATTR_ID_WATT = 0x130A000A @@ -38,30 +26,18 @@ local PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT = 0x130A000E local RECURRING_POLL_TIMER = "RECURRING_POLL_TIMER" local TIMER_REPEAT = (1 * 60) -- Run the timer each minute --- Timer to report the power consumption every 15 minutes to satisfy the ST energy requirement -local RECURRING_REPORT_POLL_TIMER = "RECURRING_REPORT_POLL_TIMER" local LAST_REPORT_TIME = "LAST_REPORT_TIME" local LATEST_TOTAL_CONSUMPTION_WH = "LATEST_TOTAL_CONSUMPTION_WH" -local REPORT_TIMEOUT = (15 * 60) -- Report the value each 15 minutes +local MINIMUM_ST_ENERGY_REPORT_INTERVAL = (15 * 60) -- 15 minutes, reported in seconds ------------------------------------------------------------------------------------- -- Eve specifics ------------------------------------------------------------------------------------- -local function is_eve_energy_products(opts, driver, device) - -- this sub driver does not support child devices - if device.network_type == device_lib.NETWORK_TYPE_MATTER and - device.manufacturer_info.vendor_id == EVE_MANUFACTURER_ID then - return true - end - - return false -end - -- Return a ISO 8061 formatted timestamp in UTC (Z) -- @return e.g. 2022-02-02T08:00:00Z -local function iso8061Timestamp(time) +local function epoch_to_iso8601(time) return os.date("!%Y-%m-%dT%TZ", time) end @@ -116,43 +92,33 @@ local function delete_poll_schedule(device) end end +local function report_power_consumption_to_st_energy(device, latest_total_imported_energy_wh) + local current_time = os.time() + local last_time = device:get_field(LAST_REPORT_TIME) or 0 -local function create_poll_report_schedule(device) - -- the poll schedule is only needed for devices that support powerConsumption - if not device:supports_capability(capabilities.powerConsumptionReport) then + -- Ensure that the previous report was sent at least 15 minutes ago + if MINIMUM_ST_ENERGY_REPORT_INTERVAL >= (current_time - last_time) then return end - -- The powerConsumption report needs to be updated at least every 15 minutes in order to be included in SmartThings Energy - local timer = device.thread:call_on_schedule(REPORT_TIMEOUT, function() - local current_time = os.time() - local last_time = device:get_field(LAST_REPORT_TIME) or 0 - local latestTotalConsumptionWH = device:get_field(LATEST_TOTAL_CONSUMPTION_WH) or 0 - - device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) - - -- Calculate the energy consumed between the start and the end time - local previousTotalConsumptionWh = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, - capabilities.powerConsumptionReport.powerConsumption.NAME) - - local deltaEnergyWh = 0.0 - if previousTotalConsumptionWh ~= nil and previousTotalConsumptionWh.energy ~= nil then - deltaEnergyWh = math.max(latestTotalConsumptionWH - previousTotalConsumptionWh.energy, 0.0) - end - - local startTime = iso8061Timestamp(last_time) - local endTime = iso8061Timestamp(current_time - 1) + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) - -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' - device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ - start = startTime, - ["end"] = endTime, - deltaEnergy = deltaEnergyWh, - energy = latestTotalConsumptionWH - })) - end, "polling_report_schedule_timer") + -- Calculate the energy delta between reports + local energy_delta_wh = 0.0 + local previous_imported_report = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME) + if previous_imported_report and previous_imported_report.energy then + energy_delta_wh = math.max(latest_total_imported_energy_wh - previous_imported_report.energy, 0.0) + end - device:set_field(RECURRING_REPORT_POLL_TIMER, timer) + -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' + local component = device.profile.components["main"] + device:emit_component_event(component, capabilities.powerConsumptionReport.powerConsumption({ + start = epoch_to_iso8601(last_time), + ["end"] = epoch_to_iso8601(current_time - 1), + deltaEnergy = energy_delta_wh, + energy = latest_total_imported_energy_wh + })) end @@ -263,7 +229,6 @@ local function device_init(driver, device) device:subscribe() create_poll_schedule(device) - create_poll_report_schedule(device) end end @@ -277,36 +242,31 @@ local function device_removed(driver, device) delete_poll_schedule(device) end +-- override do_configure to prevent it running in the main driver +local function do_configure(driver, device) end + +-- override driver_switched to prevent it running in the main driver +local function driver_switched(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + local function handle_refresh(self, device) requestData(device) end local function handle_resetEnergyMeter(self, device) - -- 978307200 is the number of seconds from 1 January 1970 to 1 January 2001 local current_time = os.time() + + -- 978307200 is the number of seconds from 1 January 1970 to 1 January 2001 local current_time_2001 = current_time - 978307200 if current_time_2001 < 0 then current_time_2001 = 0 end - local last_time = device:get_field(LAST_REPORT_TIME) or 0 - local startTime = iso8061Timestamp(last_time) - local endTime = iso8061Timestamp(current_time - 1) - -- Reset the consumption on the device local data = data_types.validate_or_build_type(current_time_2001, data_types.Uint32) device:send(cluster_base.write(device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT, nil, data)) - - -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' - device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ - start = startTime, - ["end"] = endTime, - deltaEnergy = 0, - energy = 0 - })) - - device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) end ------------------------------------------------------------------------------------- @@ -357,8 +317,9 @@ end local function watt_accumulated_attr_handler(driver, device, ib, zb_rx) if ib.data.value then local totalConsumptionRawValue = ib.data.value - local totalConsumptionWh = utils.round(1000 * totalConsumptionRawValue) + local totalConsumptionWh = st_utils.round(1000 * totalConsumptionRawValue) updateEnergyMeter(device, totalConsumptionWh) + report_power_consumption_to_st_energy(device, totalConsumptionWh) end end @@ -368,6 +329,8 @@ local eve_energy_handler = { init = device_init, added = device_added, removed = device_removed, + doConfigure = do_configure, + driverSwitched = driver_switched }, matter_handlers = { attr = { @@ -394,7 +357,7 @@ local eve_energy_handler = { capabilities.energyMeter, capabilities.powerConsumptionReport }, - can_handle = is_eve_energy_products + can_handle = require("sub_drivers.eve_energy.can_handle") } return eve_energy_handler diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua new file mode 100644 index 0000000000..3ac75a5805 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local device_lib = require "st.device" + local get_product_override_field = require "switch_utils.utils".get_product_override_field + + local checked_device = device.network_type == device_lib.NETWORK_TYPE_MATTER and device or device:get_parent_device() + if get_product_override_field(checked_device, "needs_hager_subdriver") then + return true, require("sub_drivers.hager") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua new file mode 100644 index 0000000000..db8cfe67d4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -0,0 +1,483 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local device_lib = require "st.device" +local buttonCfg = require "switch_utils.device_configuration".ButtonCfg +local create_child = require "switch_utils.device_configuration".ChildCfg.create_or_update_child_devices +local switch_utils = require "switch_utils.utils" +local fields = require "switch_utils.fields" +local version = require "version" + +local ACTIVE_EPS = "__active_EPS" +local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" +local PARENT_ID = "PARENT_ID" +local CURRENT_LIFT = "__current_lift" +local BUTTON_EPS = "__button_eps" +local MAIN_WC_EP = "__main_wc_ep" + +local PRODUCT_ID = { + SWITCH_1G = 0x0005, + SWITCH_2G = 0x0006, + PIR_1_1M = 0x0007, + PIR_2_2M = 0x000A, +} + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +local function subscribe (device, endpoint_id, cluster_id, attr_id, event_id) + device:send(cluster_base.subscribe(device, endpoint_id, cluster_id, attr_id, event_id)) +end + +local function get_parent (driver, device) + return driver:get_device_info(device:get_field(PARENT_ID) or nil) +end + +local function get_matter_device(device) + local matter_device_id = device:get_field(MATTER_DEVICE_ID) + if matter_device_id then + local driver = device.driver + if driver then + local matter_device = driver:get_device_info(matter_device_id) + if matter_device then + return matter_device + end + end + end + return nil +end + +local function extract_reported_endpoints(ib) + local eps = {} + if ib.data and ib.data.elements then + for _, el in ipairs(ib.data.elements) do + local ep_id = el.value + if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 then + table.insert(eps, ep_id) + end + end + end + return eps +end + +local function detect_endpoint_changes (device, ib_elements) + local stored_eps = device:get_field(ACTIVE_EPS) or {} + ib_elements = ib_elements or {} + + local old_set, new_set = {}, {} + for _, ep_id in ipairs(stored_eps) do + old_set[ep_id] = true + end + for _, ep_id in ipairs(ib_elements) do + new_set[ep_id] = true + end + + local removed_eps, added_eps = {}, {} + + for ep_id in pairs(old_set) do + if not new_set[ep_id] then + table.insert(removed_eps, ep_id) + end + end + for ep_id in pairs(new_set) do + if not old_set[ep_id] then + table.insert(added_eps, ep_id) + end + end + return removed_eps, added_eps +end + +local function assign_profile_for_endpoint(device_type_id) + local profile = fields.device_type_profile_map[device_type_id] or "switch-binary" + return function(device, ep_id, is_child_device) + return profile, nil + end +end + +local function link_matter_device_and_parent(matter_device) + local parent = matter_device:get_parent_device() + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function handle_set_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local lift_value = device.preferences.presetPosition + local hundredths_lift_percent = (100 - tonumber(lift_value)) * 100 + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage(device, endpoint_id, hundredths_lift_percent)) +end + +local function handle_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + if device.preferences.reverse then + req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + end + device:send(req) +end + +local function handle_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + if device.preferences.reverse then + req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + end + device:send(req) +end + +local function handle_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id) + device:send(req) +end + +local function handle_shade_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local lift_percentage_value = 100 - cmd.args.shadeLevel + local hundredths_lift_percentage = lift_percentage_value * 100 + local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percentage + ) + device:send(req) +end + +local current_pos_handler = function(attribute) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local windowShade = capabilities.windowShade.windowShade + local position = 100 - math.floor(ib.data.value / 100) + local reverse = device.preferences.reverse + device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) + if attribute == capabilities.windowShadeLevel.shadeLevel then + device:set_field(CURRENT_LIFT, position) + end + local lift_position = device:get_field(CURRENT_LIFT) + if lift_position == 100 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) + elseif lift_position > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) + elseif lift_position == 0 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) + end + end +end + +local function current_status_handler(driver, device, ib, response) + local windowShade = capabilities.windowShade.windowShade + local reverse = device.preferences.reverse + local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL + if state == 1 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closing() or windowShade.opening()) + elseif state == 2 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) + elseif state ~= 0 then + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) + end +end + +local function info_changed(driver, device, event, args) + if not device or not device.id or not device.profile then + return + end + + if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then + local parent = device:get_parent_device() + local matter_device = get_matter_device(parent) + local map = {} + device.thread:call_with_delay(2, function() + if device:supports_capability(capabilities.button) then + local button_eps = parent:get_field(BUTTON_EPS) + local clean_eps = {} + table.sort(button_eps) + for _, v in ipairs(button_eps or {}) do + table.insert(clean_eps, v) + end + + buttonCfg.update_button_component_map(matter_device, clean_eps[1], clean_eps) + for _, ep_id in ipairs(clean_eps) do + subscribe(device, ep_id, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID) + subscribe(device, ep_id, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID) + device:emit_event_for_endpoint(ep_id, capabilities.button.supportedButtonValues({ "pushed", "double", "held" })) + end + return + elseif device:supports_capability(capabilities.switch) then + map = { main = 3 } + subscribe(device, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + if device:supports_capability(capabilities.switchLevel) then + map = { main = 4 } + subscribe(device, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + subscribe(device, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID) + subscribe(device, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID) + end + elseif device:supports_capability(capabilities.motionSensor) then + subscribe(device, nil, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil) + subscribe(device, nil, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) + elseif device:supports_capability(capabilities.windowShadeLevel) then + map = { main = 5 } + subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID) + subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + end + if device.id == matter_device.id then + parent:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) + matter_device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) + end + end) + end +end + +local function device_init (driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then + device.thread:call_with_delay(4, function() + info_changed(driver, device, nil, { + old_st_store = { profile = { } } + }) + end) + return + end + + if device:get_parent_device() ~= nil then + link_matter_device_and_parent(device) + local parent = device:get_parent_device() + device:extend_device("send", function(self, message) + return parent:send(message) + end) + else + subscribe(device, 2, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID) + device.thread:call_with_delay(3, function() + local matter_device = get_matter_device(device) + device:extend_device("emit_event_for_endpoint", function(self, ep_info, event) + local endpoint_id = type(ep_info) == "number" and ep_info or ep_info.endpoint_id + local child = self:get_child_by_parent_assigned_key(string.format("%d", endpoint_id)) + if child then + return child:emit_event(event) + end + if matter_device then + return matter_device:emit_event_for_endpoint(ep_info, event) + end + end) + end) + end + + device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) +end + +local function handle_descriptor_report(driver, device, ib, response) + local product_id = device.manufacturer_info and device.manufacturer_info.product_id + local parent = get_parent(driver, device) + + if not parent or ib.endpoint_id ~= 2 then + return + end + + local matter_device = get_matter_device(device) + + local new_eps = extract_reported_endpoints(ib) or {} + table.sort(new_eps) + local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) + + device:set_field(ACTIVE_EPS, new_eps, { persist = true }) + + for _, ep_id in ipairs(added_eps or {}) do + subscribe(parent, ep_id, clusters.Descriptor.ID, clusters.Descriptor.attributes.DeviceTypeList.ID, nil) + + if product_id == PRODUCT_ID.SWITCH_1G and ep_id == 3 then + matter_device:try_update_metadata({ profile = "light-binary" }) + elseif product_id == PRODUCT_ID.SWITCH_2G then + if ep_id == 3 then + local profile = switch_utils.tbl_contains(new_eps, 4) and "light-binary" or "2-button" + matter_device:try_update_metadata({ profile = profile }) + elseif ep_id == 4 then + local profile = switch_utils.tbl_contains(new_eps, 3) and "light-binary" or "2-button" + matter_device:try_update_metadata({ profile = profile }) + end + end + end + + for _, ep_id in ipairs(removed_eps) do + local button_eps = parent:get_field(BUTTON_EPS) or {} + local clean_eps = {} + + for _, value in ipairs(button_eps) do + if value ~= ep_id then + table.insert(clean_eps, value) + end + end + table.sort(clean_eps) + parent:set_field(BUTTON_EPS, clean_eps, { persist = true }) + matter_device:set_field(BUTTON_EPS, clean_eps, { persist = true }) + + local child = parent:get_child_by_parent_assigned_key(tostring(ep_id)) + if child then + driver:try_delete_device(child.id) + end + + if ep_id == 3 then + if device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then + matter_device:try_update_metadata({ profile = "2-button" }) + elseif device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + local has_ep4 = switch_utils.tbl_contains(new_eps, 4) + matter_device:try_update_metadata({ profile = has_ep4 and "2-button" or "4-button" }) + end + elseif ep_id == 4 then + if device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + local button_comb = switch_utils.tbl_contains(new_eps, 3) + if button_comb then + matter_device:try_update_metadata({ profile = "2-button" }) + create_child(driver, parent, { 3 }, 1, assign_profile_for_endpoint(fields.DEVICE_TYPE_ID.LIGHT.ON_OFF)) + else + matter_device:try_update_metadata({ profile = "4-button" }) + end + end + end + end +end + +local function device_type_handler (driver, device, ib) + if version.api < 16 then + clusters.Descriptor.server.attributes.DeviceTypeList:augment_type(ib.data) + end + + local parent = get_parent(driver, device) + if not parent then + return + end + + local matter_device = get_matter_device(device) + local stored_btn_eps = parent:get_field(BUTTON_EPS) or {} + local new_btn_eps = {} + local ep_id = ib.endpoint_id + local value = ib.data.elements + + for _, v in ipairs(stored_btn_eps) do + table.insert(new_btn_eps, v) + end + for _, element in ipairs(value) do + local device_type_field = element.elements.device_type + local device_type_id = device_type_field and device_type_field.value + + if device_type_id == fields.DEVICE_TYPE_ID.GENERIC_SWITCH then + if not switch_utils.tbl_contains(new_btn_eps, ep_id) then + switch_utils.set_field_for_endpoint(parent, fields.SUPPORTS_MULTI_PRESS, ep_id) + switch_utils.set_field_for_endpoint(parent, fields.IGNORE_NEXT_MPC, ep_id) + table.insert(new_btn_eps, ep_id) + table.sort(new_btn_eps) + parent:set_field(BUTTON_EPS, new_btn_eps, { persist = true }) + end + end + + if device_type_id == fields.DEVICE_TYPE_ID.LIGHT.ON_OFF then + local active_eps = device:get_field(ACTIVE_EPS) + + if ep_id == 3 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then + return + elseif ep_id == 4 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + if switch_utils.tbl_contains(active_eps, 3) then + local ep3 = parent:get_child_by_parent_assigned_key("3") + if ep3 then + driver:try_delete_device(ep3.id) + end + else + create_child(driver, parent, { 4 }, 1, assign_profile_for_endpoint(device_type_id)) + return + end + elseif ep_id == 3 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + if not switch_utils.tbl_contains(active_eps, 4) then + create_child(driver, parent, { 3 }, 1, assign_profile_for_endpoint(device_type_id)) + else + driver:try_delete_device(ep_id) + end + return + end + create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) + elseif device_type_id == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then + if ep_id == 4 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then + return + end + create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) + elseif device_type_id == fields.DEVICE_TYPE_ID.WINDOW_COVERING then + if matter_device:get_field(MAIN_WC_EP) == nil then + create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) + end + end + end +end + +local function do_configure (driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + + local wc_eps = device:get_endpoints(clusters.WindowCovering.ID) + local oc_eps = device:get_endpoints(clusters.OccupancySensing.ID) + local bt_eps = device:get_endpoints(clusters.Switch.ID) + local lvl_eps = device:get_endpoints(clusters.LevelControl.ID) + local product_id = device.manufacturer_info.product_id + + table.sort(wc_eps) + table.sort(oc_eps) + table.sort(lvl_eps) + + if #oc_eps > 0 then + device:try_update_metadata({ profile = "motion-illuminance" }) + elseif #wc_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G then + device:try_update_metadata({ profile = "window-covering" }) + device:set_field(MAIN_WC_EP, wc_eps[1], { persist = true }) + elseif #bt_eps == 4 then + device:try_update_metadata({ profile = "4-button" }) + elseif #bt_eps == 2 then + device:try_update_metadata({ profile = "2-button" }) + elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G then + device:try_update_metadata({ profile = "light-level" }) + end + device:set_field(BUTTON_EPS, bt_eps, { persist = true }) + end +end + +local function added (driver, device) end +local function driver_switched(driver, device) end + +local hager_switch = { + NAME = "Hager Subdriver", + lifecycle_handlers = { + added = added, + init = device_init, + infoChanged = info_changed, + doConfigure = do_configure, + driverSwitched = driver_switched + }, + matter_handlers = { + attr = { + [clusters.Descriptor.ID] = { + [clusters.Descriptor.attributes.PartsList.ID] = handle_descriptor_report, + [clusters.Descriptor.attributes.DeviceTypeList.ID] = device_type_handler, + }, + [clusters.WindowCovering.ID] = { + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler(capabilities.windowShadeLevel.shadeLevel), + [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, + }, + }, + }, + capability_handlers = { + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = handle_set_preset, + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.close.NAME] = handle_close, + [capabilities.windowShade.commands.open.NAME] = handle_open, + [capabilities.windowShade.commands.pause.NAME] = handle_pause, + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = handle_shade_level, + }, + }, + can_handle = require("sub_drivers.hager.can_handle") +} + +return hager_switch diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/can_handle.lua new file mode 100644 index 0000000000..1245fae5c9 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local switch_utils = require "switch_utils.utils" + +return function(opts, driver, device) + if switch_utils.get_product_override_field(device, "is_ikea_scroll") then + return true, require("sub_drivers.ikea_scroll") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua new file mode 100644 index 0000000000..6d6410dffa --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua @@ -0,0 +1,62 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local switch_utils = require "switch_utils.utils" +local scroll_utils = require "sub_drivers.ikea_scroll.scroll_utils.utils" +local scroll_cfg = require "sub_drivers.ikea_scroll.scroll_utils.device_configuration" +local event_handlers = require "sub_drivers.ikea_scroll.scroll_handlers.event_handlers" + +local IkeaScrollLifecycleHandlers = {} + +-- prevent main driver device_added handling from running +function IkeaScrollLifecycleHandlers.device_added(driver, device) +end + +function IkeaScrollLifecycleHandlers.device_init(driver, device) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + device:extend_device("subscribe", scroll_utils.subscribe) + device:subscribe() +end + +function IkeaScrollLifecycleHandlers.do_configure(driver, device) + scroll_cfg.match_profile(driver, device) +end + +function IkeaScrollLifecycleHandlers.driver_switched(driver, device) + scroll_cfg.match_profile(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +function IkeaScrollLifecycleHandlers.info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + scroll_cfg.configure_buttons(device) + device:subscribe() + end +end + + +-- DEVICE TEMPLATE -- + +local ikea_scroll_handler = { + NAME = "Ikea Scroll Handler", + lifecycle_handlers = { + added = IkeaScrollLifecycleHandlers.device_added, + doConfigure = IkeaScrollLifecycleHandlers.do_configure, + driverSwitched = IkeaScrollLifecycleHandlers.driver_switched, + infoChanged = IkeaScrollLifecycleHandlers.info_changed, + init = IkeaScrollLifecycleHandlers.device_init, + }, + matter_handlers = { + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = event_handlers.initial_press_handler, + [clusters.Switch.events.MultiPressOngoing.ID] = event_handlers.multi_press_ongoing_handler, + [clusters.Switch.events.MultiPressComplete.ID] = event_handlers.multi_press_complete_handler, + } + } + }, + can_handle = require("sub_drivers.ikea_scroll.can_handle") +} + +return ikea_scroll_handler 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 new file mode 100644 index 0000000000..1fac107a75 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua @@ -0,0 +1,83 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local switch_utils = require "switch_utils.utils" +local generic_event_handlers = require "switch_handlers.event_handlers" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" +local event_utils = require "sub_drivers.ikea_scroll.scroll_utils.event_utils" + +local IkeaScrollEventHandlers = {} + + +local function rotate_amount_event_helper(device, endpoint_id, num_presses_to_handle) + if num_presses_to_handle <= 0 then return end + + -- to cut down on checks, we can assume that if the endpoint is not in ENDPOINTS_UP_SCROLL, it is in ENDPOINTS_DOWN_SCROLL + 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( + event_utils.rotate_amount_scaling_helper(device, endpoint_id, scroll_direction * scroll_fields.PER_SCROLL_EVENT_ROTATION * num_presses_to_handle), + -100, 100 + ) + + 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 +function IkeaScrollEventHandlers.multi_press_ongoing_handler(driver, device, ib, response) + if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then + device.log.debug("Received MultiPressOngoing event from push endpoint, ignoring.") + else + local cur_num_presses_counted = ib.data.elements and ib.data.elements.current_number_of_presses_counted.value or 0 + local cur_multi_press_count = cur_num_presses_counted + if #response.info_blocks > 1 then + -- note: keep in mind that response blocks with mutliple info blocks are not supported by unit tests today. + if event_utils.is_last_valid_info_block(ib.event_id, cur_num_presses_counted, response.info_blocks) then + local aggregated_presses = event_utils.aggregate_scroll_amount_for_info_blocks(device, response.info_blocks) or {} + cur_num_presses_counted = aggregated_presses.total_presses or 0 + cur_multi_press_count = aggregated_presses.presses_in_current_chain or 0 + else + device.log.debug("Received MultiPressOngoing event that is not the last valid info block, ignoring.") + return + end + end + local num_presses_to_handle = cur_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED) or 0) + device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED, cur_multi_press_count) + rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) + end +end + +function IkeaScrollEventHandlers.multi_press_complete_handler(driver, device, ib, response) + if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then + generic_event_handlers.multi_press_complete_handler(driver, device, ib, response) + else + local total_num_presses_counted = ib.data.elements and ib.data.elements.total_number_of_presses_counted.value or 0 + if #response.info_blocks > 1 then + -- note: keep in mind that response blocks with mutliple info blocks are not supported by unit tests today. + if event_utils.is_last_valid_info_block(ib.event_id, total_num_presses_counted, response.info_blocks) then + local aggregated_presses = event_utils.aggregate_scroll_amount_for_info_blocks(device, response.info_blocks) or {} + total_num_presses_counted = aggregated_presses.total_presses or 0 + else + device.log.debug("Received MultiPressComplete event that is not the last valid info block, ignoring.") + return + end + end + local num_presses_to_handle = total_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED) or 0) + rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) + -- always reset the LATEST_NUMBER_OF_PRESSES_HANDLED to nil at the end of a handled MultiPress chain. + device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED, nil) + end +end + +function IkeaScrollEventHandlers.initial_press_handler(driver, device, ib, response) + if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then + generic_event_handlers.initial_press_handler(driver, device, ib, response) + else + device.log.debug("Received InitialPress event from scroll endpoint, ignoring.") + end +end + +return IkeaScrollEventHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua new file mode 100644 index 0000000000..0ad8a68826 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua @@ -0,0 +1,37 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local switch_utils = require "switch_utils.utils" +local switch_fields = require "switch_utils.fields" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" + +local IkeaScrollConfiguration = {} + +function IkeaScrollConfiguration.build_button_component_map(device) + local component_map = { + main = {scroll_fields.ENDPOINTS_PUSH[1], scroll_fields.ENDPOINTS_UP_SCROLL[1], scroll_fields.ENDPOINTS_DOWN_SCROLL[1]}, + group2 = {scroll_fields.ENDPOINTS_PUSH[2], scroll_fields.ENDPOINTS_UP_SCROLL[2], scroll_fields.ENDPOINTS_DOWN_SCROLL[2]}, + group3 = {scroll_fields.ENDPOINTS_PUSH[3], scroll_fields.ENDPOINTS_UP_SCROLL[3], scroll_fields.ENDPOINTS_DOWN_SCROLL[3]}, + } + device:set_field(switch_fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) +end + +function IkeaScrollConfiguration.configure_buttons(device) + for _, ep in ipairs(scroll_fields.ENDPOINTS_PUSH) do + device:send(clusters.Switch.attributes.MultiPressMax:read(device, ep)) + switch_utils.set_field_for_endpoint(device, switch_fields.SUPPORTS_MULTI_PRESS, ep, true, {persist = true}) + end + for _, ep in ipairs(scroll_fields.ENDPOINTS_UP_SCROLL) do -- and by extension, ENDPOINTS_DOWN_SCROLL + device:emit_event_for_endpoint(ep, capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}})) + end +end + +function IkeaScrollConfiguration.match_profile(driver, device) + device:try_update_metadata({profile = "ikea-scroll"}) + IkeaScrollConfiguration.build_button_component_map(device) + IkeaScrollConfiguration.configure_buttons(device) +end + +return IkeaScrollConfiguration 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 new file mode 100644 index 0000000000..d5568d2c9f --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua @@ -0,0 +1,90 @@ +-- 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) + local last_valid_emission_idx = #info_blocks + -- Ignore all InitialPress events in a multi-response block + while (last_valid_emission_idx > 1) and (info_blocks[last_valid_emission_idx].info_block.event_id == clusters.Switch.events.InitialPress.ID) do + last_valid_emission_idx = last_valid_emission_idx - 1 + end + + -- Because the info block does not include the unique_key Matter defined event number, this + -- logic is a best guess at matching the current info block to the last valid info block. + local emission_ib = info_blocks[last_valid_emission_idx].info_block + if emission_ib.event_id ~= cur_info_block_event_id then + return false + elseif emission_ib.event_id == clusters.Switch.events.MultiPressComplete.ID then + local last_valid_ib_value = emission_ib.data.elements and emission_ib.data.elements.total_number_of_presses_counted.value or 0 + return last_valid_ib_value == cur_info_block_value + elseif emission_ib.event_id == clusters.Switch.events.MultiPressOngoing.ID then + local last_valid_ib_value = emission_ib.data.elements and emission_ib.data.elements.current_number_of_presses_counted.value or 0 + return last_valid_ib_value == cur_info_block_value + elseif last_valid_emission_idx == 1 then -- aka, all ib's are InitialPress + return true + end + + return false +end + +function IkeaScrollEventUtils.aggregate_scroll_amount_for_info_blocks(device, info_blocks) + local total_presses = 0 + local presses_in_current_chain = 0 + for _, ib in ipairs(info_blocks) do + if ib.info_block.event_id == clusters.Switch.events.MultiPressOngoing.ID then + presses_in_current_chain = ib.info_block.data.elements and ib.info_block.data.elements.current_number_of_presses_counted.value or 0 + elseif ib.info_block.event_id == clusters.Switch.events.MultiPressComplete.ID then + total_presses = total_presses + (ib.info_block.data.elements and ib.info_block.data.elements.total_number_of_presses_counted.value or 0) + presses_in_current_chain = 0 + end + end + total_presses = total_presses + presses_in_current_chain -- aggregate any presses to the total from the current chain + return { total_presses = total_presses, presses_in_current_chain = presses_in_current_chain } +end + +function IkeaScrollEventUtils.rotate_amount_scaling_helper(device, endpoint_id, scroll_amount) + local scale_factor = 1.0 -- the default scale factor is 1.0, i.e none + local component = device:endpoint_to_component(endpoint_id) + if component == "main" then + scale_factor = scroll_fields.KNOB_SENSITIVITY_PREFERENCE_FACTORS[device.preferences.knobSensitivityGroup1] or 1.0 + elseif component == "group2" then + scale_factor = scroll_fields.KNOB_SENSITIVITY_PREFERENCE_FACTORS[device.preferences.knobSensitivityGroup2] or 1.0 + elseif component == "group3" then + scale_factor = scroll_fields.KNOB_SENSITIVITY_PREFERENCE_FACTORS[device.preferences.knobSensitivityGroup3] or 1.0 + end + local scaled_scroll_amount = st_utils.clamp_value(math.floor(scroll_amount * scale_factor), -100, 100) + return scaled_scroll_amount +end + +return IkeaScrollEventUtils 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 new file mode 100644 index 0000000000..2445e2fd30 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua @@ -0,0 +1,58 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" + +local IkeaScrollFields = {} + +-- PowerSource supported on Root Node +IkeaScrollFields.ENDPOINT_POWER_SOURCE = 0 + +-- Generic Switch Endpoints used for basic push functionality +IkeaScrollFields.ENDPOINTS_PUSH = {3, 6, 9} + +-- Generic Switch Endpoints used for Up Scroll functionality +IkeaScrollFields.ENDPOINTS_UP_SCROLL = {1, 4, 7} + +-- Generic Switch Endpoints used for Down Scroll functionality +IkeaScrollFields.ENDPOINTS_DOWN_SCROLL = {2, 5, 8} + +-- Amount to rotate per scroll event +-- 6 == st_utils.round(1/18 * 100), where 18 is the maximum number of presses that can be pressed at a time +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 + +-- Amounts by which to multiply the scroll amount based on user preference for sensitivity +IkeaScrollFields.KNOB_SENSITIVITY_PREFERENCE_FACTORS = { + ["0"] = 0.5, -- Low + ["1"] = 1.0, -- Medium + ["2"] = 2.0 -- High +} + +-- Required Events for the ENDPOINTS_PUSH. +IkeaScrollFields.switch_press_subscribed_events = { + clusters.Switch.events.InitialPress.ID, + clusters.Switch.events.MultiPressComplete.ID, + clusters.Switch.events.LongPress.ID, +} + +-- Required Events for the ENDPOINTS_UP_SCROLL and ENDPOINTS_DOWN_SCROLL. Adds a +-- MultiPressOngoing subscription to handle step functionality in real-time +IkeaScrollFields.switch_scroll_subscribed_events = { + clusters.Switch.events.InitialPress.ID, + clusters.Switch.events.MultiPressOngoing.ID, + clusters.Switch.events.MultiPressComplete.ID, +} + +return IkeaScrollFields diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua new file mode 100644 index 0000000000..5e249955e2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua @@ -0,0 +1,46 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local im = require "st.matter.interaction_model" +local clusters = require "st.matter.clusters" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" +local switch_fields = require "switch_utils.fields" + +local IkeaScrollUtils = {} + +-- override subscribe function in the main driver +function IkeaScrollUtils.subscribe(device) + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + for _, ep_push in ipairs(scroll_fields.ENDPOINTS_PUSH) do + for _, switch_event in ipairs(scroll_fields.switch_press_subscribed_events) do + local ib = im.InteractionInfoBlock(ep_push, clusters.Switch.ID, nil, switch_event) + subscribe_request:with_info_block(ib) + end + end + for _, ep_up in ipairs(scroll_fields.ENDPOINTS_UP_SCROLL) do + for _, switch_event in ipairs(scroll_fields.switch_scroll_subscribed_events) do + local ib = im.InteractionInfoBlock(ep_up, clusters.Switch.ID, nil, switch_event) + subscribe_request:with_info_block(ib) + end + end + for _, ep_down in ipairs(scroll_fields.ENDPOINTS_DOWN_SCROLL) do + for _, switch_event in ipairs(scroll_fields.switch_scroll_subscribed_events) do + local ib = im.InteractionInfoBlock(ep_down, clusters.Switch.ID, nil, switch_event) + subscribe_request:with_info_block(ib) + end + end + local cluster_id = clusters.PowerSource.ID + local attr_id = clusters.PowerSource.attributes.BatPercentRemaining.ID + local ib = im.InteractionInfoBlock( + scroll_fields.ENDPOINT_POWER_SOURCE, cluster_id, attr_id + ) + subscribe_request:with_info_block(ib) + device:send(subscribe_request) + + local subscribed_attrs = device:get_field(switch_fields.SUBSCRIBED_ATTRIBUTES_KEY) or {} + subscribed_attrs[cluster_id] = subscribed_attrs[cluster_id] or {} + subscribed_attrs[cluster_id][attr_id] = ib + device:set_field(switch_fields.SUBSCRIBED_ATTRIBUTES_KEY, subscribed_attrs) +end + +return IkeaScrollUtils \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/can_handle.lua new file mode 100644 index 0000000000..2e1b734406 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local utils = require "switch_utils.utils" + +return function(opts, driver, device) + if utils.get_product_override_field(device, "is_third_reality_garage_door") then + return true, require("sub_drivers.third_reality_garage_door") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/init.lua new file mode 100644 index 0000000000..4666d091bc --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/init.lua @@ -0,0 +1,92 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +------------------------------------------------------------------------------------- +-- Third Reality Garage Door Opener specifics +-- +-- This device uses the OnOff cluster to control the door: +-- OnOff = true -> door open +-- OnOff = false -> door closed +-- Commands are mapped from doorControl capability to OnOff cluster commands. +------------------------------------------------------------------------------------- + +local function device_init(driver, device) + -- Force a subscription to the OnOff cluster, since doorControl does not explicitly map to it in the default driver. + device:add_subscribed_attribute(clusters.OnOff.attributes.OnOff) + device:add_subscribed_attribute(clusters.PowerSource.attributes.BatPercentRemaining) + device:subscribe() +end + +local function match_profile(driver, device) + device:try_update_metadata({profile = "garage-door-battery"}) +end + +-- Prevent any of the main driver's logic from running +local function device_added(driver, device) end + +-- Prevent any of the main driver's logic from running +local function info_changed(driver, device, event, args) end + +local function do_configure(driver, device) + match_profile(driver, device) +end + +local function driver_switched(driver, device) + match_profile(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + + +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.doorControl.door.open()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.doorControl.door.closed()) + end +end + +local function handle_door_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:emit_event_for_endpoint(endpoint_id, capabilities.doorControl.door.opening()) + device:send(clusters.OnOff.server.commands.On(device, endpoint_id)) +end + +local function handle_door_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:emit_event_for_endpoint(endpoint_id, capabilities.doorControl.door.closing()) + device:send(clusters.OnOff.server.commands.Off(device, endpoint_id)) +end + +local third_reality_garage_door_handler = { + NAME = "ThirdReality Garage Door Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + driverSwitched = driver_switched, + infoChanged = info_changed, + }, + matter_handlers = { + attr = { + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, + }, + }, + }, + capability_handlers = { + [capabilities.doorControl.ID] = { + [capabilities.doorControl.commands.open.NAME] = handle_door_open, + [capabilities.doorControl.commands.close.NAME] = handle_door_close, + }, + }, + supported_capabilities = { + capabilities.doorControl, + capabilities.battery, + }, + can_handle = require("sub_drivers.third_reality_garage_door.can_handle") +} + +return third_reality_garage_door_handler diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua new file mode 100644 index 0000000000..f68e568639 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local utils = require "switch_utils.utils" + +return function(opts, driver, device) + if utils.get_product_override_field(device, "is_third_reality_mk1") then + return true, require("sub_drivers.third_reality_mk1") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua new file mode 100644 index 0000000000..8ba2bd28a1 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua @@ -0,0 +1,113 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" + +local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" + +------------------------------------------------------------------------------------- +-- Third Reality MK1 specifics +------------------------------------------------------------------------------------- + +local function endpoint_to_component(device, ep) + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} + for component, endpoint in pairs(map) do + if endpoint == ep then + return component + end + end + return "main" +end + +-- override subscribe function to prevent subscribing to additional events from the main driver +local function subscribe(device) + local ib = im.InteractionInfoBlock(nil, clusters.Switch.ID, nil, clusters.Switch.events.InitialPress.ID) + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + subscribe_request:with_info_block(ib) + device:send(subscribe_request) +end + +local function configure_buttons(device) + local ms_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + for _, ep in ipairs(ms_eps) do + if device.profile.components[endpoint_to_component(device, ep)] then + device.log.info(string.format("Configuring Supported Values for generic switch endpoint %d", ep)) + local supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ep, supportedButtonValues_event) + else + device.log.info(string.format("Component not found for generic switch endpoint %d. Skipping Supported Value configuration", ep)) + end + end +end + +local function build_button_component_map(device) + local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + table.sort(button_eps) + local component_map = {} + component_map["main"] = button_eps[1] + for component_num = 2, 12 do + component_map["F" .. component_num] = button_eps[component_num] + end + device:set_field(COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) +end + +local function device_init(driver, device) + device:set_endpoint_to_component_fn(endpoint_to_component) + device:extend_device("subscribe", subscribe) + device:subscribe() +end + +-- override device_added to prevent it running in the main driver +local function device_added(driver, device) end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + configure_buttons(device) + device:subscribe() + end +end + +local function match_profile(driver, device) + device:try_update_metadata({profile = "12-button-keyboard"}) + build_button_component_map(device) + configure_buttons(device) +end + +local function do_configure(driver, device) + match_profile(driver, device) +end + +local function driver_switched(driver, device) + match_profile(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +local function initial_press_event_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) +end + +local third_reality_mk1_handler = { + NAME = "ThirdReality MK1 Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed, + doConfigure = do_configure, + driverSwitched = driver_switched + }, + matter_handlers = { + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = initial_press_event_handler + } + } + }, + supported_capabilities = { + capabilities.button + }, + can_handle = require("sub_drivers.third_reality_mk1.can_handle") +} + +return third_reality_mk1_handler diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..753c76992e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -0,0 +1,636 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local version = require "version" +local im = require "st.matter.interaction_model" +local st_utils = require "st.utils" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" +local color_utils = require "switch_utils.color_utils" +local cfg = require "switch_utils.device_configuration" +local device_cfg = cfg.DeviceCfg + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.PowerTopology = require "embedded_clusters.PowerTopology" +end + +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + +local AttributeHandlers = {} + +-- [[ ON OFF CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) + end + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("switch", "switch") + end +end + + +-- [[ LEVEL CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.level_control_current_level_handler(driver, device, ib, response) + if ib.data.value ~= nil then + local level = ib.data.value + if level > 0 then + level = math.max(1, st_utils.round(level / 254.0 * 100)) + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.level(level)) + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("switchLevel", "level") + end + end +end + +function AttributeHandlers.level_bounds_handler_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local lighting_endpoints = device:get_endpoints(clusters.LevelControl.ID, {feature_bitmap = clusters.LevelControl.FeatureMap.LIGHTING}) + local lighting_support = switch_utils.tbl_contains(lighting_endpoints, ib.endpoint_id) + -- If the lighting feature is supported then we should check if the reported level is at least 1. + if lighting_support and ib.data.value < fields.SWITCH_LEVEL_LIGHTING_MIN then + device.log.warn_with({hub_logs = true}, string.format("Lighting device reported a switch level %d outside of supported capability range", ib.data.value)) + return + end + -- Convert level from given range of 0-254 to range of 0-100. + local level = st_utils.round(ib.data.value / 254.0 * 100) + -- If the device supports the lighting feature, the minimum capability level should be 1 so we do not send a 0 value for the level attribute + if lighting_support and level == 0 then + level = 1 + end + switch_utils.set_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..minOrMax, ib.endpoint_id, level) + local min = switch_utils.get_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.levelRange({ value = {minimum = min, maximum = max} })) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min level value %d that is not lower than the reported max level value %d", min, max)) + end + switch_utils.set_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MAX, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MIN, ib.endpoint_id, nil) + end + end +end + + +-- [[ COLOR CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.current_hue_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) ~= fields.X_Y_COLOR_MODE and ib.data.value ~= nil then + local hue = math.floor((ib.data.value / 0xFE * 100) + 0.5) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(hue)) + end + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("colorControl", "hue") + end +end + +function AttributeHandlers.current_saturation_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) ~= fields.X_Y_COLOR_MODE and ib.data.value ~= nil then + local sat = math.floor((ib.data.value / 0xFE * 100) + 0.5) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(sat)) + end + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("colorControl", "saturation") + end +end + +function AttributeHandlers.color_temperature_mireds_handler(driver, device, ib, response) + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("colorTemperature", "colorTemperature") + end + local temp_in_mired = ib.data.value + if temp_in_mired == nil then + return + end + if (temp_in_mired < fields.COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > fields.COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, fields.COLOR_TEMPERATURE_MIRED_MIN, fields.COLOR_TEMPERATURE_MIRED_MAX)) + return + end + local min_temp_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, ib.endpoint_id) + local max_temp_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, ib.endpoint_id) + + local temp = st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_mired) + if min_temp_mired ~= nil and temp_in_mired <= min_temp_mired then + temp = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, ib.endpoint_id) + elseif max_temp_mired ~= nil and temp_in_mired >= max_temp_mired then + temp = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, ib.endpoint_id) + end + + local temp_device = device + if device:get_field(fields.IS_PARENT_CHILD_DEVICE) == true then + temp_device = switch_utils.find_child(device, ib.endpoint_id) or device + end + local latest_requested_kelvin = temp_device:get_field(fields.LATEST_REQUESTED_KELVIN) + -- this is to avoid rounding errors from the round-trip conversion of Kelvin to mireds + if latest_requested_kelvin and + latest_requested_kelvin <= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired - 1)) and + latest_requested_kelvin >= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired + 1)) then + temp = latest_requested_kelvin + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperature(temp)) +end + +function AttributeHandlers.current_x_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then + return + end + local y = device:get_field(fields.RECEIVED_Y) + --TODO it is likely that both x and y attributes are in the response (not guaranteed though) + -- if they are we can avoid setting fields on the device. + if y == nil then + device:set_field(fields.RECEIVED_X, ib.data.value) + else + local x = ib.data.value + local h, s, _ = color_utils.safe_xy_to_hsv(x, y, nil) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) + device:set_field(fields.RECEIVED_Y, nil) + end +end + +function AttributeHandlers.current_y_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then + return + end + local x = device:get_field(fields.RECEIVED_X) + if x == nil then + device:set_field(fields.RECEIVED_Y, ib.data.value) + else + local y = ib.data.value + local h, s, _ = color_utils.safe_xy_to_hsv(x, y, nil) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) + device:set_field(fields.RECEIVED_X, nil) + end +end + +function AttributeHandlers.color_mode_handler(driver, device, ib, response) + if ib.data.value == device:get_field(fields.COLOR_MODE) + or (ib.data.value ~= clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION + and ib.data.value ~= clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) then + return + end + device:set_field(fields.COLOR_MODE, ib.data.value) + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + if ib.data.value == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then + req:merge(clusters.ColorControl.attributes.CurrentHue:read()) + req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) + elseif ib.data.value == clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY then + req:merge(clusters.ColorControl.attributes.CurrentX:read()) + req:merge(clusters.ColorControl.attributes.CurrentY:read()) + end + if #req.info_blocks > 0 then + device:send(req) + end +end + +--TODO setup configure handler to read this attribute. +function AttributeHandlers.color_capabilities_handler(driver, device, ib, response) + if ib.data.value ~= nil then + if ib.data.value & 0x1 then + device:set_field(fields.HUESAT_SUPPORT, true) + end + end +end + +function AttributeHandlers.color_temp_physical_mireds_bounds_factory(minOrMax) + return function(driver, device, ib, response) + local temp_in_mired = ib.data.value + if temp_in_mired == nil then + return + end + if (temp_in_mired < fields.COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > fields.COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, fields.COLOR_TEMPERATURE_MIRED_MIN, fields.COLOR_TEMPERATURE_MIRED_MAX)) + return + end + local temp_in_kelvin = switch_utils.mired_to_kelvin(temp_in_mired, minOrMax) + switch_utils.set_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..minOrMax, ib.endpoint_id, temp_in_kelvin) + -- the minimum color temp in kelvin corresponds to the maximum temp in mireds + if minOrMax == fields.COLOR_TEMP_MIN then + switch_utils.set_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, ib.endpoint_id, temp_in_mired) + else + switch_utils.set_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, ib.endpoint_id, temp_in_mired) + end + local min = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min, maximum = max} })) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min color temperature %d K that is not lower than the reported max color temperature %d K", min, max)) + end + end + end +end + + +-- [[ ILLUMINANCE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.illuminance_measured_value_handler(driver, device, ib, response) + local lux = math.floor(10 ^ ((ib.data.value - 1) / 10000)) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.illuminanceMeasurement.illuminance(lux)) +end + + +-- [[ OCCUPANCY CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.occupancy_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) +end + + +-- [[ ELECTRICAL POWER MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.active_power_handler(driver, device, ib, response) + if ib.data.value then + local watt_value = ib.data.value / 1000 -- convert received milliwatt to watt + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.powerMeter.power({ value = watt_value, unit = "W"})) + end + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("powerMeter","power") + end +end + + +-- [[ VALVE CONFIGURATION AND CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.valve_configuration_current_state_handler(driver, device, ib, response) + if ib.data.value == 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.valve.valve.closed()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.valve.valve.open()) + end +end + +function AttributeHandlers.valve_configuration_current_level_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.level.level(ib.data.value)) + end +end + + +-- [[ ELECTRICAL ENERGY MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.energy_imported_factory(is_periodic_report) + return function(driver, device, ib, response) + if version.api < 11 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct:augment_type(ib.data) + end + if ib.data.elements.energy then + local energy_imported_wh = ib.data.elements.energy.value / 1000 -- convert received milliwatt-hour to watt-hour + if is_periodic_report then + -- handle this report only if cumulative reports are not supported + if device:get_field(fields.CUMULATIVE_REPORTS_SUPPORTED) then return end + local energy_meter_latest_state = switch_utils.get_latest_state_for_endpoint( + device, ib, capabilities.energyMeter.ID, capabilities.energyMeter.energy.NAME + ) or 0 + energy_imported_wh = energy_imported_wh + energy_meter_latest_state + else + -- the field containing the offset may be associated with a child device + local field_device = switch_utils.find_child(device, ib.endpoint_id) or device + local energy_meter_offset = field_device:get_field(fields.ENERGY_METER_OFFSET) or 0.0 + energy_imported_wh = energy_imported_wh - energy_meter_offset + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.energyMeter.energy({ value = energy_imported_wh, unit = "Wh" })) + switch_utils.report_power_consumption_to_st_energy(device, ib.endpoint_id, energy_imported_wh) + else + device.log.warn("Received data from the energy imported attribute does not include a numerical energy value") + end + end +end + + +-- [[ POWER TOPOLOGY CLUSTER ATTRIBUTES ]] -- + +--- AvailableEndpoints: This attribute SHALL indicate the list of endpoints capable of +--- providing power to and/or consuming power from the endpoint hosting this server. +--- +--- In the case there are multiple endpoints supporting the PowerTopology cluster with +--- SET feature, all AvailableEndpoints responses must be handled before profiling. +function AttributeHandlers.available_endpoints_handler(driver, device, ib, response) + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) ~= nil then + device.log.warn("Received an AvailableEndpoints response after power topology has already been determined. Ignoring this response.") + return + end + local set_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) + if set_topology_eps == nil then + device.log.warn("Received an AvailableEndpoints response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with SET feature. Ignoring this response.") + return + end + for i, set_ep_info in pairs(set_topology_eps or {}) do + if ib.endpoint_id == set_ep_info.endpoint_id then + -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table + switch_utils.remove_field_index(device, fields.ELECTRICAL_SENSOR_EPS, i) + local available_endpoints_ids = {} + for _, element in pairs(ib.data.elements or {}) do + table.insert(available_endpoints_ids, element.value) + end + -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. + -- set an assigned child key in the case this will emit events on an EDGE_CHILD device + switch_utils.set_fields_for_electrical_sensor_endpoint(device, set_ep_info, available_endpoints_ids) + break + end + end + if #set_topology_eps == 0 then -- in other words, all AvailableEndpoints attribute responses have been handled + device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY, {persist=true}) + device_cfg.match_profile(driver, device) + end +end + + +-- [[ DESCRIPTOR CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.parts_list_handler(driver, device, ib, response) + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) ~= nil then + device.log.warn("Received a PartsList response after power topology has already been determined. Ignoring this response.") + return + end + local tree_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) + if tree_topology_eps == nil then + device.log.warn("Received a PartsList response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with TREE feature. Ignoring this response.") + return + end + for i, tree_ep_info in pairs(tree_topology_eps or {}) do + if ib.endpoint_id == tree_ep_info.endpoint_id then + -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table + switch_utils.remove_field_index(device, fields.ELECTRICAL_SENSOR_EPS, i) + local associated_endpoints_ids = {} + for _, element in pairs(ib.data.elements or {}) do + table.insert(associated_endpoints_ids, element.value) + end + -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. + -- set an assigned child key in the case this will emit events on an EDGE_CHILD device + switch_utils.set_fields_for_electrical_sensor_endpoint(device, tree_ep_info, associated_endpoints_ids) + break + end + end + if #tree_topology_eps == 0 then -- in other words, all PartsList attribute responses for TREE Electrical Sensor EPs have been handled + device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, {persist=true}) + device_cfg.match_profile(driver, device) + end +end + + +-- [[ POWER SOURCE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.bat_percent_remaining_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response) + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) + local previous_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) + for _, attr in ipairs(ib.data.elements or {}) do + if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist=true}) + break + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID and + device:get_field(fields.profiling_data.BATTERY_SUPPORT) ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist=true}) + end + end + if not previous_battery_support or previous_battery_support ~= device:get_field(fields.profiling_data.BATTERY_SUPPORT) then + device_cfg.match_profile(driver, device) + end +end + + +-- [[ SWITCH CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.multi_press_max_handler(driver, device, ib, response) + local max = ib.data.value or 1 --get max number of presses + device.log.debug("Device supports "..max.." presses") + -- capability only supports up to 6 presses + if max > 6 then + device.log.info("Device supports more than 6 presses") + max = 6 + end + local MSL = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) + local supportsHeld = switch_utils.tbl_contains(MSL, ib.endpoint_id) + local values = switch_utils.create_multi_press_values_list(max, supportsHeld) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.supportedButtonValues(values, {visibility = {displayed = false}})) +end + + +-- [[ TEMPERATURE MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.temperature_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local temp = measured_value / 100.0 + local unit = "C" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperature({value = temp, unit = unit})) + end +end + +function AttributeHandlers.temperature_measured_value_bounds_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local temp = ib.data.value / 100.0 + local unit = "C" + switch_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..minOrMax, ib.endpoint_id, temp) + local min = switch_utils.get_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + -- Only emit the capability for RPC version >= 5 (unit conversion for + -- temperature range capability is only supported for RPC >= 5) + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit })) + end + switch_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MIN, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + + +-- [[ RELATIVE HUMIDITY MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.relative_humidity_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local humidity = st_utils.round(measured_value / 100.0) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) + end +end + + +-- [[ FAN CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.fan_mode_handler(driver, device, ib, response) + if ib.data.value == clusters.FanControl.attributes.FanMode.OFF then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("off")) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.LOW then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("low")) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.MEDIUM then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("medium")) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.HIGH then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("high")) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("auto")) + end +end + +function AttributeHandlers.fan_mode_sequence_handler(driver, device, ib, response) + local supportedFanModes + if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + else + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME + } + end + local event = capabilities.fanMode.supportedFanModes(supportedFanModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.percent_current_handler(driver, device, ib, response) + if ib.data.value == nil or ib.data.value < 0 or ib.data.value > 100 then + return + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(ib.data.value)) +end + + +-- [[ OPERATIONAL STATE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.operational_state_accepted_command_list_attr_handler(driver, device, ib, response) + if ib.data.elements == nil then return end + local accepted_command_list = {} + for _, accepted_command in ipairs(ib.data.elements) do + local accepted_command_id = accepted_command.value + if fields.operational_state_command_map[accepted_command_id] ~= nil then + table.insert(accepted_command_list, fields.operational_state_command_map[accepted_command_id]) + end + end + local event = capabilities.operationalState.supportedCommands(accepted_command_list, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.operational_state_attr_handler(driver, device, ib, response) + if ib.data.value == clusters.OperationalState.types.OperationalStateEnum.STOPPED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.stopped()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.RUNNING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.running()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.PAUSED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.paused()) + end +end + +function AttributeHandlers.operational_error_attr_handler(driver, device, ib, response) + if ib.data.elements == nil or ib.data.elements.error_state_id == nil or ib.data.elements.error_state_id.value == nil then return end + if version.api < 10 then + clusters.OperationalState.types.ErrorStateStruct:augment_type(ib.data) + end + local operationalError = ib.data.elements.error_state_id.value + if operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToStartOrResume()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToCompleteOperation()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + end +end + + +-- [[ FLOW MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.flow_attr_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local flow = measured_value / 10.0 + local unit = "m^3/h" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flow({value = flow, unit = unit})) + end +end + +function AttributeHandlers.flow_attr_handler_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local flow_bound = ib.data.value / 10.0 + local unit = "m^3/h" + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..minOrMax, ib.endpoint_id, flow_bound) + local min = switch_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flowRange({ value = { minimum = min, maximum = max }, unit = unit })) + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min flow measurement %d that is not lower than the reported max flow measurement %d", min, max)) + end + end + end +end + +return AttributeHandlers diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua new file mode 100644 index 0000000000..573d2b791b --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua @@ -0,0 +1,255 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local st_utils = require "st.utils" +local version = require "version" +local switch_utils = require "switch_utils.utils" +local fields = require "switch_utils.fields" + +local CapabilityHandlers = {} + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +-- [[ SWITCH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_switch_on(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + --TODO use OnWithRecallGlobalScene for devices with the LT feature + device:send(clusters.OnOff.server.commands.On(device, endpoint_id)) +end + +function CapabilityHandlers.handle_switch_off(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OnOff.server.commands.Off(device, endpoint_id)) +end + + +-- [[ SWITCH LEVEL CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_switch_set_level(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + local level = st_utils.round(cmd.args.level/100.0 * 254) + device:send(clusters.LevelControl.server.commands.MoveToLevelWithOnOff(device, endpoint_id, level, cmd.args.rate, 0, 0)) +end + + +-- [[ STATELESS SWITCH LEVEL STEP CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_step_level(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_size = st_utils.round((cmd.args and cmd.args.stepSize or 0)/100.0 * 254) + if step_size == 0 then return end + local endpoint_id = device:component_to_endpoint(cmd.component) + local step_mode = step_size > 0 and clusters.LevelControl.types.StepMode.UP or clusters.LevelControl.types.StepMode.DOWN + local transition_time = device:get_field(fields.TRANSITION_TIME.SWITCH_LEVEL_STEP) or fields.DEFAULT_STEP_TRANSITION_TIME + device:send(clusters.LevelControl.server.commands.Step(device, endpoint_id, step_mode, math.abs(step_size), transition_time, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF)) +end + + +-- [[ COLOR CONTROL CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_color(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req + local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) + if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then + local hue = switch_utils.convert_huesat_st_to_matter(cmd.args.color.hue) + local sat = switch_utils.convert_huesat_st_to_matter(cmd.args.color.saturation) + req = clusters.ColorControl.server.commands.MoveToHueAndSaturation(device, endpoint_id, hue, sat, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + else + local x, y, _ = st_utils.safe_hsv_to_xy(cmd.args.color.hue, cmd.args.color.saturation) + req = clusters.ColorControl.server.commands.MoveToColor(device, endpoint_id, x, y, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + end + device:send(req) +end + +function CapabilityHandlers.handle_set_hue(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) + if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then + local hue = switch_utils.convert_huesat_st_to_matter(cmd.args.hue) + local req = clusters.ColorControl.server.commands.MoveToHue(device, endpoint_id, hue, 0, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + device:send(req) + else + device.log.warn("Device does not support huesat features on its color control cluster") + end +end + +function CapabilityHandlers.handle_set_saturation(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) + if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then + local sat = switch_utils.convert_huesat_st_to_matter(cmd.args.saturation) + local req = clusters.ColorControl.server.commands.MoveToSaturation(device, endpoint_id, sat, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + device:send(req) + else + device.log.warn("Device does not support huesat features on its color control cluster") + end +end + + +-- [[ COLOR TEMPERATURE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_color_temperature(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + local temp_in_kelvin = cmd.args.temperature + -- note: the field containing the color temp bounds will be associated with a parent device + local field_device = device:get_parent_device() or device + local min_temp_kelvin = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, endpoint_id) + local max_temp_kelvin = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, endpoint_id) + + local temp_in_mired = st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_kelvin) + if min_temp_kelvin ~= nil and temp_in_kelvin <= min_temp_kelvin then + temp_in_mired = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, endpoint_id) + elseif max_temp_kelvin ~= nil and temp_in_kelvin >= max_temp_kelvin then + temp_in_mired = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, endpoint_id) + end + local req = clusters.ColorControl.server.commands.MoveToColorTemperature(device, endpoint_id, temp_in_mired, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + device:set_field(fields.LATEST_REQUESTED_KELVIN, cmd.args.temperature) + device:send(req) +end + + +-- [[ STATELESS COLOR TEMPERATURE STEP CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_step_color_temperature_by_percent(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_percent_change = cmd.args and cmd.args.stepSize or 0 + if step_percent_change == 0 then return end + step_percent_change = st_utils.clamp_value(step_percent_change, -100, 100) + local endpoint_id = device:component_to_endpoint(cmd.component) + -- before the Matter 1.3 lua libs update (HUB FW 55), there was no ColorControl StepModeEnum type defined + local step_mode = step_percent_change > 0 and (clusters.ColorControl.types.StepModeEnum and clusters.ColorControl.types.StepModeEnum.DOWN or 3) or (clusters.ColorControl.types.StepModeEnum and clusters.ColorControl.types.StepModeEnum.UP or 1) + -- note: the field containing the color temp bounds will be associated with a parent device + local field_device = device:get_parent_device() or device + local min_mireds = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, endpoint_id) + local max_mireds = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, endpoint_id) + -- since colorTemperatureRange is only set after both custom bounds are, use defaults if any custom bound is missing + if not (min_mireds and max_mireds) then + min_mireds = fields.DEFAULT_MIRED_MIN + max_mireds = fields.DEFAULT_MIRED_MAX + end + local step_size_in_mireds = st_utils.round((max_mireds - min_mireds) * (math.abs(step_percent_change)/100.0)) + local transition_time = device:get_field(fields.TRANSITION_TIME.COLOR_TEMP_STEP) or fields.DEFAULT_STEP_TRANSITION_TIME + device:send(clusters.ColorControl.server.commands.StepColorTemperature(device, endpoint_id, step_mode, step_size_in_mireds, transition_time, min_mireds, max_mireds, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF)) +end + +-- [[ VALVE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_valve_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ValveConfigurationAndControl.server.commands.Open(device, endpoint_id)) +end + +function CapabilityHandlers.handle_valve_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ValveConfigurationAndControl.server.commands.Close(device, endpoint_id)) +end + + +-- [[ LEVEL CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_level(driver, device, cmd) + local commands = clusters.ValveConfigurationAndControl.server.commands + local endpoint_id = device:component_to_endpoint(cmd.component) + local level = cmd.args.level + if not level then + return + elseif level == 0 then + device:send(commands.Close(device, endpoint_id)) + else + device:send(commands.Open(device, endpoint_id, nil, level)) + end +end + + +-- [[ FAN MODE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_fan_mode(driver, device, cmd) + local fan_mode_id + if cmd.args.fanMode == capabilities.fanMode.fanMode.low.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.LOW + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.medium.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.MEDIUM + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.high.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.HIGH + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.auto.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO + else + fan_mode_id = clusters.FanControl.attributes.FanMode.OFF + end + if fan_mode_id then + local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1] + device:send(clusters.FanControl.attributes.FanMode:write(device, fan_ep, fan_mode_id)) + end +end + + +-- [[ FAN SPEED PERCENT CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_fan_speed_set_percent(driver, device, cmd) + local speed = math.floor(cmd.args.percent) + local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1] + device:send(clusters.FanControl.attributes.PercentSetting:write(device, fan_ep, speed)) +end + + +-- [[ ENERGY METER CAPABILITY COMMANDS ]] -- + +--- +--- If a Cumulative Reporting device, this will store the most recent energy meter reading, and all subsequent reports will have this value subtracted +--- from the value reported. Matter, like Zigbee and unlike Z-Wave, does not provide a way to reset the value to zero, so this is an attempt at a workaround. +--- In the case it is a Periodic Reporting device, the reports do not need to be offset, so setting the current energy to 0.0 will do the same thing. +--- +function CapabilityHandlers.handle_reset_energy_meter(driver, device, cmd) + local energy_meter_latest_state = device:get_latest_state(cmd.component, capabilities.energyMeter.ID, capabilities.energyMeter.energy.NAME) or 0.0 + if energy_meter_latest_state ~= 0.0 then + device:emit_component_event(device.profile.components[cmd.component], capabilities.energyMeter.energy({value = 0.0, unit = "Wh"})) + -- note: field containing cumulative reports supported is only set on the parent device + local field_device = device:get_parent_device() or device + if field_device:get_field(fields.CUMULATIVE_REPORTS_SUPPORTED) then + local current_offset = device:get_field(fields.ENERGY_METER_OFFSET) or 0.0 + device:set_field(fields.ENERGY_METER_OFFSET, current_offset + energy_meter_latest_state, {persist=true}) + end + end +end + + +-- [[ OPERATIONAL STATE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_operational_state_resume(driver, device, cmd) + local endpoint_id = device:get_endpoints(clusters.OperationalState.ID)[1] + device:send(clusters.OperationalState.server.commands.Resume(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +function CapabilityHandlers.handle_operational_state_pause(driver, device, cmd) + local endpoint_id = device:get_endpoints(clusters.OperationalState.ID)[1] + device:send(clusters.OperationalState.server.commands.Pause(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/event_handlers.lua new file mode 100644 index 0000000000..16177b38f4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/event_handlers.lua @@ -0,0 +1,82 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local lua_socket = require "socket" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" + +local EventHandlers = {} + + +-- [[ SWITCH CLUSTER EVENTS ]] -- + +function EventHandlers.initial_press_handler(driver, device, ib, response) + if switch_utils.get_field_for_endpoint(device, fields.SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + -- Receipt of an InitialPress event means we do not want to ignore the next MultiPressComplete event + -- or else we would potentially not create the expected button capability event + switch_utils.set_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id, nil) + elseif switch_utils.get_field_for_endpoint(device, fields.INITIAL_PRESS_ONLY, ib.endpoint_id) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) + elseif switch_utils.get_field_for_endpoint(device, fields.EMULATE_HELD, ib.endpoint_id) then + -- if our button doesn't differentiate between short and long holds, do it in code by keeping track of the press down time + switch_utils.set_field_for_endpoint(device, fields.START_BUTTON_PRESS, ib.endpoint_id, lua_socket.gettime(), {persist = false}) + end +end + +-- if the device distinguishes a long press event, it will always be a "held" +-- there's also a "long release" event, but this event is required to come first +function EventHandlers.long_press_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.held({state_change = true})) + if switch_utils.get_field_for_endpoint(device, fields.SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + -- Ignore the next MultiPressComplete event if it is sent as part of this "long press" event sequence + switch_utils.set_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id, true) + end +end + +function EventHandlers.multi_press_complete_handler(driver, device, ib, response) + -- in the case of multiple button presses + -- emit number of times, multiple presses have been completed + if ib.data and not switch_utils.get_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id) then + local press_value = ib.data.elements.total_number_of_presses_counted.value + --capability only supports up to 6 presses + if press_value < 7 then + local button_event = capabilities.button.button.pushed({state_change = true}) + if press_value == 2 then + button_event = capabilities.button.button.double({state_change = true}) + elseif press_value > 2 then + button_event = capabilities.button.button(string.format("pushed_%dx", press_value), {state_change = true}) + end + + device:emit_event_for_endpoint(ib.endpoint_id, button_event) + else + device.log.info(string.format("Number of presses (%d) not supported by capability", press_value)) + end + end + switch_utils.set_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id, nil) +end + +local function emulate_held_event(device, ep) + local now = lua_socket.gettime() + local press_init = switch_utils.get_field_for_endpoint(device, fields.START_BUTTON_PRESS, ep) or now -- if we don't have an init time, assume instant release + if (now - press_init) < fields.TIMEOUT_THRESHOLD then + if (now - press_init) > fields.HELD_THRESHOLD then + device:emit_event_for_endpoint(ep, capabilities.button.button.held({state_change = true})) + else + device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = true})) + end + end + switch_utils.set_field_for_endpoint(device, fields.START_BUTTON_PRESS, ep, nil, {persist = false}) +end + +function EventHandlers.short_release_handler(driver, device, ib, response) + if not switch_utils.get_field_for_endpoint(device, fields.SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + if switch_utils.get_field_for_endpoint(device, fields.EMULATE_HELD, ib.endpoint_id) then + emulate_held_event(device, ib.endpoint_id) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) + end + end +end + +return EventHandlers \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/color_utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/color_utils.lua similarity index 88% rename from drivers/SmartThings/matter-switch/src/color_utils.lua rename to drivers/SmartThings/matter-switch/src/switch_utils/color_utils.lua index ce73b1eecd..7e4c231a30 100644 --- a/drivers/SmartThings/matter-switch/src/color_utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/color_utils.lua @@ -1,3 +1,6 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --TODO remove the usage of these color utils once 0.48.x has been distributed -- to all hubs. local color_utils = {} @@ -46,9 +49,9 @@ end --- Convert from x/y/Y to Hue/Saturation --- If every value is missing then [x, y, Y] = [0, 0, 1] --- ---- @param x number red in range [0x0000, 0xFFFF] ---- @param y number green in range [0x0000, 0xFFFF] ---- @param Y number blue in range [0x0000, 0xFFFF] +--- @param x number|nil red in range [0x0000, 0xFFFF] +--- @param y number|nil green in range [0x0000, 0xFFFF] +--- @param Y number|nil blue in range [0x0000, 0xFFFF] --- @returns number, number equivalent hue, saturation, level each in range [0,100]% color_utils.safe_xy_to_hsv = function(x, y, Y) local safe_x = x ~= nil and x / 65536 or 0 diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua new file mode 100644 index 0000000000..130fd1c1e4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -0,0 +1,321 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local version = require "version" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" +local embedded_cluster_utils = require "switch_utils.embedded_cluster_utils" + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + +local DeviceConfiguration = {} +local ChildConfiguration = {} +local SwitchDeviceConfiguration = {} +local ButtonDeviceConfiguration = {} +local FanDeviceConfiguration = {} +local ValveDeviceConfiguration = {} + +function ChildConfiguration.create_or_update_child_devices(driver, device, server_cluster_ep_ids, default_endpoint_id, assign_profile_fn) + if #server_cluster_ep_ids == 1 and server_cluster_ep_ids[1] == default_endpoint_id then -- no children will be created + return + end + + table.sort(server_cluster_ep_ids) + for device_num, ep_id in ipairs(server_cluster_ep_ids) do + if ep_id ~= default_endpoint_id then -- don't create a child device that maps to the main endpoint + local label_and_name = string.format("%s %d", device.label, device_num) + local child_profile, optional_component_capabilities = assign_profile_fn(device, ep_id, true) + local existing_child_device = device:get_field(fields.IS_PARENT_CHILD_DEVICE) and switch_utils.find_child(device, ep_id) + if not existing_child_device then + driver:try_create_device({ + type = "EDGE_CHILD", + label = label_and_name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%d", ep_id), + vendor_provided_label = label_and_name + }) + else + existing_child_device:try_update_metadata({ + profile = child_profile, + optional_component_capabilities = optional_component_capabilities + }) + end + end + end + + -- Persist so that the find_child function is always set on each driver init. + device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) + device:set_find_child(switch_utils.find_child) +end + +function FanDeviceConfiguration.assign_profile_for_fan_ep(device, server_fan_ep_id) + local ep_info = switch_utils.get_endpoint_info(device, server_fan_ep_id) + local fan_cluster_info = switch_utils.find_cluster_on_ep(ep_info, clusters.FanControl.ID) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + + if clusters.FanControl.are_features_supported(clusters.FanControl.types.Feature.MULTI_SPEED, fan_cluster_info.feature_map) then + table.insert(main_component_capabilities, capabilities.fanSpeedPercent.ID) + -- only fanMode can trigger AUTO, so a multi-speed fan still requires this capability if it supports AUTO + if clusters.FanControl.are_features_supported(clusters.FanControl.types.Feature.AUTO, fan_cluster_info.feature_map) then + table.insert(main_component_capabilities, capabilities.fanMode.ID) + end + else -- MULTI_SPEED is not supported + table.insert(main_component_capabilities, capabilities.fanMode.ID) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + return "fan-modular", optional_supported_component_capabilities +end + +function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_onoff_ep_id, is_child_device) + local ep_info = switch_utils.get_endpoint_info(device, server_onoff_ep_id) + + -- per spec, the Switch device types support OnOff as CLIENT, though some vendors break spec and support it as SERVER. + local primary_dt_id = switch_utils.find_max_subset_device_type(ep_info, fields.DEVICE_TYPE_ID.LIGHT) + or switch_utils.find_max_subset_device_type(ep_info, fields.DEVICE_TYPE_ID.SWITCH) + or switch_utils.find_primary_device_type(ep_info) + + local generic_profile = fields.device_type_profile_map[primary_dt_id] + + local static_electrical_tags = switch_utils.get_field_for_endpoint(device, fields.ELECTRICAL_TAGS, server_onoff_ep_id) + if type(static_electrical_tags) == "string" then + -- if no associated profile is found for the device type and static electrical tags are available, use "plug-binary" as a fallback + generic_profile = generic_profile or "plug-binary" + -- profiles like 'light-binary' and 'plug-binary' should drop the '-binary' and become 'light-power', 'plug-energy-powerConsumption', etc. + generic_profile = string.gsub(generic_profile, "-binary", "") .. static_electrical_tags + end + + if is_child_device and generic_profile == switch_utils.get_product_override_field(device, "initial_profile") then + generic_profile = switch_utils.get_product_override_field(device, "target_profile") or generic_profile + end + + -- if no supported device type is found, return switch-binary as a generic "OnOff EP" profile + return generic_profile or "switch-binary" +end + +-- Per the spec, these attributes are "meant to be changed only during commissioning." +function SwitchDeviceConfiguration.set_device_control_options(device) + for _, ep in ipairs(device.endpoints) do + -- before the Matter 1.3 lua libs update (HUB FW 54), OptionsBitmap was defined as LevelControlOptions + if switch_utils.find_cluster_on_ep(ep, clusters.LevelControl.ID) then + device:send(clusters.LevelControl.attributes.Options:write(device, ep.endpoint_id, clusters.LevelControl.types.LevelControlOptions.EXECUTE_IF_OFF)) + end + -- before the Matter 1.4 lua libs update (HUB FW 56), there was no OptionsBitmap type defined + if switch_utils.find_cluster_on_ep(ep, clusters.ColorControl.ID) then + local excute_if_off_bit = clusters.ColorControl.types.OptionsBitmap and clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF or 0x0001 + device:send(clusters.ColorControl.attributes.Options:write(device, ep.endpoint_id, excute_if_off_bit)) + end + end +end + +function ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, num_button_eps) + local profile_name = string.gsub(num_button_eps .. "-button", "1%-", "") -- remove the "1-" in a device with 1 button ep + if switch_utils.device_type_supports_button_switch_combination(device, default_endpoint_id) then + profile_name = "light-level-" .. profile_name + end + local motion_eps = device:get_endpoints(clusters.OccupancySensing.ID) + if #motion_eps > 0 and (num_button_eps == 3 or num_button_eps == 6) then -- only these two devices are handled + profile_name = profile_name .. "-motion" + end + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) + if battery_support == fields.battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + elseif battery_support == fields.battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + end + if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then + profile_name = "3-button-battery-temperature-humidity" + end + if switch_utils.get_product_override_field(device, "is_ikea_dual_button") then + profile_name = "ikea-2-button-battery" + end + return profile_name +end + +function ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_eps) + -- create component mapping on the main profile button endpoints + table.sort(button_eps) + local component_map = {} + component_map["main"] = default_endpoint_id + for component_num, ep in ipairs(button_eps) do + if ep ~= default_endpoint_id then + local button_component = "button" + if #button_eps > 1 then + button_component = button_component .. component_num + end + component_map[button_component] = ep + end + end + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) +end + +function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) + local msr_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE}) + local msl_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) + local msm_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS}) + + for _, ep in ipairs(momentary_switch_ep_ids or {}) do + if device.profile.components[switch_utils.endpoint_to_component(device, ep)] then + device.log.info_with({hub_logs=true}, string.format("Configuring Supported Values for generic switch endpoint %d", ep)) + local supportedButtonValues_event + -- this ordering is important, since MSM & MSL devices must also support MSR + if switch_utils.tbl_contains(msm_eps, ep) then + supportedButtonValues_event = nil -- deferred to the max press handler + device:send(clusters.Switch.attributes.MultiPressMax:read(device, ep)) + switch_utils.set_field_for_endpoint(device, fields.SUPPORTS_MULTI_PRESS, ep, true, {persist = true}) + elseif switch_utils.tbl_contains(msl_eps, ep) then + supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}) + elseif switch_utils.tbl_contains(msr_eps, ep) then + supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}) + switch_utils.set_field_for_endpoint(device, fields.EMULATE_HELD, ep, true, {persist = true}) + else -- this switch endpoint only supports momentary switch, no release events + supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) + switch_utils.set_field_for_endpoint(device, fields.INITIAL_PRESS_ONLY, ep, true, {persist = true}) + end + + if supportedButtonValues_event then + device:emit_event_for_endpoint(ep, supportedButtonValues_event) + end + else + device.log.info_with({hub_logs=true}, string.format("Component not found for generic switch endpoint %d. Skipping Supported Value configuration", ep)) + end + end +end + +function ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device, irrigation_system_ep_id, is_child_device) + local main_component_capabilities = {} + local profile_name = "irrigation-system" + + local valve_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) + table.sort(valve_ep_ids) + local supports_level = switch_utils.find_cluster_on_ep( + switch_utils.get_endpoint_info(device, is_child_device and irrigation_system_ep_id or valve_ep_ids[1]), + clusters.ValveConfigurationAndControl.ID, + {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL} + ) + if supports_level then + table.insert(main_component_capabilities, capabilities.level.ID) + end + + if is_child_device then + return profile_name, {{"main", main_component_capabilities}} + end + + local irrigation_system_ep_info = switch_utils.get_endpoint_info(device, irrigation_system_ep_id) + if switch_utils.find_cluster_on_ep(irrigation_system_ep_info, clusters.FlowMeasurement.ID) then + table.insert(main_component_capabilities, capabilities.flowMeasurement.ID) + end + if switch_utils.find_cluster_on_ep(irrigation_system_ep_info, clusters.OperationalState.ID) then + table.insert(main_component_capabilities, capabilities.operationalState.ID) + end + + return profile_name, {{"main", main_component_capabilities}} +end + + +-- [[ PROFILE MATCHING AND CONFIGURATIONS ]] -- + +function DeviceConfiguration.match_child_profile(driver, device) + local parent_device = device:get_parent_device() + local irrigation_system_ep_ids = switch_utils.get_endpoints_by_device_type( + parent_device, + fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM + ) + if #irrigation_system_ep_ids > 0 then + ChildConfiguration.create_or_update_child_devices( + driver, + parent_device, + {device:get_endpoint()}, + nil, + ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep + ) + end +end + +local function profiling_data_still_required(device) + for _, field in pairs(fields.profiling_data) do + if device:get_field(field) == nil then + return true -- data still required if a field is nil + end + end + return false +end + +function DeviceConfiguration.match_profile(driver, device) + if profiling_data_still_required(device) then return end + + local default_endpoint_id = switch_utils.find_default_endpoint(device) + local optional_component_capabilities + local updated_profile + + local server_onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) -- get_endpoints defaults to return EPs supporting SERVER or BOTH + if #server_onoff_ep_ids > 0 then + ChildConfiguration.create_or_update_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep) + end + + if switch_utils.tbl_contains(server_onoff_ep_ids, default_endpoint_id) then + updated_profile = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, default_endpoint_id) + local generic_profile = function(s) return string.find(updated_profile or "", s, 1, true) end + if generic_profile("light-level") and #device:get_endpoints(clusters.OccupancySensing.ID) > 0 then + updated_profile = switch_utils.get_product_override_field(device, "target_profile") or "light-level-motion" + elseif switch_utils.check_switch_category_vendor_overrides(device) then + -- check whether the overwrite should be over "plug" or "light" based on the current profile + local overwrite_category = string.find(updated_profile, "plug") and "plug" or "light" + updated_profile = string.gsub(updated_profile, overwrite_category, "switch") + elseif generic_profile("light-level-colorTemperature") or generic_profile("light-color-level") then + -- ignore attempts to dynamically profile light-level-colorTemperature and light-color-level devices for now, since + -- these may lose fingerprinted Kelvin ranges when dynamically profiled. + return + end + end + + local irrigation_system_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM) + local valve_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) + if #irrigation_system_ep_ids > 0 then + updated_profile, optional_component_capabilities = ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device, irrigation_system_ep_ids[1], false) + ChildConfiguration.create_or_update_child_devices(driver, device, valve_ep_ids, default_endpoint_id, ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep) + elseif #valve_ep_ids > 0 then + updated_profile = "water-valve" + if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, + {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then + updated_profile = updated_profile .. "-level" + end + end + + if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) > 0 then + updated_profile, optional_component_capabilities = FanDeviceConfiguration.assign_profile_for_fan_ep(device, default_endpoint_id) + end + + -- initialize the main device card with buttons if applicable + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then + updated_profile = ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) + -- All button endpoints found will be added as additional components in the profile containing the default_endpoint_id. + ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) + ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) + end + + device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) +end + +return { + ButtonCfg = ButtonDeviceConfiguration, + ChildCfg = ChildConfiguration, + DeviceCfg = DeviceConfiguration, + SwitchCfg = SwitchDeviceConfiguration, + ValveCfg = ValveDeviceConfiguration +} diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/embedded_cluster_utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/embedded_cluster_utils.lua new file mode 100644 index 0000000000..8f52a1488c --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_utils/embedded_cluster_utils.lua @@ -0,0 +1,56 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local utils = require "st.utils" + +-- Include driver-side definitions when lua libs api version is < 11 +local version = require "version" +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +local embedded_cluster_utils = {} + +local embedded_clusters = { + [clusters.ElectricalEnergyMeasurement.ID] = clusters.ElectricalEnergyMeasurement, + [clusters.ElectricalPowerMeasurement.ID] = clusters.ElectricalPowerMeasurement, + [clusters.ValveConfigurationAndControl.ID] = clusters.ValveConfigurationAndControl +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 11 and embedded_clusters[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end + end + + return embedded_cluster_utils \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua new file mode 100644 index 0000000000..5873cb40b2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -0,0 +1,235 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" + +local SwitchFields = {} + +SwitchFields.LATEST_REQUESTED_KELVIN = "mostRecentTemp" +SwitchFields.RECEIVED_X = "receivedX" +SwitchFields.RECEIVED_Y = "receivedY" +SwitchFields.HUESAT_SUPPORT = "huesatSupport" + +SwitchFields.MIRED_KELVIN_CONVERSION_CONSTANT = 1000000 + +-- These values are a "sanity check" to check that values we are getting are reasonable +SwitchFields.COLOR_TEMPERATURE_MIRED_MIN = 67 -- 15000 Kelvin +SwitchFields.COLOR_TEMPERATURE_MIRED_MAX = 1000 -- 1000 Kelvin + +-- These values are the config bounds in the default Matter profiles (e.g. light-level-colorTemperature, light-color-level) +SwitchFields.DEFAULT_MIRED_MIN = 154 -- 6500 Kelvin +SwitchFields.DEFAULT_MIRED_MAX = 455 -- 2200 Kelvin + +SwitchFields.SWITCH_LEVEL_LIGHTING_MIN = 1 +SwitchFields.CURRENT_HUESAT_ATTR_MIN = 0 +SwitchFields.CURRENT_HUESAT_ATTR_MAX = 254 + +SwitchFields.DEVICE_TYPE_ID = { + AGGREGATOR = 0x000E, + BRIDGED_NODE = 0x0013, + CAMERA = 0x0142, + CHIME = 0x0146, + DIMMABLE_PLUG_IN_UNIT = 0x010B, + DOORBELL = 0x0143, + ELECTRICAL_SENSOR = 0x0510, + FAN = 0x002B, + GENERIC_SWITCH = 0x000F, + LIGHT_SENSOR = 0x0106, + IRRIGATION_SYSTEM = 0x0040, + OCCUPANCY_SENSOR = 0x0107, + MOUNTED_ON_OFF_CONTROL = 0x010F, + MOUNTED_DIMMABLE_LOAD_CONTROL = 0x0110, + ON_OFF_PLUG_IN_UNIT = 0x010A, + LIGHT = { + ON_OFF = 0x0100, + DIMMABLE = 0x0101, + COLOR_TEMPERATURE = 0x010C, + EXTENDED_COLOR = 0x010D, + }, + SWITCH = { + ON_OFF_LIGHT = 0x0103, + DIMMER = 0x0104, + COLOR_DIMMER = 0x0105, + }, + WATER_VALVE = 0x0042, + WINDOW_COVERING = 0x0202 +} + +SwitchFields.device_type_profile_map = { + [SwitchFields.DEVICE_TYPE_ID.LIGHT.ON_OFF] = "light-binary", + [SwitchFields.DEVICE_TYPE_ID.LIGHT.DIMMABLE] = "light-level", + [SwitchFields.DEVICE_TYPE_ID.LIGHT.COLOR_TEMPERATURE] = "light-level-colorTemperature", + [SwitchFields.DEVICE_TYPE_ID.LIGHT.EXTENDED_COLOR] = "light-color-level", + [SwitchFields.DEVICE_TYPE_ID.SWITCH.ON_OFF_LIGHT] = "switch-binary", + [SwitchFields.DEVICE_TYPE_ID.SWITCH.DIMMER] = "switch-level", + [SwitchFields.DEVICE_TYPE_ID.SWITCH.COLOR_DIMMER] = "switch-color-level", + [SwitchFields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT] = "plug-binary", + [SwitchFields.DEVICE_TYPE_ID.DIMMABLE_PLUG_IN_UNIT] = "plug-level", + [SwitchFields.DEVICE_TYPE_ID.MOUNTED_ON_OFF_CONTROL] = "switch-binary", + [SwitchFields.DEVICE_TYPE_ID.MOUNTED_DIMMABLE_LOAD_CONTROL] = "switch-level", + [SwitchFields.DEVICE_TYPE_ID.WINDOW_COVERING] = "window-covering" +} + +-- COMPONENT_TO_ENDPOINT_MAP is here to preserve the endpoint mapping for +-- devices that were joined to this driver as MCD devices before the transition +-- to join switch devices as parent-child. This value will exist in the device +-- table for devices that joined prior to this transition, and is also used for +-- button devices that require component mapping. +SwitchFields.COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" +SwitchFields.IS_PARENT_CHILD_DEVICE = "__is_parent_child_device" + +--- If the ASSIGNED_CHILD_KEY field is populated for an endpoint, it should be +--- used as the key in the get_child_by_parent_assigned_key() function. This allows +--- multiple endpoints to associate with the same child device, though right now child +--- devices are keyed using only one endpoint id. +SwitchFields.ASSIGNED_CHILD_KEY = "__assigned_child_key" + +SwitchFields.COLOR_TEMP_BOUND_RECEIVED_KELVIN = "__colorTemp_bound_received_kelvin" +SwitchFields.COLOR_TEMP_BOUND_RECEIVED_MIRED = "__colorTemp_bound_received_mired" +SwitchFields.COLOR_TEMP_MIN = "__color_temp_min" +SwitchFields.COLOR_TEMP_MAX = "__color_temp_max" +SwitchFields.LEVEL_BOUND_RECEIVED = "__level_bound_received" +SwitchFields.LEVEL_MIN = "__level_min" +SwitchFields.LEVEL_MAX = "__level_max" +SwitchFields.COLOR_MODE = "__color_mode" +SwitchFields.FLOW_BOUND_RECEIVED = "__flow_bound_received" +SwitchFields.FLOW_MIN = "__flow_min" +SwitchFields.FLOW_MAX = "__flow_max" + +SwitchFields.SUBSCRIBED_ATTRIBUTES_KEY = "__subscribed_attributes" + +SwitchFields.updated_fields = { + { current_field_name = "__component_to_endpoint_map_button", updated_field_name = SwitchFields.COMPONENT_TO_ENDPOINT_MAP }, + { current_field_name = "__switch_intialized", updated_field_name = nil }, + { current_field_name = "__energy_management_endpoint", updated_field_name = nil }, + { current_field_name = "__total_imported_energy", updated_field_name = nil }, + { current_field_name = "__last_imported_report_timestamp", updated_field_name = nil }, + { current_field_name = "mostRecentTemp", updated_field_name = nil }, +} + +SwitchFields.vendor_overrides = { + [0x115F] = { -- AQARA_MANUFACTURER_ID + [0x1006] = { ignore_combo_switch_button = true }, -- 3 Buttons(Generic Switch), 1 Channel (Dimmable Light) + [0x100A] = { ignore_combo_switch_button = true }, -- 1 Buttons(Generic Switch), 1 Channel (Dimmable Light) + [0x2004] = { is_climate_sensor_w100 = true }, -- Climate Sensor W100, requires unique profile + }, + [0x117C] = { -- IKEA_MANUFACTURER_ID + [0x8000] = { is_ikea_scroll = true }, -- BILRESA scroll wheel + [0x8001] = { is_ikea_dual_button = true}, -- BILRESA dual button + }, + [0x1189] = { -- LEDVANCE_MANUFACTURER_ID + [0x0891] = { target_profile = "switch-binary", initial_profile = "light-binary" }, + }, + [0x1285] = { -- HAGER_MANUFACTURER_ID + [0x0005] = { needs_hager_subdriver = true }, -- Hager HBnet 1g switch + [0x0006] = { needs_hager_subdriver = true }, -- Hager HBnet 2g switch + [0x0007] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 1.1M + [0x000A] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 2.2M + }, + [0x1321] = { -- SONOFF_MANUFACTURER_ID + [0x000C] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, + [0x000D] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, + }, + [0x1209] = { -- BOSCH_MANUFACTURER_ID + [0x3013] = {target_profile = "light-level-battery-illuminance-motion-temperature"}, + }, + [0x1407] = { -- THIRD_REALITY_MANUFACTURER_ID + [0x1098] = { is_third_reality_garage_door = true }, + [0x1388] = { is_third_reality_mk1 = true}, + }, +} + +SwitchFields.switch_category_vendor_overrides = { + [0x1432] = -- Elko + {0x1000}, + [0x130A] = -- Eve + {0x005D, 0x0043}, + [0x1339] = -- GE + {0x007D, 0x0074, 0x0075}, + [0x1372] = -- Innovation Matters + {0x0002}, + [0x1189] = -- Ledvance + {0x0891, 0x0892}, + [0x1021] = -- Legrand + {0x0005}, + [0x109B] = -- Leviton + {0x1001, 0x1000, 0x100B, 0x100E, 0x100C, 0x100D, 0x1009, 0x1003, 0x1004, 0x1002}, + [0x142B] = -- LeTianPai + {0x1004, 0x1003, 0x1002}, + [0x1509] = -- SmartSetup + {0x0004, 0x0001}, + [0x1321] = -- SONOFF + {0x000B, 0x000C, 0x000D}, + [0x147F] = -- U-Tec + {0x0004}, + [0x139C] = -- Zemismart + {0xEEE2, 0xAB08, 0xAB31, 0xAB04, 0xAB01, 0xAB43, 0xAB02, 0xAB03, 0xAB05} +} + +SwitchFields.operational_state_command_map = { + [clusters.OperationalState.commands.Pause.ID] = "pause", + [clusters.OperationalState.commands.Resume.ID] = "resume" +} + +--- stores a table of endpoints that support the Electrical Sensor device type, used during profiling +--- in AvailableEndpoints and PartsList handlers for SET and TREE PowerTopology features, respectively +SwitchFields.ELECTRICAL_SENSOR_EPS = "__electrical_sensor_eps" + +--- used in tandem with an EP ID. Stores the required electrical tags "-power", "-energy-powerConsumption", etc. +--- for an Electrical Sensor EP with a "primary" endpoint, used during device profiling. +SwitchFields.ELECTRICAL_TAGS = "__electrical_tags" + +SwitchFields.profiling_data = { + POWER_TOPOLOGY = "__power_topology", + BATTERY_SUPPORT = "__battery_support", +} + +SwitchFields.battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE", +} + +SwitchFields.ENERGY_METER_OFFSET = "__energy_meter_offset" +SwitchFields.CUMULATIVE_REPORTS_SUPPORTED = "__cumulative_reports_supported" +SwitchFields.LAST_IMPORTED_REPORT_TIMESTAMP = "__last_imported_report_timestamp" +SwitchFields.MINIMUM_ST_ENERGY_REPORT_INTERVAL = (15 * 60) -- 15 minutes, reported in seconds + +SwitchFields.START_BUTTON_PRESS = "__start_button_press" +SwitchFields.TIMEOUT_THRESHOLD = 10 --arbitrary timeout +SwitchFields.HELD_THRESHOLD = 1 + +-- this is the number of buttons for which we have a static profile already made +SwitchFields.STATIC_BUTTON_PROFILE_SUPPORTED = {1, 2, 3, 4, 5, 6, 7, 8, 9} + +-- Some switches will send a MultiPressComplete event as part of a long press sequence. Normally the driver will create a +-- button capability event on receipt of MultiPressComplete, but in this case that would result in an extra event because +-- the "held" capability event is generated when the LongPress event is received. The IGNORE_NEXT_MPC flag is used +-- to tell the driver to ignore MultiPressComplete if it is received after a long press to avoid this extra event. +SwitchFields.IGNORE_NEXT_MPC = "__ignore_next_mpc" + +-- These are essentially storing the supported features of a given endpoint +-- TODO: add an is_feature_supported_for_endpoint function to matter.device that takes an endpoint +SwitchFields.EMULATE_HELD = "__emulate_held" -- for non-MSR (MomentarySwitchRelease) devices we can emulate this on the software side +SwitchFields.SUPPORTS_MULTI_PRESS = "__multi_button" -- for MSM devices (MomentarySwitchMultiPress), create an event on receipt of MultiPressComplete +SwitchFields.INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) + +SwitchFields.TEMP_BOUND_RECEIVED = "__temp_bound_received" +SwitchFields.TEMP_MIN = "__temp_min" +SwitchFields.TEMP_MAX = "__temp_max" + +SwitchFields.ZERO_TRANSITION_TIME = 0 -- 0.0 seconds +SwitchFields.DEFAULT_STEP_TRANSITION_TIME = 3 -- 0.3 seconds, measured in tenths of a second as per the Matter spec + +SwitchFields.TRANSITION_TIME = { + SWITCH_LEVEL_STEP = "__switch_level_step_transition_time", + COLOR_TEMP_STEP = "__color_temp_step_transition_time", +} + +-- For Level/Color Control cluster commands, this field indicates which bits in the OptionsOverride field are valid. In this case, we specify that the ExecuteIfOff option (bit 1) may be overridden. +SwitchFields.OPTIONS_MASK = 0x01 +-- the OptionsOverride field's first bit overrides the ExecuteIfOff option, defining whether the command should take effect when the device is off. +SwitchFields.HANDLE_COMMAND_IF_OFF = 0x01 +SwitchFields.IGNORE_COMMAND_IF_OFF = 0x00 + +return SwitchFields diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua new file mode 100644 index 0000000000..1d12f41781 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -0,0 +1,592 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MatterDriver = require "st.matter.driver" +local fields = require "switch_utils.fields" +local st_utils = require "st.utils" +local version = require "version" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local im = require "st.matter.interaction_model" +local log = require "log" + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.PowerTopology = require "embedded_clusters.PowerTopology" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + +local utils = {} + +function utils.tbl_contains(array, value) + if value == nil then return false end + for _, element in pairs(array or {}) do + if element == value then + return true + end + end + return false +end + +function utils.convert_huesat_st_to_matter(val) + return st_utils.clamp_value(math.floor((val * 0xFE) / 100.0 + 0.5), fields.CURRENT_HUESAT_ATTR_MIN, fields.CURRENT_HUESAT_ATTR_MAX) +end + +function utils.get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +function utils.set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +function utils.remove_field_index(device, field_name, index) + local new_table = device:get_field(field_name) + if type(new_table) == "table" then + new_table[index] = nil -- remove value associated with index from table + device:set_field(field_name, new_table) + end +end + +function utils.mired_to_kelvin(value, minOrMax) + if value == 0 then -- shouldn't happen, but has + value = 1 + log.warn(string.format("Received a color temperature of 0 mireds. Using a color temperature of 1 mired to avoid divide by zero")) + end + -- We divide inside the rounding and multiply outside of it because we expect these + -- bounds to be multiples of 100. For the maximum mired value (minimum K value), + -- add 1 before converting and round up to nearest hundreds. For the minimum mired + -- (maximum K value) value, subtract 1 before converting and round down to nearest + -- hundreds. Note that 1 is added/subtracted from the mired value in order to avoid + -- rounding errors from the conversion of Kelvin to mireds. + local kelvin_step_size = 100 + local rounding_value = 0.5 + if minOrMax == fields.COLOR_TEMP_MIN then + return st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT / (kelvin_step_size * (value + 1)) + rounding_value) * kelvin_step_size + elseif minOrMax == fields.COLOR_TEMP_MAX then + return st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT / (kelvin_step_size * (value - 1)) - rounding_value) * kelvin_step_size + else + log.warn_with({hub_logs = true}, "Attempted to convert temperature unit for an undefined value") + end +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.check_field_name_updates(device) + for _, field in ipairs(fields.updated_fields) do + if device:get_field(field.current_field_name) then + if field.updated_field_name ~= nil then + device:set_field(field.updated_field_name, device:get_field(field.current_field_name), {persist = true}) + end + device:set_field(field.current_field_name, nil) + end + end +end + +function utils.check_switch_category_vendor_overrides(device) + for _, product_id in ipairs(fields.switch_category_vendor_overrides[device.manufacturer_info.vendor_id] or {}) do + if device.manufacturer_info.product_id == product_id then + return true + end + end +end + +--- device_type_supports_button_switch_combination helper function used to check +--- whether the device type for an endpoint is currently supported by a profile for +--- combination button/switch devices. +function utils.device_type_supports_button_switch_combination(device, endpoint_id) + if utils.get_product_override_field(device, "ignore_combo_switch_button") then + return false + end + local dimmable_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE) + return utils.tbl_contains(dimmable_eps, endpoint_id) +end + +--- Some devices report multiple device types which are a subset of a superset +--- device type (Ex. Dimmable Light is a superset of On/Off Light). We should map +--- to the largest superset device type supported. +--- This can be done by matching to the device type with the highest ID +--- note: that superset device types have a higher ID than those of their subset +--- is heuristic and could therefore break in the future, were the spec expanded +function utils.find_max_subset_device_type(ep, device_type_set) + if ep.endpoint_id == 0 then return end -- EP-scoped device types not permitted on Root Node + local primary_dt_id = -1 + for _, dt in ipairs(ep.device_types) do + -- only device types in the subset should be considered. + if utils.tbl_contains(device_type_set, dt.device_type_id) then + primary_dt_id = math.max(primary_dt_id, dt.device_type_id) + end + end + return (primary_dt_id > 0) and primary_dt_id or nil +end + +--- Lights and Switches are Device Types that have Superset-style functionality +--- For all other device types, this function should be used to identify the primary device type +function utils.find_primary_device_type(ep_info) + for _, dt in ipairs(ep_info.device_types) do + if dt.device_type_id ~= fields.DEVICE_TYPE_ID.BRIDGED_NODE then + -- if this is not a bridged node, return the first device type seen + return dt.device_type_id + end + end +end + +--- find_default_endpoint is a helper function to handle situations where +--- device does not have endpoint ids in sequential order from 1 +function utils.find_default_endpoint(device) + -- Buttons should not be set on the main component for the Aqara Climate Sensor W100, + if utils.get_product_override_field(device, "is_climate_sensor_w100") then + return device.MATTER_DEFAULT_ENDPOINT + end + + local get_first_non_zero_endpoint = function(endpoints) + table.sort(endpoints) + for _,ep in ipairs(endpoints) do + if ep ~= 0 then -- 0 is the matter RootNode endpoint + return ep + end + end + return nil + end + + -- Return the first fan endpoint as the default endpoint if any is found + local fan_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) + if #fan_endpoint_ids > 0 then + return get_first_non_zero_endpoint(fan_endpoint_ids) + end + + -- Return the first water valve endpoint as the default endpoint if any is found + local water_valve_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) + if #water_valve_endpoint_ids > 0 then + return get_first_non_zero_endpoint(water_valve_endpoint_ids) + end + + -- If both onoff and momentary switch endpoints are present, check the device type on the first onoff + -- endpoint. If it is not a supported device type, return the first momentary switch endpoint as the + -- default endpoint. Else return the first onoff endpoint as the default endpoint. + -- + -- If only one of the two types of endpoints are present, return the first endpoint of the present type. + local onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + if #onoff_ep_ids > 0 and #momentary_switch_ep_ids > 0 then + local default_endpoint_id = get_first_non_zero_endpoint(onoff_ep_ids) + if utils.device_type_supports_button_switch_combination(device, default_endpoint_id) then + return default_endpoint_id + else + device.log.warn("The main switch endpoint does not contain a supported device type for a component configuration with buttons") + return get_first_non_zero_endpoint(momentary_switch_ep_ids) + end + elseif #onoff_ep_ids > 0 then + return get_first_non_zero_endpoint(onoff_ep_ids) + elseif #momentary_switch_ep_ids > 0 then + return get_first_non_zero_endpoint(momentary_switch_ep_ids) + end + + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return device.MATTER_DEFAULT_ENDPOINT +end + +function utils.component_to_endpoint(device, component) + local map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} + if map[component] then + return map[component] + end + return utils.find_default_endpoint(device) +end + +--- An extension of the library function endpoint_to_component, used to support a mapping scheme +--- that optionally includes cluster and attribute ids so that multiple components can be mapped +--- to a single endpoint. This extension also handles the case that multiple endpoints map to the +--- same component +--- +--- @param device any a Matter device object +--- @param ep_info number|table either an ep_id or a table { endpoint_id, optional(cluster_id), optional(attribute_id) } +--- where cluster_id is required for an attribute_id to be handled. +--- @return string component +function utils.endpoint_to_component(device, ep_info) + if type(ep_info) == "number" then + ep_info = { endpoint_id = ep_info } + end + for component, map_info in pairs(device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {}) do + if type(map_info) == "number" and map_info == ep_info.endpoint_id then + return component + elseif type(map_info) == "table" then + if type(map_info.endpoint_id) == "number" then + map_info = {map_info} + end + for _, ep_map_info in ipairs(map_info) do + if type(ep_map_info) == "number" and ep_map_info == ep_info.endpoint_id then + return component + elseif type(ep_map_info) == "table" and ep_map_info.endpoint_id == ep_info.endpoint_id + and (not ep_map_info.cluster_id or (ep_map_info.cluster_id == ep_info.cluster_id + and (not ep_map_info.attribute_ids or utils.tbl_contains(ep_map_info.attribute_ids, ep_info.attribute_id)))) then + return component + end + end + end + end + return "main" +end + +--- An extension of the library function emit_event_for_endpoint, used to support devices with +--- multiple components mapped to the same endpoint. This is handled by extending the parameters to optionally +--- include a cluster id and attribute id for more specific routing +--- +--- @param device any a Matter device object +--- @param ep_info number|table endpoint_id or an ib (the ib data includes endpoint_id, cluster_id, and attribute_id fields) +--- @param event any a capability event object +function utils.emit_event_for_endpoint(device, ep_info, event) + if type(ep_info) == "number" then + ep_info = { endpoint_id = ep_info } + end + if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then + local child = utils.find_child(device, ep_info.endpoint_id) + if child ~= nil then + child:emit_event(event) + return + end + end + local comp_id = utils.endpoint_to_component(device, ep_info) + local comp = device.profile.components[comp_id] + device:emit_component_event(comp, event) +end + +function utils.find_child(parent_device, ep_id) + local assigned_key = utils.get_field_for_endpoint(parent_device, fields.ASSIGNED_CHILD_KEY, ep_id) or ep_id + return parent_device:get_child_by_parent_assigned_key(string.format("%d", assigned_key)) +end + +function utils.get_endpoint_info(device, endpoint_id) + for _, ep in ipairs(device.endpoints) do + if ep.endpoint_id == endpoint_id then return ep end + end + return {} +end + +function utils.find_cluster_on_ep(ep, cluster_id, opts) + opts = opts or {} + local clus_has_features = function(cluster, checked_feature) + return (cluster.feature_map & checked_feature) == checked_feature + end + if type(ep) ~= "table" then return nil end + for _, cluster in ipairs(ep.clusters or {}) do + if ((cluster.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(cluster, opts.feature_bitmap)) + and ((opts.cluster_type == nil and cluster.cluster_type == "SERVER" or cluster.cluster_type == "BOTH") + or (opts.cluster_type == cluster.cluster_type)) + or (cluster_id == nil)) then + return cluster + end + end +end + +-- Fallback handler for responses that dont have their own handler +function utils.matter_handler(driver, device, response_block) + device.log.info(string.format("Fallback handler for %s", response_block)) +end + +-- get a list of endpoints for a specified device type. +function utils.get_endpoints_by_device_type(device, device_type_id, opts) + opts = opts or {} + local dt_eps = {} + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == device_type_id then + if opts.with_info then + table.insert(dt_eps, ep) + else + table.insert(dt_eps, ep.endpoint_id) + end + break + end + end + end + return dt_eps +end + +--helper function to create list of multi press values +function utils.create_multi_press_values_list(size, supportsHeld) + local list = {"pushed", "double"} + if supportsHeld then table.insert(list, "held") end + -- add multi press values of 3 or greater to the list + for i=3, size do + table.insert(list, string.format("pushed_%dx", i)) + end + return list +end + +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function utils.deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not utils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return utils.deep_equals(mt_a, mt_b, opts, seen) +end + +function utils.detect_bridge(device) + return #utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.AGGREGATOR) > 0 +end + +--- Generalizes the 'get_latest_state' function to be callable with extra endpoint information, described below, +--- without directly specifying the expected component. See the 'get_latest_state' definition for more +--- information about parameters and expected functionality otherwise. +--- +--- @param endpoint_info number|table an endpoint id or an ib (the ib data includes endpoint_id, cluster_id, and attribute_id fields) +function utils.get_latest_state_for_endpoint(device, endpoint_info, capability_id, attribute_id, default_value, default_state_table) + if type(endpoint_info) == "number" then + endpoint_info = { endpoint_id = endpoint_info } + end + + local component = device:endpoint_to_component(endpoint_info) + local state_device = utils.find_child(device, endpoint_info.endpoint_id) or device + return state_device:get_latest_state(component, capability_id, attribute_id, default_value, default_state_table) +end + +function utils.report_power_consumption_to_st_energy(device, endpoint_id, total_imported_energy_wh) + local current_time = os.time() + local last_time = utils.get_field_for_endpoint(device, fields.LAST_IMPORTED_REPORT_TIMESTAMP, endpoint_id) or 0 + + -- Ensure that the previous report was sent at least 15 minutes ago + if fields.MINIMUM_ST_ENERGY_REPORT_INTERVAL >= (current_time - last_time) then + return + end + utils.set_field_for_endpoint(device, fields.LAST_IMPORTED_REPORT_TIMESTAMP, endpoint_id, current_time, { persist = true }) + + local previous_imported_report = utils.get_latest_state_for_endpoint(device, endpoint_id, capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME, { energy = total_imported_energy_wh }) -- default value if nil + local delta_energy = total_imported_energy_wh - previous_imported_report.energy + -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' + local epoch_to_iso8601 = function(time) return os.date("!%Y-%m-%dT%H:%M:%SZ", time) end -- Return an ISO-8061 timestamp from UTC + device:emit_event_for_endpoint(endpoint_id, capabilities.powerConsumptionReport.powerConsumption({ + start = epoch_to_iso8601(last_time), + ["end"] = epoch_to_iso8601(current_time - 1), + deltaEnergy = delta_energy >= 0.0 and delta_energy or total_imported_energy_wh, -- clarifying assumption: a negative delta means the meter was reset + energy = total_imported_energy_wh + })) +end + +--- sets fields for handling EPs with the Electrical Sensor device type +--- +--- @param device table a Matter device object +--- @param electrical_sensor_ep table an EP object that includes an Electrical Sensor device type +--- @param associated_endpoint_ids table EP IDs that are associated with the Electrical Sensor EP +--- @return boolean +function utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sensor_ep, associated_endpoint_ids) + if #associated_endpoint_ids == 0 then + return false + else + local tags = "" + if utils.find_cluster_on_ep(electrical_sensor_ep, clusters.ElectricalPowerMeasurement.ID) then tags = tags.."-power" end + if utils.find_cluster_on_ep(electrical_sensor_ep, clusters.ElectricalEnergyMeasurement.ID) then tags = tags.."-energy-powerConsumption" end + -- note: using the lowest valued EP ID here is arbitrary (not spec defined) and is done to create internal consistency + -- Ex. for the NODE topology, electrical capabilities will then be associated with the default (aka lowest ID'd) OnOff EP + 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, {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 +end + +function utils.handle_electrical_sensor_info(device) + local electrical_sensor_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR, { with_info = true }) + if #electrical_sensor_eps == 0 then + -- no Electrical Sensor EPs are supported. Set profiling data to false and return + device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, {persist=true}) + return + end + + -- energy reporting must be handled by a cumulative report, a periodic report, or both attributes simultaneously. + -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. + for _, ep_info in ipairs(electrical_sensor_eps) do + if utils.find_cluster_on_ep(ep_info, clusters.ElectricalEnergyMeasurement.ID, + {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY}) then + device:set_field(fields.CUMULATIVE_REPORTS_SUPPORTED, true) + break + end + end + + -- check the feature map for the first (or only) Electrical Sensor EP if the device profiling has not been completed + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) == nil then + local endpoint_power_topology_cluster = utils.find_cluster_on_ep(electrical_sensor_eps[1], clusters.PowerTopology.ID) or {} + local endpoint_power_topology_feature_map = endpoint_power_topology_cluster.feature_map or 0 + if clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.SET_TOPOLOGY, endpoint_power_topology_feature_map) or + clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, endpoint_power_topology_feature_map) then + -- stores a table of endpoints that support the Electrical Sensor device type, used during profiling + -- in AvailableEndpoints and PartsList handlers for SET and TREE PowerTopology features, respectively + device:set_field(fields.ELECTRICAL_SENSOR_EPS, electrical_sensor_eps) + elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, endpoint_power_topology_feature_map) then + -- EP has a NODE topology, so there is only ONE Electrical Sensor EP + device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, {persist=true}) + if utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sensor_eps[1], device:get_endpoints(clusters.OnOff.ID)) == false then + device.log.warn("Electrical Sensor EP with NODE topology found, but no OnOff EPs exist. Electrical Sensor capabilities will not be exposed.") + end + end + end +end + +function utils.lazy_load(sub_driver_name) + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + end +end + +function utils.lazy_load_if_possible(sub_driver_name) + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end + +--- helper for the switch subscribe override, which adds to a subscribed request for a checked device +--- +--- @param checked_device any a Matter device object, either a parent or child device, so not necessarily the same as device +--- @param subscribe_request table a subscribe request that will be appended to as needed for the device +--- @param capabilities_seen table a list of capabilities that have already been checked by previously handled devices +--- @param attributes_seen table a list of attributes that have already been checked +--- @param events_seen table a list of events that have already been checked +--- @param subscribed_attributes table key-value pairs mapping capability ids to subscribed attributes +--- @param subscribed_events table key-value pairs mapping capability ids to subscribed events +function utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, subscribed_attributes, subscribed_events) + for _, component in pairs(checked_device.st_store.profile.components) do + for _, capability in pairs(component.capabilities) do + if not capabilities_seen[capability.id] then + for _, attr in ipairs(subscribed_attributes[capability.id] or {}) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + end + for _, event in ipairs(subscribed_events[capability.id] or {}) do + local cluster_id = event.cluster or event._cluster.ID + local event_id = event.ID or event.event + if not events_seen[cluster_id] or not events_seen[cluster_id][event_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, nil, event_id) + subscribe_request:with_info_block(ib) + events_seen[cluster_id] = events_seen[cluster_id] or {} + events_seen[cluster_id][event_id] = ib + end + end + capabilities_seen[capability.id] = true -- only loop through any capability once + end + end + end +end + +--- create and send a subscription request by checking all devices, accounting for both parent and child devices +--- +--- @param device any a Matter device object +function utils.subscribe(device) + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + local devices_seen, capabilities_seen, attributes_seen, events_seen = {}, {}, {}, {} + + for _, endpoint_info in ipairs(device.endpoints) do + local checked_device = utils.find_child(device, endpoint_info.endpoint_id) or device + if not devices_seen[checked_device.id] then + utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, + device.driver.subscribed_attributes, device.driver.subscribed_events + ) + devices_seen[checked_device.id] = true -- only loop through any device once + end + end + + -- If the type of battery support has not yet been determined, add the PowerSource AttributeList to the list of + -- subscribed attributes in order to determine which if any battery capability should be used. + if device:get_field(fields.profiling_data.BATTERY_SUPPORT) == nil then + local ib = im.InteractionInfoBlock(nil, clusters.PowerSource.ID, clusters.PowerSource.attributes.AttributeList.ID) + subscribe_request:with_info_block(ib) + attributes_seen[clusters.PowerSource.ID] = attributes_seen[clusters.PowerSource.ID] or {} + attributes_seen[clusters.PowerSource.ID][clusters.PowerSource.attributes.AttributeList.ID] = ib + end + + -- If the power topology of the device has not yet been determined, add the AvailableEndpoints (for SET topology) + -- or PartsList (for TREE topology) attributes to the list of subscribed attributes in order to map the device's electrical endpoints + -- to the proper device card(s). + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) == nil then + local electrical_sensor_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR, { with_info = true }) or {} + local endpoint_power_topology_cluster = utils.find_cluster_on_ep(electrical_sensor_eps[1], clusters.PowerTopology.ID) or {} + if clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.SET_TOPOLOGY, endpoint_power_topology_cluster.feature_map or 0) then + local ib = im.InteractionInfoBlock(nil, clusters.PowerTopology.ID, clusters.PowerTopology.attributes.AvailableEndpoints.ID) + subscribe_request:with_info_block(ib) + attributes_seen[clusters.PowerTopology.ID] = attributes_seen[clusters.PowerTopology.ID] or {} + attributes_seen[clusters.PowerTopology.ID][clusters.PowerTopology.attributes.AvailableEndpoints.ID] = ib + elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, endpoint_power_topology_cluster.feature_map or 0) then + local ib = im.InteractionInfoBlock(nil, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID) + subscribe_request:with_info_block(ib) + attributes_seen[clusters.Descriptor.ID] = attributes_seen[clusters.Descriptor.ID] or {} + attributes_seen[clusters.Descriptor.ID][clusters.Descriptor.attributes.PartsList.ID] = ib + end + end + + -- The refresh capability command handler in the lua libs uses this key to determine which attributes to read. Note + -- that only attributes_seen needs to be saved here, and not events_seen, since the refresh handler only checks + -- attributes and not events. + device:set_field(fields.SUBSCRIBED_ATTRIBUTES_KEY, attributes_seen) + + if #subscribe_request.info_blocks > 0 then + device:send(subscribe_request) + end +end + +return utils diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua new file mode 100644 index 0000000000..50713626f2 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua @@ -0,0 +1,497 @@ +-- Copyright © 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.generated.zap_clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local uint32 = require "st.matter.data_types.Uint32" + +-- Mock a 3-button device with temperature and humidity sensor +local aqara_mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("3-button-battery-temperature-humidity.yml"), + manufacturer_info = {vendor_id = 0x115F, product_id = 0x2004, product_name = "Aqara Climate Sensor W100"}, + matter_version = {hardware = 1, software = 1}, + label = "Climate Sensor W100", + device_id = "00000000-1111-2222-3333-000000000001", + 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.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1}, + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1}, + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_LONG_PRESS, + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_LONG_PRESS, + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_LONG_PRESS, + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY + } + }, + device_types = { + {device_type_id = 0x0011, device_type_revision = 1} + } + } + } +}) + +local function configure_buttons() + test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 3)}) + + test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 4)}) + + test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 5)}) +end + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(aqara_mock_device) + local cluster_subscribe_list = { + clusters.PowerSource.server.attributes.AttributeList, + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = cluster_subscribe_list[1]:subscribe(aqara_mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(aqara_mock_device)) + end + end + + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "init" }) + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "doConfigure" }) + aqara_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +local function update_profile() + test.socket.matter:__queue_receive({aqara_mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + aqara_mock_device, 6, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + configure_buttons() + aqara_mock_device:expect_metadata_update({ profile = "3-button-battery-temperature-humidity" }) +end + +test.register_coroutine_test( + "Temperature reports should generate correct messages", + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(aqara_mock_device, 1, 40*100) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Min and max temperature attributes set capability constraint", + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(aqara_mock_device, 1, 500) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(aqara_mock_device, 1, 4000) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 40.00 }, unit = "C" })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Relative humidity reports should generate correct messages", + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(aqara_mock_device, 2, 4049) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(aqara_mock_device, 2, 4050) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery percent reports should generate correct messages", + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(aqara_mock_device, 1, 150) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + update_profile() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on multi button", -- pushes should only be generated from multiPressComplete events + function () + update_profile() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a multi press on multi button", + function() + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 4, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report(aqara_mock_device, 4, {new_position = 1, current_number_of_presses_counted = 2}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report(aqara_mock_device, 4, {new_position = 0, total_number_of_presses_counted = 2, previous_position = 1}) + } + ) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", capabilities.button.button.double({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", capabilities.button.button.held({state_change = true}))) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on multi button", + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) + } + ) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", capabilities.button.button.held({state_change = true}))) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report(aqara_mock_device, 5, {previous_position = 0}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle double press", + function() + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report(aqara_mock_device, 3, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0}) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("button1", capabilities.button.button.double({state_change = true})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Receiving a max press attribute of 2 should emit correct event", + function() + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data(aqara_mock_device, 3, 2) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("button1", capabilities.button.supportedButtonValues({"pushed", "double", "held"}, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for emulated hold on short-release-only button", + function () + update_profile() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 4, {new_position = 1}) + } + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 4, {previous_position = 0}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Receiving a max press attribute of 3 should emit correct event", + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + aqara_mock_device, 5, 3 + ) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message( + "button3", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Receiving a max press attribute of 3 should emit correct event", + function () + update_profile() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + aqara_mock_device, 3, 7 + ) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message( + "button1", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua new file mode 100644 index 0000000000..dbda0c35fc --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua @@ -0,0 +1,279 @@ +-- Copyright © 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +test.add_package_capability("cubeAction.yml") +test.add_package_capability("cubeFace.yml") +local capabilities = require "st.capabilities" +local cubeAction = capabilities["stse.cubeAction"] +local cubeFace = capabilities["stse.cubeFace"] + +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +-- used in unit testing, since device.profile.id and args.old_st_store.profile.id are always the same +-- and this is to avoid the crash of the test case that occurs when try_update_metadata is performed in the device_init stage. +local TEST_CONFIGURE = "__test_configure" + +--mock the actual device1 +local mock_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("cube-t1-pro.yml"), + manufacturer_info = {vendor_id = 0x115f, product_id = 0x0000, product_name = "Aqara Cube T1 Pro"}, + label = "Aqara Cube", + device_id = "00000000-1111-2222-3333-000000000001", + 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 = 2, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} + }, + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + } + } +) + +--mock the actual device2 +local mock_device_exhausted = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("cube-t1-pro.yml"), + manufacturer_info = {vendor_id = 0x115f, product_id = 0x0000, product_name = "Aqara Cube T1 Pro"}, + label = "Aqara Cube", + device_id = "00000000-1111-2222-3333-000000000003", + 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 = 2, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} + }, + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 250, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 251, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + } + } +) + +-- add device for each mock device +local CLUSTER_SUBSCRIBE_LIST ={ + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, +} + +local function test_init() + local opts = { persist = true } + mock_device:set_field(TEST_CONFIGURE, true, opts) + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then + subscribe_request:merge(clus:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", cubeAction.cubeAction({value = "flipToSide1"})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", cubeFace.cubeFace({value = "face1Up"})) + ) +end + +test.set_test_init_function(test_init) + +local function test_init_exhausted() + local opts = { persist = true } + mock_device_exhausted:set_field(TEST_CONFIGURE, true, opts) + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device_exhausted) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then + subscribe_request:merge(clus:subscribe(mock_device_exhausted)) + end + end + test.socket.matter:__expect_send({mock_device_exhausted.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_exhausted) + test.socket.capability:__expect_send( + mock_device_exhausted:generate_test_message("main", cubeAction.cubeAction({value = "flipToSide1"})) + ) + test.socket.capability:__expect_send( + mock_device_exhausted:generate_test_message("main", cubeFace.cubeFace({value = "face1Up"})) + ) +end + +test.register_coroutine_test( + "Handle single press sequence when changing the device_lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.mock_devices_api._expected_device_updates[mock_device.device_id] = "00000000-1111-2222-3333-000000000001" + test.mock_devices_api._expected_device_updates[1] = {device_id = "00000000-1111-2222-3333-000000000001"} + test.mock_devices_api._expected_device_updates[1].metadata = {deviceId="00000000-1111-2222-3333-000000000001", profileReference="cube-t1-pro"} + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({value = "face1Up"})) + -- let the driver run + test.wait_for_events() + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 2, {new_position = 1} --move to position 1? + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", cubeAction.cubeAction({value = "flipToSide1"})) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", cubeFace.cubeFace({value = "face1Up"})) + ) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device, 2, 150 + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence in case of exhausted endpoint", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_exhausted.id, "added" }) + test.mock_devices_api._expected_device_updates[mock_device_exhausted.device_id] = "00000000-1111-2222-3333-000000000003" + test.mock_devices_api._expected_device_updates[1] = {device_id = "00000000-1111-2222-3333-000000000003"} + test.mock_devices_api._expected_device_updates[1].metadata = {deviceId="00000000-1111-2222-3333-000000000003", profileReference="cube-t1-pro"} + + test.socket.device_lifecycle:__queue_receive(mock_device_exhausted:generate_info_changed({value = "face1Up"})) + -- let the driver run + test.wait_for_events() + + test.socket.matter:__queue_receive( + { + mock_device_exhausted.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device_exhausted, 250, {new_position = 1} --move to position 1? + ) + } + ) + + test.socket.capability:__expect_send( + mock_device_exhausted:generate_test_message("main", cubeAction.cubeAction({value = "flipToSide1"})) + ) + + test.socket.capability:__expect_send( + mock_device_exhausted:generate_test_message("main", cubeFace.cubeFace({value = "face1Up"})) + ) + end, + { + test_init = test_init_exhausted, + min_api_version = 17 + } +) + +-- run the tests +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua new file mode 100644 index 0000000000..468232cbc3 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua @@ -0,0 +1,351 @@ +-- Copyright © 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local dkjson = require "dkjson" +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.PowerTopology = require "embedded_clusters.PowerTopology" +end + +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + +local aqara_parent_ep = 4 +local aqara_child1_ep = 1 +local aqara_child2_ep = 2 + +local aqara_mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("4-button.yml"), + manufacturer_info = {vendor_id = 0x115F, product_id = 0x1009, product_name = "Aqara Light Switch H2"}, + matter_version = {hardware = 1, software = 1}, + label = "Aqara Light Switch", + device_id = "00000000-1111-2222-3333-000000000001", + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 2 }, + {cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 5 }, + {cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 1 } -- NODE_TOPOLOGY + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1}, -- RootNode + {device_type_id = 0x0510, device_type_revision = 1} -- Electrical Sensor + } + }, + { + endpoint_id = aqara_child1_ep, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light + } + }, + { + endpoint_id = aqara_child2_ep, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light + } + }, + { + endpoint_id = aqara_parent_ep, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + +local aqara_child_profiles = { + [aqara_child1_ep] = t_utils.get_profile_definition("light-power-energy-powerConsumption.yml"), + [aqara_child2_ep] = t_utils.get_profile_definition("light-binary.yml"), +} + +local aqara_mock_children = {} +for i, endpoint in ipairs(aqara_mock_device.endpoints) do + if endpoint.endpoint_id == aqara_child1_ep or endpoint.endpoint_id == aqara_child2_ep then + local child_data = { + profile = aqara_child_profiles[endpoint.endpoint_id], + device_network_id = string.format("%s:%d", aqara_mock_device.id, endpoint.endpoint_id), + parent_device_id = aqara_mock_device.id, + parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) + } + aqara_mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) + end +end + +local cumulative_report_val_19 = { + energy = 19000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_29 = { + energy = 29000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_39 = { + energy = 39000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local function configure_buttons() + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button4", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) +end + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(aqara_mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(aqara_mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(aqara_mock_device)) + end + end + + -- Test added -> doConfigure logic + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "init" }) + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "doConfigure" }) + configure_buttons() + aqara_mock_device:expect_metadata_update({ profile = "4-button" }) + aqara_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + for _, child in pairs(aqara_mock_children) do + test.mock_device.add_test_device(child) + end + + aqara_mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Aqara Light Switch 1", + profile = "light-power-energy-powerConsumption", + parent_device_id = aqara_mock_device.id, + parent_assigned_child_key = string.format("%d", aqara_child1_ep) + }) + + aqara_mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Aqara Light Switch 2", + profile = "light-binary", + parent_device_id = aqara_mock_device.id, + parent_assigned_child_key = string.format("%d", aqara_child2_ep) + }) + + local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) + device_info_copy.profile.id = "4-button" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) + configure_buttons() + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Button/Switch device : button/switch capability should send the appropriate commands", + function() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 4, {new_position = 1}) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("button2", capabilities.button.button.pushed({state_change = true})) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(aqara_mock_device, aqara_child1_ep, true) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.switch.switch.on()) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(aqara_mock_device, aqara_child2_ep, true) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child2_ep]:generate_test_message("main", capabilities.switch.switch.on()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check Energy/Power Management and powerConsumptionReport", + function() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(aqara_mock_device, 0, 17000) + } + ) + + test.socket.capability:__expect_send( + -- when energy management is in the root endpoint, the event is sent to the first switch endpoint in CHILD_EDGE. + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.powerMeter.power({value = 17.0, unit="W"})) + ) + + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(aqara_mock_device, 0, cumulative_report_val_19) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 19.0 + })) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(aqara_mock_device, 0, cumulative_report_val_29) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.energyMeter.energy({ value = 29.0, unit = "Wh" })) + ) + + -- don't send a powerConsumptionReport event, 15 minutes have not passed since the last one. + + test.wait_for_events() + test.mock_time.advance_time(1500) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data( + aqara_mock_device, 0, cumulative_report_val_39 + ) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.energyMeter.energy({ value = 39.0, unit = "Wh" })) + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:40:00Z", + deltaEnergy = 20.0, + energy = 39.0 + })) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua new file mode 100644 index 0000000000..18c44f7b9c --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua @@ -0,0 +1,714 @@ +-- Copyright © 2024 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 uint32 = require "st.matter.data_types.Uint32" +local version = require "version" +local st_utils = require "st.utils" + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.PowerTopology = require "embedded_clusters.PowerTopology" +end + +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-level-power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 14, }, + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", feature_map = 0, }, + { cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", feature_map = 4, }, -- SET_TOPOLOGY + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 }, -- Electrical Sensor + } + }, + { + endpoint_id = 2, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0, }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + { device_type_id = 0x010B, device_type_revision = 1 }, -- Dimmable Plug In Unit + } + }, + { + endpoint_id = 3, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 14, }, + { cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", feature_map = 4, }, -- SET_TOPOLOGY + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 }, -- Electrical Sensor + } + }, + { + endpoint_id = 4, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0, }, + { cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + }, + device_types = { + { device_type_id = 0x010B, device_type_revision = 1 }, -- Dimmable Plug In Unit + } + }, + }, +}) + + +local mock_device_periodic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0, }, + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 10, }, + { cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", feature_map = 4, } -- SET_TOPOLOGY + }, + device_types = { + { device_type_id = 0x010A, device_type_revision = 1 }, -- OnOff Plug + { device_type_id = 0x0510, device_type_revision = 1 }, -- Electrical Sensor + } + }, + }, +}) + +local subscribed_attributes_periodic = { + clusters.OnOff.attributes.OnOff, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.PowerTopology.attributes.AvailableEndpoints, +} +local subscribed_attributes = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.PowerTopology.attributes.AvailableEndpoints, +} + +local cumulative_report_val_19 = { + energy = 19000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_29 = { + energy = 29000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_39 = { + energy = 39000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local periodic_report_val_23 = { + energy = 23000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("plug-level-energy-powerConsumption.yml"), + device_network_id = string.format("%s:%d", mock_device.id, 4), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", 4) +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) + + local subscribe_request = subscribed_attributes[1]:subscribe(mock_device) + for i, cluster in ipairs(subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) +end +test.set_test_init_function(test_init) + +local function test_init_periodic() + test.mock_device.add_test_device(mock_device_periodic) + local subscribe_request = subscribed_attributes_periodic[1]:subscribe(mock_device_periodic) + for i, cluster in ipairs(subscribed_attributes_periodic) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_periodic)) + end + end + test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) +end + +test.register_message_test( + "Active power measurement should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ElectricalPowerMeasurement.server.attributes.ActivePower:build_test_report_data(mock_device, 1, 17000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({value = 17.0, unit="W"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cumulative Energy measurement should generate correct messages", + function() + + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_19 + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 19.0 + })) + ) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_29 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 29.0, unit = "Wh" })) + ) + + test.wait_for_events() + test.mock_time.advance_time(1500) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_39 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 39.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:40:00Z", + deltaEnergy = 20.0, + energy = 39.0 + })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Periodic Energy as subordinate to Cumulative Energy measurement should not generate any messages", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device, 1, periodic_report_val_23 + ) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device, 1, periodic_report_val_23 + ) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Periodic Energy measurement should generate correct messages", + function() + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + test.socket.matter:__queue_receive( + { + mock_device_periodic.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device_periodic, 1, periodic_report_val_23 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.energyMeter.energy({value = 23.0, unit="Wh"})) + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 23.0 + })) + ) + test.socket.matter:__queue_receive( + { + mock_device_periodic.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device_periodic, 1, periodic_report_val_23 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.energyMeter.energy({value = 46.0, unit="Wh"})) + ) + test.wait_for_events() + test.mock_time.advance_time(2000) + test.socket.matter:__queue_receive( + { + mock_device_periodic.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device_periodic, 1, periodic_report_val_23 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.energyMeter.energy({value = 69.0, unit="Wh"})) + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:48:20Z", + deltaEnergy = 46.0, + energy = 69.0 + })) + ) + end, + { + test_init = test_init_periodic, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change on init for Electrical Sensor device type", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 2, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 4, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ mock_device.id, clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data(mock_device, 1, {uint32(2)})}) + test.socket.matter:__queue_receive({ mock_device.id, clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data(mock_device, 3, {uint32(4)})}) + mock_device:expect_metadata_update({ profile = "plug-level-power-energy-powerConsumption" }) + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "nil 2", + profile = "plug-level-energy-powerConsumption", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", 4) + }) + end, + { + test_init = test_init, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change on init for only Periodic Electrical Sensor device type", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_periodic.id, "doConfigure" }) + mock_device_periodic:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ mock_device_periodic.id, clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data(mock_device_periodic, 1, {uint32(1)})}) + mock_device_periodic:expect_metadata_update({ profile = "plug-energy-powerConsumption" }) + end, + { + test_init = test_init_periodic, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test resetEnergyMeter command on parent and child for CumulativeEnergyImported", + function() + + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + -- this block needs to run to set the requisite fields. It is tested on its own elsewhere + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 2, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 4, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ mock_device.id, clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data(mock_device, 1, {uint32(2)})}) + test.socket.matter:__queue_receive({ mock_device.id, clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data(mock_device, 3, {uint32(4)})}) + mock_device:expect_metadata_update({ profile = "plug-level-power-energy-powerConsumption" }) + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "nil 2", + profile = "plug-level-energy-powerConsumption", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", 4) + }) + -- end of block + + -- Initial Parent Energy Report + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_19 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 19.0 + })) + ) + + -- Initial Child Energy Report + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 3, cumulative_report_val_19 + ) + } + ) + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 19.0 + })) + ) + + test.wait_for_events() + test.mock_time.advance_time(1500) + + + -- Parent call to resetEnergyMeter + test.socket.capability:__queue_receive({mock_device.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {}}}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + ) + -- Child call to resetEnergyMeter + test.socket.capability:__queue_receive({mock_child.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {}}}) + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + ) + + test.wait_for_events() + + -- Second Child Energy Report + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 3, cumulative_report_val_39 + ) + } + ) + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 20.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:40:00Z", + deltaEnergy = 1.0, + energy = 20.0 + })) + ) + + -- Second Parent Energy Report + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_39 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 20.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:40:00Z", + deltaEnergy = 1.0, + energy = 20.0 + })) + ) + end, + { + test_init = test_init, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test resetEnergyMeter command on device for PeriodicEnergyImported", + function() + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + test.socket.matter:__queue_receive( + { + mock_device_periodic.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device_periodic, 1, periodic_report_val_23 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.energyMeter.energy({value = 23.0, unit="Wh"})) + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 23.0 + })) + ) + test.socket.matter:__queue_receive( + { + mock_device_periodic.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device_periodic, 1, periodic_report_val_23 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.energyMeter.energy({value = 46.0, unit="Wh"})) + ) + + test.wait_for_events() + test.mock_time.advance_time(2000) + + test.socket.capability:__queue_receive({mock_device_periodic.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {}}}) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + ) + + test.wait_for_events() + + test.socket.matter:__queue_receive( + { + mock_device_periodic.id, + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:build_test_report_data( + mock_device_periodic, 1, cumulative_report_val_19 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.energyMeter.energy({value = 19.0, unit="Wh"})) + ) + test.socket.capability:__expect_send( + mock_device_periodic:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:48:20Z", + deltaEnergy = 19.0, + energy = 19.0 + })) + ) + end, + { + test_init = test_init_periodic, + min_api_version = 17 + } +) + +test.register_message_test( + "Set level command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = {20,20} } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_cmd_id = "setLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 2, st_utils.round(20/100.0 * 254), 20, 0 ,0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 2) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 2, 50) + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switchLevel.level(20)) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 2, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua new file mode 100644 index 0000000000..2abfe6e9c5 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua @@ -0,0 +1,347 @@ +-- Copyright © 2025 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 uint32 = require "st.matter.data_types.Uint32" +local version = require "version" +local st_utils = require "st.utils" + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.PowerTopology = require "embedded_clusters.PowerTopology" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-level-power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 14, }, + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", feature_map = 0, }, + { cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", feature_map = 2, }, -- TREE_TOPOLOGY + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 }, -- Electrical Sensor + } + }, + { + endpoint_id = 2, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0, }, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + { device_type_id = 0x010B, device_type_revision = 1 }, -- OnOff Dimmable Plug + } + }, + { + endpoint_id = 3, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 14, }, + { cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", feature_map = 2, }, -- TREE_TOPOLOGY + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 }, -- Electrical Sensor + } + }, + { + endpoint_id = 4, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0, }, + { cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + }, + device_types = { + { device_type_id = 0x010B, device_type_revision = 1 }, -- OnOff Dimmable Plug + } + }, + }, +}) + +local subscribed_attributes = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.Descriptor.attributes.PartsList, +} + +local cumulative_report_val_19 = { + energy = 19000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_29 = { + energy = 29000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_39 = { + energy = 39000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local function test_init() + test.mock_device.add_test_device(mock_device) + local subscribe_request = subscribed_attributes[1]:subscribe(mock_device) + for i, cluster in ipairs(subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Active power measurement should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ElectricalPowerMeasurement.server.attributes.ActivePower:build_test_report_data(mock_device, 1, 17000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({value = 17.0, unit="W"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cumulative Energy measurement should generate correct messages", + function() + + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_19 + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 19.0 + })) + ) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_29 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 29.0, unit = "Wh" })) + ) + + test.wait_for_events() + test.mock_time.advance_time(1500) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_39 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 39.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:40:00Z", + deltaEnergy = 20.0, + energy = 39.0 + })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change on init for Electrical Sensor device type", + function() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 2, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 4, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ mock_device.id, clusters.Descriptor.attributes.PartsList:build_test_report_data(mock_device, 1, {uint32(2)})}) + test.socket.matter:__queue_receive({ mock_device.id, clusters.Descriptor.attributes.PartsList:build_test_report_data(mock_device, 3, {uint32(4)})}) + mock_device:expect_metadata_update({ profile = "plug-level-power-energy-powerConsumption" }) + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "nil 2", + profile = "plug-level-energy-powerConsumption", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", 4) + }) + end, + { + test_init = test_init, + min_api_version = 17 + } +) + +test.register_message_test( + "Set level command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = {20,20} } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_cmd_id = "setLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 2, st_utils.round(20/100.0 * 254), 20, 0 ,0) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 2) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 2, 50) + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switchLevel.level(20)) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 2, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua index def4530cdf..e90f5a70da 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua @@ -1,21 +1,11 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local data_types = require "st.matter.data_types" +local fields = require "switch_utils.fields" local clusters = require "st.matter.clusters" local cluster_base = require "st.matter.cluster_base" @@ -25,6 +15,28 @@ local PRIVATE_ATTR_ID_WATT = 0x130A000A local PRIVATE_ATTR_ID_WATT_ACCUMULATED = 0x130A000B local PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT = 0x130A000E +-- Helper function to add get_endpoints method to mock devices +local function add_get_endpoints_to_mock(device) + device.get_endpoints = function(self, cluster_id, opts) + opts = opts or {} + local eps = {} + for _, ep in ipairs(self.endpoints) do + for _, cluster in ipairs(ep.clusters or {}) do + if cluster.cluster_id == cluster_id then + -- Check feature_bitmap if specified + if opts.feature_bitmap == nil or + (cluster.feature_map and (cluster.feature_map & opts.feature_bitmap) == opts.feature_bitmap) then + table.insert(eps, ep.endpoint_id) + break + end + end + end + end + return eps + end + return device +end + local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("power-energy-powerConsumption.yml"), manufacturer_info = { @@ -63,6 +75,102 @@ local mock_device = test.mock_device.build_test_matter_device({ } } }) +add_get_endpoints_to_mock(mock_device) + +local mock_eve_device_using_electrical_sensor = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x130A, + product_id = 0x0050, + }, + 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.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, --u32 bitmap + }, + { + cluster_id = PRIVATE_CLUSTER_ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, --u32 bitmap + } + }, + device_types = { + { device_type_id = 0x010A, device_type_revision = 1 } -- On/Off Plug + } + }, + { + endpoint_id = 2, + clusters = { + { + cluster_id = clusters.ElectricalEnergyMeasurement.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY, + }, + { + cluster_id = clusters.PowerTopology.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, + } + }, + device_types = { + { device_type_id = fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR, device_type_revision = 1 } + } + } + } +}) +add_get_endpoints_to_mock(mock_eve_device_using_electrical_sensor) + +-- Mock device without Eve Private Cluster (should not match eve_energy sub-driver) +local mock_device_without_private_cluster = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-binary.yml"), + manufacturer_info = { + vendor_id = 0x130A, + product_id = 0x0051, + }, + 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.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, --u32 bitmap + } + }, + device_types = { + { device_type_id = 0x010A, device_type_revision = 1 } -- On/Off Plug + } + } + } +}) +add_get_endpoints_to_mock(mock_device_without_private_cluster) local function test_init() local cluster_subscribe_list = { @@ -80,6 +188,31 @@ local function test_init() end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Eve Energy sub-driver can_handle should return true for devices with Eve Private Cluster and no Electrical Sensor", + function() + local eve_energy_can_handle = require("sub_drivers.eve_energy.can_handle") + local result, sub_driver = eve_energy_can_handle(nil, nil, mock_device) + assert(result == true, "can_handle should return true for Eve device with private cluster and no electrical sensor") + assert(sub_driver ~= nil, "sub_driver should be returned") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Eve Energy sub-driver can_handle should return false for devices without Eve Private Cluster", + function() + local eve_energy_can_handle = require("sub_drivers.eve_energy.can_handle") + local result = eve_energy_can_handle(nil, nil, mock_device_without_private_cluster) + assert(result == false, "can_handle should return false for device without Eve Private Cluster") + end, + { + min_api_version = 17 + } +) + test.register_message_test( "On command should send the appropriate commands", { @@ -91,6 +224,14 @@ test.register_message_test( { capability = "switch", component = "main", command = "on", args = {} } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "matter", direction = "send", @@ -99,6 +240,9 @@ test.register_message_test( clusters.OnOff.server.commands.On(mock_device, 1) } } + }, + { + min_api_version = 17 } ) @@ -113,6 +257,14 @@ test.register_message_test( { capability = "switch", component = "main", command = "off", args = {} } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "matter", direction = "send", @@ -121,6 +273,9 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, 1) } } + }, + { + min_api_version = 17 } ) @@ -138,7 +293,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -148,12 +306,12 @@ test.register_coroutine_test( local poll_timer = mock_device:get_field("RECURRING_POLL_TIMER") assert(poll_timer ~= nil, "poll_timer should exist") - local report_poll_timer = mock_device:get_field("RECURRING_REPORT_POLL_TIMER") - assert(report_poll_timer ~= nil, "report_poll_timer should exist") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "removed" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -184,7 +342,8 @@ test.register_coroutine_test( test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(60, "interval", "create_poll_schedule") - end + end, + min_api_version = 17 } ) @@ -201,7 +360,10 @@ test.register_coroutine_test( refresh_response:merge(cluster_base.read(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT_ACCUMULATED, nil)) test.socket.matter:__expect_send({ mock_device.id, refresh_response}) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -225,7 +387,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -249,7 +414,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -275,8 +443,20 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 50000, unit = "Wh" })) ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T16:39:59Z", + deltaEnergy = 0.0, + energy = 50000, + })) + ) + test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -300,18 +480,11 @@ test.register_coroutine_test( test.socket.matter:__expect_send({ mock_device.id, cluster_base.write(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT, nil, data) }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.powerConsumptionReport.powerConsumption({ - energy = 0, - deltaEnergy = 0, - start = "1970-01-01T00:00:00Z", - ["end"] = "2001-01-01T00:00:00Z" - })) - ) - test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -335,7 +508,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -352,23 +528,6 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 0, unit = "W" })) ) - - test.wait_for_events() - -- after 15 minutes, the device should still report power consumption even when off - test.mock_time.advance_time(60 * 15) -- Ensure that the timer created in create_poll_schedule triggers - - - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", - capabilities.powerConsumptionReport.powerConsumption({ - energy = 0, - deltaEnergy = 0.0, - start = "1970-01-01T00:00:00Z", - ["end"] = "1970-01-01T00:14:59Z" - })) - ) - - test.wait_for_events() end, { test_init = function() @@ -386,8 +545,106 @@ test.register_coroutine_test( test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(60 * 15, "interval", "create_poll_report_schedule") test.timer.__create_and_queue_test_time_advance_timer(60, "interval", "create_poll_schedule") - end + end, + min_api_version = 17 } ) +local cumulative_report_val_19 = { + energy = 19000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_29 = { + energy = 29000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +local cumulative_report_val_39 = { + energy = 39000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, + apparent_energy = 0, + reactive_energy = 0 +} + +test.register_coroutine_test( + "Cumulative Energy measurement should generate correct messages", + function() + mock_device = mock_eve_device_using_electrical_sensor + + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_19 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 19.0 + })) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_29 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 29.0, unit = "Wh" })) + ) + test.wait_for_events() + test.mock_time.advance_time(1500) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:build_test_report_data( + mock_device, 1, cumulative_report_val_39 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 39.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:40:00Z", + deltaEnergy = 20.0, + energy = 39.0 + })) + ) + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_eve_device_using_electrical_sensor) + end, + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua new file mode 100644 index 0000000000..96a684529d --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -0,0 +1,1780 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local data_types = require "st.matter.data_types" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local descriptor = require "embedded_clusters.Descriptor" +local version = require "version" + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" +local PARENT_ID = "PARENT_ID" +local BUTTON_EPS = "__button_eps" + +local function create_parent_device(product_id) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 4x Button", + profile = t_utils.get_profile_definition("matter-bridge.yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = product_id, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } + }, + device_types = { { device_type_id = 0x000E, device_type_revision = 1 } } -- AggregateNode + }, + { + endpoint_id = 2, + clusters = { + { + cluster_id = clusters.Descriptor.ID, + cluster_type = "SERVER", + cluster_revision = 1, + } + }, + device_types = { { device_type_id = 0x0039, device_type_revision = 1 } } -- BridgedNode + } + }, + }) +end + +local function create_matter_device(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 4x Button", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + }, + }) +end + +-- Create Hager 2G Relay device with endpoints 3 & 4 (OnOff clusters) +local function create_hager_2g_relay(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 2G Relay", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + { + endpoint_id = 4, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + +-- Create Hager Dimmer device with ONLY dimmable endpoint (3) - no button endpoints +local function create_hager_dimmer_device_1g(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 4, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + } + }) +end + +local function create_hager_dimmer_device_2g(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + } + }) +end + +local function create_matter_device_with_window(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 matter device with Window Covering", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.WindowCovering.types.Feature.LIFT | + clusters.WindowCovering.types.Feature.POSITION_AWARE_LIFT | + clusters.WindowCovering.types.Feature.ABSOLUTE_POSITION, + attributes = { + [clusters.WindowCovering.attributes.OperationalStatus.ID] = 0x00, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = 0x0000, + } + } + }, + device_types = { { device_type_id = 0x0202, device_type_revision = 1 } } + }, + } + }) +end + +local function create_hager_pir_device(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 PIR with Buttons and Motion/Illuminance/Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0007, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + { + endpoint_id = 6, + clusters = { + { + cluster_id = clusters.OccupancySensing.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OccupancySensing.attributes.Occupancy.ID] = 0 + } + } + }, + device_types = { { device_type_id = 0x0107, device_type_revision = 1 } } + }, + { + endpoint_id = 5, + clusters = { + { + cluster_id = clusters.IlluminanceMeasurement.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = 0 + } + } + }, + device_types = { { device_type_id = 0x0106, device_type_revision = 1 } } + }, + } + }) +end + +local function add_parent_device(parent) + test.mock_device.add_test_device(parent) + test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) + test.mock_time.advance_time(5) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 2, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + +local function subscribe_switch_events(parent, button_eps) + for _, ep in ipairs(button_eps) do + test.socket.matter:__expect_send({ + parent.id, + clusters.Switch.events.MultiPressComplete:subscribe(parent, ep) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Switch.events.LongPress:subscribe(parent, ep) + }) + end +end + +local function add_matter_device(matter_device, parent, expected_profile_change) + test.mock_device.add_test_device(matter_device) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "doConfigure" }) + matter_device:expect_metadata_update({ profile = expected_profile_change }) + matter_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "init" }) + + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function add_switch_matter_device(matter_device, parent) + test.mock_device.add_test_device(matter_device) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "doConfigure" }) + matter_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "init" }) + + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function button_supported_values (matter_device) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) +end + +local function initiate_info_changed(device, profile) + test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = { id = profile } })) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.mock_time.advance_time(2) +end + +local function configure_parent(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + test.socket.matter:__expect_send({ + device.id, + cluster_base.subscribe(device, 2, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.mock_time.advance_time(3) +end + +local parent = create_parent_device(0x0006) -- 2G button product +local parent_1g = create_parent_device(0x0005) -- 1G button product +local parent_pir = create_parent_device(0x0007) -- PIR product + +local function test_init() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test("Test: 4-Button Device Detection - Profile Changes from matter-bridge to 4-button When Four Button Endpoints Present", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local matter_device = create_matter_device("matter-bridge", parent) + add_matter_device(matter_device, parent, "4-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + + local matter_device_parent_id = matter_device:get_field(PARENT_ID) + local matter_device_id = matter_device:get_field(MATTER_DEVICE_ID) + local parent_id = parent:get_field(PARENT_ID) + local parent_matter_device_id = parent:get_field(MATTER_DEVICE_ID) + + assert(matter_device_parent_id == parent.id, "link_host_and_subhub 1/4") + assert(matter_device_id == matter_device.id, "link_host_and_subhub 2/4") + assert(parent_id == parent.id, "link_host_and_subhub 3/4") + assert(parent_matter_device_id == matter_device.id, "link_host_and_subhub 4/4") + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x08) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x09) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0A) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0B) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 10, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 11, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + +end) + +test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press, and Held Events on 4-Button Device", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local matter_device = create_matter_device("4-button", parent) + add_matter_device(matter_device, parent, "4-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x08) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x09) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0A) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0B) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 10, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 11, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.wait_for_events() + + initiate_info_changed(matter_device, "4-button") + subscribe_switch_events(parent, { 8, 9, 10, 11 }) + button_supported_values(matter_device) + test.wait_for_events() + + --Test single press (pushed) on endpoint 8 (button1) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 -- Single press + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) + + --Test double press on endpoint 8 (button1) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 9 (button2) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 10 (button3) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 11 (button4) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.held({ state_change = true }))) +end) + +test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 15) and OnOff (Type 256) Device Types with Child Creation", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local matter_device = create_matter_device("4-button", parent) + add_matter_device(matter_device, parent, "4-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(9), + data_types.Uint16(8), + data_types.Uint16(6), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 6) + }) + + test.wait_for_events() + + -- Receive DeviceTypeList report with device type 15 (button) for endpoint 8 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- Receive DeviceTypeList report with device type 256 (OnOff) for endpoint 6 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 6, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "6" + }) + test.wait_for_events() + initiate_info_changed(matter_device, "2-button") + subscribe_switch_events(parent, { 8, 9 }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + + test.wait_for_events() + + local child_switch = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-binary.yml"), + device_network_id = string.format("%s:6", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "6" + }) + test.wait_for_events() + initiate_info_changed(child_switch, "light-binary") + + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.attributes.OnOff:subscribe(parent, nil) + }) +end) + +test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-binary and 2-button Based On Endpoint Availability", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + add_parent_device(parent) + local relay = create_hager_2g_relay("light-binary", parent) + add_switch_matter_device(relay, parent) + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + -- Scenario 1: EP3 + EP4 present → light-binary profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + -- + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 3) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 4) + }) + + relay:expect_metadata_update({ profile = "light-binary" }) + relay:expect_metadata_update({ profile = "light-binary" }) + test.wait_for_events() + -- Both endpoints are device type 256 (OnOff) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + local child_switch = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-binary.yml"), + device_network_id = string.format("%s:4", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + + initiate_info_changed(relay, "light-binary") + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.attributes.OnOff:subscribe(parent, nil) + }) + + initiate_info_changed(child_switch, "light-binary") + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.attributes.OnOff:subscribe(parent, nil) + }) + + --Scenario 2: EP4 removed → profile changes to 2-button, child created for EP3 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + + relay:expect_metadata_update({ profile = "2-button" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- Scenario 3: EP4 reappears → profile changes back to light-binary + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 4) + }) + relay:expect_metadata_update({ profile = "light-binary" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + + -- Scenario 4: EP3 removed → profile changes to 2-button, child created for EP4 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(4), + data_types.Uint16(12), + data_types.Uint16(13), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 13) + }) + + relay:expect_metadata_update({ profile = "2-button" }) + + -- Scenario 5: EP3 and EP4 removed → 4-button profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 10) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 11) + }) + relay:expect_metadata_update({ profile = "4-button" }) + + -- Scenario 6: Only EP4 present → 2-button profile, child created + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(4), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + relay:expect_metadata_update({ profile = "2-button" }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 4) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) +end) + +test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable Endpoint with Button Support", function() + test.socket.matter:__set_channel_ordering("relaxed") + + add_parent_device(parent) + local dimmer = create_hager_dimmer_device_2g("2-button", parent) + add_matter_device(dimmer, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + --Scenario 1: 2 button endpoints (8, 9) detected → 2-button profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(3) + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 3) + }) + dimmer:expect_metadata_update({ profile = "2-button" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 3, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-level", + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + + -- Create mock child device for EP3 + local child_dimmer = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:3", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + initiate_info_changed(child_dimmer, "light-level") + + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switch", "on") + + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.server.commands.On(child_dimmer, 3) + }) + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 20 } } }) + + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switchLevel", "setLevel") + + test.socket.matter:__expect_send({ + parent.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(child_dimmer, 3, 51, nil, 0, 0) + }) + +end) + +test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Update to light-level", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local dimmer = create_hager_dimmer_device_2g("2-button", parent) + add_matter_device(dimmer, parent, "2-button") + test.wait_for_events() + configure_parent(parent) +end) + +test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control Capabilities", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + add_parent_device(parent_1g) + local dimmer = create_hager_dimmer_device_1g("light-level", parent_1g) + add_matter_device(dimmer, parent_1g, "light-level") + test.wait_for_events() + configure_parent(parent_1g) + + -- Send dimmable endpoint 4 detection (device type 257) to trigger profile change and subscriptions + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(4), + })) + }) + + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 4) + }) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 4, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + ----subscriptions from device_type_handler + + initiate_info_changed(dimmer, "light-level") + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + test.socket.capability:__queue_receive({ dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + dimmer.expect_native_cmd_handler_registration(dimmer, "switch", "on") + + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.OnOff.server.commands.On(dimmer, 4) + }) + test.socket.capability:__queue_receive({ dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 20 } } }) + + dimmer.expect_native_cmd_handler_registration(dimmer, "switchLevel", "setLevel") + + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(dimmer, 4, 51, nil, 0, 0) + }) +end) + +test.register_coroutine_test("Test: PIR Device - Initialization with Motion and Illuminance Capabilities", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_pir) + local pir_device = create_hager_pir_device("motion-illuminance", parent_pir) + add_matter_device(pir_device, parent_pir, "motion-illuminance") + test.wait_for_events() + configure_parent(parent_pir) + +end) + +test.register_coroutine_test("Test: PIR Device - Complete Functionality with Motion, Illuminance, and Dimmer Support", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_pir) + local pir_device = create_hager_pir_device("motion-illuminance", parent_pir) + add_matter_device(pir_device, parent_pir, "motion-illuminance") + test.wait_for_events() + + configure_parent(parent_pir) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_pir, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + data_types.Uint16(5) + + })) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_pir, 3) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_pir, 4) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_pir, 5) + }) + + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 4, data_types.Array { + { + device_type = data_types.Uint32(263), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 5, data_types.Array { + { + device_type = data_types.Uint32(262), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 3, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + parent_pir:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-level", + parent_device_id = parent_pir.id, + parent_assigned_child_key = "3" + }) + test.wait_for_events() + initiate_info_changed(pir_device, "motion-illuminance") + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil) + }) + + local child_dimmer = test.mock_device.build_test_child_device({ + label = "Hager G2 4x Button 1", + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:3", parent_pir.id), + parent_device_id = parent_pir.id, + parent_assigned_child_key = "3" + }) + + test.mock_device.add_test_device(child_dimmer) + initiate_info_changed(child_dimmer, "light-level") + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + test.wait_for_events() + --Verify motion detected event + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(parent_pir, 6, 1) + }) + + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + + -- Verify illuminance measurement event + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(parent_pir, 5, 21370) + }) + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(137))) + + -- Send on command to OnOff endpoint (dimmer) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switch", "on") + + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.OnOff.commands.On(parent_pir, 3) + }) + -- Verify on state via attribute report + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(parent_pir, 3, true) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) + parent_pir.expect_native_attr_handler_registration(parent_pir, "switch", "switch") + + -- Set dimmer level to 50% + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.LevelControl.commands.MoveToLevelWithOnOff(parent_pir, 3, 127, nil, 0, 0) + }) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switchLevel", "setLevel") + + -- Verify level via attribute report + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(parent_pir, 3, 127) + }) + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(50))) + parent_pir.expect_native_attr_handler_registration(parent_pir, "switchLevel", "level") +end) + +test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile with Window Covering Child Device", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + + -- DeviceTypeList report for window covering endpoint (type 514 = Window Covering Device) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = parent.id, + parent_assigned_child_key = "12" + }) + + local child_wc = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:12", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "12" + }) + + test.mock_device.add_test_device(child_wc) + initiate_info_changed(child_wc, "window-covering") + + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + + -- Open command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "open", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.UpOrOpen(parent, 12) }) + test.wait_for_events() + + -- Verify open state via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x01) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.opening())) + + + -- Close command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "close", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.DownOrClose(parent, 12) }) + + -- Verify close state via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x02) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closing())) + + -- Pause command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "pause", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.StopMotion(parent, 12) }) + test.wait_for_events() + + -- Set shade level to 50% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 } } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 5000, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 5000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) + + + -- Set shade level to 100% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 } } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 0, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 0) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.open())) + + -- Set shade level to 0% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 0 } } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 10000, nil, 0, 0) }) + + -- Verify shade level via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 10000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) + +end) + +test.register_coroutine_test("Test: Window Covering - Preference Changes for Reverse Polarity and Preset Position", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + -- + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + test.wait_for_events() + + -- DeviceTypeList reports for button endpoints (type 15 = Generic Switch) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- DeviceTypeList report for window covering endpoint (type 514 = Window Covering Device) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = parent.id, + parent_assigned_child_key = "12" + }) + + local child_wc = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:12", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "false" } })) + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + local reverse_preference_set = child_wc.preferences.reverse + assert(reverse_preference_set == "true", "reverse_preference_set is True") + -- + --Send open command - with reverse_polarity true, this should send DownOrClose + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "open", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.DownOrClose(parent, 12) }) + + -- Send close command - with reverse_polarity true, this should send UpOrOpen + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "close", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.UpOrOpen(parent, 12) }) + + -- Send Pause command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "pause", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.StopMotion(parent, 12) }) + + -- Position preset testing + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { presetPosition = "50" } })) + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { presetPosition = "20" } })) + + test.wait_for_events() + + local PRESET_LEVEL_KEY = child_wc.preferences.presetPosition + assert(PRESET_LEVEL_KEY == "20", " presetPosition is set to 20") + + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } }) + test.socket.matter:__expect_send( + { parent.id, clusters.WindowCovering.server.commands.GoToLiftPercentage(parent, 12, 8000) } + ) + + test.wait_for_events() + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + test.wait_for_events() + + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.mock_time.advance_time(5) + local BUTTON_EPS_FIELD = parent:get_field(BUTTON_EPS) + assert(BUTTON_EPS_FIELD[1] == 8) + assert(BUTTON_EPS_FIELD[2] == 9) + + test.wait_for_events() + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + child_wc:expect_metadata_update({ profile = "window-covering" }) + initiate_info_changed(child_wc, "window-covering") + + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua b/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua new file mode 100644 index 0000000000..be28bd040a --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua @@ -0,0 +1,948 @@ +-- 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 capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local mock_ikea_scroll = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("ikea-scroll.yml"), + manufacturer_info = {vendor_id = 0x117C, product_id = 0x8000, product_name = "Ikea Scroll"}, + label = "Ikea Scroll", + 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.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 2, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 3, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER"}, + }, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 4, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 5, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 6, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER"}, + }, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 7, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 8, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 9, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER"}, + }, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + } +}) + +local ENDPOINTS_PUSH = { 3, 6, 9 } +local ENDPOINTS_SCROLL = {1, 2, 4, 5, 7, 8} + +-- the ikea scroll subdriver has overriden subscribe behavior +local function ikea_scroll_subscribe() + local CLUSTER_SUBSCRIBE_LIST_PUSH ={ + clusters.Switch.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.MultiPressComplete, + } + local CLUSTER_SUBSCRIBE_LIST_SCROLL = { + clusters.Switch.events.InitialPress, + clusters.Switch.server.events.MultiPressOngoing, + clusters.Switch.server.events.MultiPressComplete, + } + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_PUSH[1]:subscribe(mock_ikea_scroll, ENDPOINTS_PUSH[1]) + for _, ep_press in ipairs(ENDPOINTS_PUSH) do + for _, event in ipairs(CLUSTER_SUBSCRIBE_LIST_PUSH) do + subscribe_request:merge(event:subscribe(mock_ikea_scroll, ep_press)) + end + end + for _, ep_press in ipairs(ENDPOINTS_SCROLL) do + for _, event in ipairs(CLUSTER_SUBSCRIBE_LIST_SCROLL) do + subscribe_request:merge(event:subscribe(mock_ikea_scroll, ep_press)) + end + end + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_ikea_scroll, 0)) + return subscribe_request +end + +local function expect_configure_buttons() + test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 3)}) + test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 6)}) + test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 9)}) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}}))) +end + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_ikea_scroll) + local subscribe_request = ikea_scroll_subscribe() + + test.socket.device_lifecycle:__queue_receive({ mock_ikea_scroll.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_ikea_scroll.id, "init" }) + test.socket.matter:__expect_send({mock_ikea_scroll.id, subscribe_request}) + + mock_ikea_scroll:expect_metadata_update({ profile = "ikea-scroll" }) + mock_ikea_scroll:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + expect_configure_buttons() + test.socket.device_lifecycle:__queue_receive({ mock_ikea_scroll.id, "doConfigure" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Ensure Ikea Scroll Button initialization works as expected", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_ikea_scroll, 3, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_ikea_scroll, 6, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_ikea_scroll, 9, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll Positive rotateAmount events on main are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll Negative rotateAmount events on main are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll Positive rotateAmount events on group2 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(18, {state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll Negative rotateAmount events on group2 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[4], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[4], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[4], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(-18, {state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll Positive rotateAmount events on group3 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(18, {state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll Negative rotateAmount events on group3 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[6], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[6], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[6], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(-18, {state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll Long Press Push events on main are handled correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[1], {new_position = 1} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[1], {new_position = 1} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.button.button.held({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ikea Scroll MultiPressComplete Push events on group2 are handled correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[2], {new_position = 1} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[2], {total_number_of_presses_counted = 1, previous_position = 0} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.button.button.pushed({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.capability:__queue_receive( + {mock_ikea_scroll.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + ) + local read_request = clusters.PowerSource.attributes.BatPercentRemaining:read(mock_ikea_scroll, 0) + test.socket.matter:__expect_send({mock_ikea_scroll.id, read_request}) + test.wait_for_events() + end +) + +-- Coroutine tests for sensitivity preference logic. +-- KNOB_SENSITIVITY_FACTORS = {0.5, 1.0, 2.0}, indexed by the preference value. +-- nil preference falls back to 1.0 (tested by the message tests above). + +test.register_coroutine_test( + "Ikea Scroll low sensitivity (factor 0.5) scales rotateAmount correctly on main", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup1 = "0" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 0.5) = 6 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(6, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 0.5) = 9 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(9, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ikea Scroll high sensitivity (factor 2.0) scales rotateAmount correctly on main", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup1 = "2" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 2.0) = 24 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(24, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 2.0) = 36 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(36, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ikea Scroll high sensitivity (factor 2.0) scales rotateAmount correctly on group2", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup2 = "2" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 2.0) = 24 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(24, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 2.0) = 36 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(36, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ikea Scroll low sensitivity (factor 0.5) scales rotateAmount correctly on group3", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup3 = "0" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 0.5) = 6 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(6, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 0.5) = 9 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(9, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua b/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua index 657f37cb43..b22498eaf7 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua @@ -1,25 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" -local TRANSITION_TIME = 0 -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("light-color-level-illuminance-motion.yml"), @@ -50,7 +36,7 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} }, device_types = { - {device_type_id = 0x010C, device_type_revision = 1} -- ColorTemperatureLight + {device_type_id = 0x010D, device_type_revision = 1} -- Extended Color Light } }, { @@ -74,8 +60,28 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) - local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local switch_fields = require "switch_utils.fields" + +test.register_coroutine_test( + "doConfigure lifecycle event should not re-configure the device profile", + function () + mock_device:set_field(switch_fields.profiling_data.BATTERY_SUPPORT, false, {persist = true}) + mock_device:set_field(switch_fields.profiling_data.POWER_TOPOLOGY, false, {persist = true}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "init should cause device to subscribe to all appropriate clusters", function() local cluster_subscribe_list = { clusters.OnOff.attributes.OnOff, clusters.LevelControl.attributes.CurrentLevel, @@ -85,456 +91,161 @@ local function test_init() clusters.ColorControl.attributes.CurrentSaturation, clusters.ColorControl.attributes.CurrentX, clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, clusters.ColorControl.attributes.ColorTemperatureMireds, clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, clusters.IlluminanceMeasurement.attributes.MeasuredValue, clusters.OccupancySensing.attributes.Occupancy } - test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) for i, cluster in ipairs(cluster_subscribe_list) do if i > 1 then subscribe_request:merge(cluster:subscribe(mock_device)) end end - 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_message_test( - "On command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, 1) - } - } - } + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + end, + { + min_api_version = 17 + } ) test.register_message_test( - "Off command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "off", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.Off(mock_device, 1) - } - } - } -) - -test.register_message_test( - "Set level command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switchLevel", component = "main", command = "setLevel", args = {20,20} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 1, math.floor(20/100.0 * 254), 20, 0 ,0) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(20)) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - } - } -) - -test.register_message_test( - "Current level reports should generate appropriate events", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) - }, - } -) - -local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) -local sat = math.floor((50 * 0xFE) / 100.0 + 0.5) - -test.register_message_test( - "Set color command should send huesat commands when supported", + "Illuminance reports should generate correct messages", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) - } - }, - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) + clusters.IlluminanceMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 2, 21370) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) + message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } - } -) - -test.register_message_test( - "Set Hue command should send MoveToHue", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHue(mock_device, 1, hue, 0, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - } -) - -test.register_message_test( - "Set Saturation command should send MoveToSaturation", + }, { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToSaturation(mock_device, 1, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, + min_api_version = 17 } ) test.register_message_test( - "Set color temperature should send the appropriate commands", + "Occupancy reports should generate correct messages", { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, 1) - } - }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 556) + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 1) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, - } -) - -test.register_message_test( - "X and Y color values should report hue and saturation once both have been received", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) - } + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 0) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(72)) + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } - } -) - -test.register_message_test( - "X and Y color values have 0 value", + }, { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 0) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 0) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(33)) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(100)) - } + min_api_version = 17 } ) +local generic_manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 } +local generic_matter_version = { hardware = 1, software = 1 } +local root_endpoint = { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } +} -test.register_message_test( - "Y and X color values should report hue and saturation once both have been received", - { +local mock_device_light_level_motion = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-level-motion.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = 0x0101, device_type_revision = 1} -- Dimmable Light } }, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + endpoint_id = 2, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(72)) } } -) +}) -test.register_message_test( - "Do not report when receiving a color temperature of 0 mireds", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 0) - } - } - } -) +local function test_init_light_level_motion() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_light_level_motion) +end -test.register_message_test( - "Illuminance reports should generate correct messages", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.IlluminanceMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 2, 21370) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) +test.register_coroutine_test( + "Test init and doConfigure for Dimmable Light device type with Occupancy Sensor", + function() + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.OccupancySensing.attributes.Occupancy } - } -) + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_light_level_motion) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_light_level_motion)) + end + end -test.register_message_test( - "Occupancy reports should generate correct messages", + test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "init" }) + test.socket.matter:__expect_send({mock_device_light_level_motion.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_light_level_motion.id, + clusters.LevelControl.attributes.Options:write(mock_device_light_level_motion, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_light_level_motion:expect_metadata_update({ profile = "light-level-motion" }) + mock_device_light_level_motion:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 1) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 0) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + test_init = test_init_light_level_motion, + min_api_version = 17 } ) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua index d337e94b49..6cb679796d 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -70,16 +59,106 @@ local mock_bridge = test.mock_device.build_test_matter_device({ } }) +local mock_basic_bridge = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-bridge.yml"), + matter_version = { hardware = 1, software = 1 }, + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x000E, device_type_revision = 1 } -- Aggregator + } + } + } +}) + local function test_init_mock_bridge() - test.socket.matter:__set_channel_ordering("relaxed") test.mock_device.add_test_device(mock_bridge) + test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "doConfigure" }) + mock_bridge:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_mock_basic_bridge() + test.mock_device.add_test_device(mock_basic_bridge) + test.socket.device_lifecycle:__queue_receive({ mock_basic_bridge.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_basic_bridge.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_basic_bridge.id, "doConfigure" }) + mock_basic_bridge:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.register_coroutine_test( "Profile should not change for devices with aggregator device type (bridges)", function() end, - { test_init = test_init_mock_bridge } + { + test_init = test_init_mock_bridge, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Camera reprofiling should happen after software version change when camera endpoint appears", + function() + local updated_endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x000E, device_type_revision = 1 } -- Aggregator + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, + cluster_type = "SERVER" + }, + { cluster_id = clusters.PushAvStreamTransport.ID, cluster_type = "SERVER" } + }, + device_types = { + { device_type_id = 0x0142, device_type_revision = 1 } -- Camera + } + } + } + + test.socket.device_lifecycle:__queue_receive( + mock_basic_bridge:generate_info_changed({ + matter_version = { hardware = 1, software = 2 }, + endpoints = updated_endpoints + }) + ) + + mock_basic_bridge:expect_metadata_update({ + profile = "camera", + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings" + } + } + } + }) + end, + { + test_init = test_init_mock_basic_bridge, + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua new file mode 100644 index 0000000000..061f2e2f47 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua @@ -0,0 +1,601 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local button_attr = capabilities.button.button +local uint32 = require "st.matter.data_types.Uint32" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" +local st_utils = require "st.utils" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("button.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + 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.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER", + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + + +local expected_component_to_endpoint_map = { + ["main"] = 1 +} +local expected_initial_press_only_state = true + +local function expect_configure_button(device) + test.socket.capability:__expect_send(device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) +end + +local function test_init_for_lifecycle_tests() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end + +local function test_init_for_message_tests() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, expected_component_to_endpoint_map, { persist = true }) + switch_utils.set_field_for_endpoint(mock_device, fields.INITIAL_PRESS_ONLY, 1, expected_initial_press_only_state, { persist = true }) +end +test.set_test_init_function(test_init_for_message_tests) + +test.register_coroutine_test( + "Handle initial init lifecycle event", + function () + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.wait_for_events() + assert(mock_device:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as not needing battery support") + assert(mock_device:get_field(fields.profiling_data.POWER_TOPOLOGY) == false, "Device should be marked as not needing to configure power topology") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle initial doConfigure lifecycle event, where profiling should be skipped", + function () + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + expect_configure_button(mock_device) + mock_device:expect_metadata_update({ profile = "button" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + assert(switch_utils.deep_equals(st_utils.deep_copy(mock_device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP)), expected_component_to_endpoint_map), "Component to endpoint map should be set in doConfigure") + assert(switch_utils.get_field_for_endpoint(mock_device, fields.INITIAL_PRESS_ONLY, 1) == expected_initial_press_only_state, "InitialPressOnly should be true") + assert(switch_utils.get_field_for_endpoint(mock_device, fields.SUPPORTS_MULTI_PRESS, 1) == nil, "MultiPress should be nil") + assert(switch_utils.get_field_for_endpoint(mock_device, fields.EMULATE_HELD, 1) == nil, "EmulateHeld should be nil") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 17 + } +) + +test.register_message_test( + "Handle single press sequence, no hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} --move to position 1 + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle single press sequence, with hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle release after short press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 1, {previous_position = 1} + ) + } + }, + { -- this is a double event because the test device in this test shouldn't support the above event + -- but we handle it anyway + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle release after long press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 1, {previous_position = 1} + ) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 2 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 1, 2 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 3 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 1, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 1, 7 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle double press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle multi press for 4 times", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 1, previous_position = 0} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See the multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 4, previous_position = 0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +local mock_device_battery = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("button-battery.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + 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.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER", + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.Feature.BATTERY + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + +local function test_init_battery() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_battery) +end + +local function test_init_profile_change_with_battery() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_battery) + -- by this point, init will have run and this will have been set to false + mock_device_battery:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) +end + +test.register_coroutine_test( + "Handle initial init lifecycle event for device with battery", + function () + local CLUSTER_SUBSCRIBE_LIST_BATTERY = { + clusters.PowerSource.server.attributes.AttributeList, + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_BATTERY[1]:subscribe(mock_device_battery) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_BATTERY) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + assert(mock_device_battery:get_field(fields.profiling_data.BATTERY_SUPPORT) == nil, "Device should not have specified battery support yet") + assert(mock_device_battery:get_field(fields.profiling_data.POWER_TOPOLOGY) == nil, "Device should be marked as not needing to configure power topology") + end, + { + test_init = test_init_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Following init events, after battery has been configured, device should not create subscriptions to AttributeList", + function() + mock_device_battery:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist = true}) + test.wait_for_events() + local CLUSTER_SUBSCRIBE_LIST_BATTERY = { + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_BATTERY[1]:subscribe(mock_device_battery) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_BATTERY) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle initial doConfigure lifecycle event for device with battery, where profiling should be skipped", + function () + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PowerSource AttributeList with BatPercentRemaining update should trigger profile update to button-battery", + function() + test.socket.matter:__queue_receive({mock_device_battery.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device_battery, 1, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + expect_configure_button(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "button-battery" }) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PowerSource AttributeList with BatChargeLevel update should trigger profile update to button-batteryLevel", + function() + test.socket.matter:__queue_receive({mock_device_battery.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device_battery, 1, {uint32(clusters.PowerSource.attributes.BatChargeLevel.ID)} + )}) + expect_configure_button(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "button-batteryLevel" }) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PowerSource AttributeList with no attributes update should set battery support to false and trigger profile update to button", + function() + test.socket.matter:__queue_receive( + { + mock_device_battery.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_battery, 1, {uint32(2)}) + } + ) + expect_configure_button(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "button" }) + test.wait_for_events() + assert(mock_device_battery:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as not supporting battery") + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "BatPercentRemaining attribute event should generate a battery capability report", + function() + test.socket.matter:__queue_receive( + { + mock_device_battery.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device_battery, 1, 150 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end, + { + test_init = test_init_battery, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua new file mode 100644 index 0000000000..c238bfd2ca --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -0,0 +1,3372 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cluster_base = require "st.matter.cluster_base" +local clusters = require "st.matter.clusters" +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local uint32 = require "st.matter.data_types.Uint32" + +test.disable_startup_messages() + +local CAMERA_EP, FLOODLIGHT_EP, CHIME_EP, DOORBELL_EP = 1, 2, 3, 4 + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + 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 = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO | + clusters.CameraAvStreamManagement.types.Feature.PRIVACY | + clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.LOCAL_STORAGE | + clusters.CameraAvStreamManagement.types.Feature.PRIVACY | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER | + clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER | + clusters.CameraAvStreamManagement.types.Feature.HIGH_DYNAMIC_RANGE | + clusters.CameraAvStreamManagement.types.Feature.NIGHT_VISION | + clusters.CameraAvStreamManagement.types.Feature.WATERMARK | + clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.CameraAvSettingsUserLevelManagement.ID, + feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.DIGITALPTZ | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.PushAvStreamTransport.ID, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.ZoneManagement.ID, + feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE | + clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY | + clusters.ZoneManagement.types.Feature.USER_DEFINED, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.WebRTCTransportProvider.ID, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.OccupancySensing.ID, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + }, + { + endpoint_id = FLOODLIGHT_EP, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30} + }, + device_types = { + {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light + } + }, + { + endpoint_id = CHIME_EP, + clusters = { + { + cluster_id = clusters.Chime.ID, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x0146, device_type_revision = 1} -- Chime + } + }, + { + endpoint_id = DOORBELL_EP, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER", + } + }, + device_types = { + {device_type_id = 0x0143, device_type_revision = 1} -- Doorbell + } + } + } +}) + +local mock_device_no_per_zone_sensitivity = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + 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 = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.ZoneManagement.ID, + feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE | + clusters.ZoneManagement.types.Feature.USER_DEFINED, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.PushAvStreamTransport.ID, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + } + } +}) + +local mock_device_no_user_defined_zone = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + 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 = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.ZoneManagement.ID, + feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.PushAvStreamTransport.ID, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + } + } +}) + +local subscribe_request +local subscribed_attributes = { + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, +} + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local floodlight_child_device_data = { + profile = t_utils.get_profile_definition("light-color-level.yml"), + device_network_id = string.format("%s:%d", mock_device.id, FLOODLIGHT_EP), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", FLOODLIGHT_EP) + } + test.mock_device.add_test_device(test.mock_device.build_test_child_device(floodlight_child_device_data)) + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Floodlight 1", + profile = "light-color-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", FLOODLIGHT_EP) + }) + subscribe_request = subscribed_attributes[1]:subscribe(mock_device) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.CameraAVSULMFeatureMapAttr.cluster, camera_fields.CameraAVSULMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) + for i, attr in ipairs(subscribed_attributes) do + if i > 1 then subscribe_request:merge(attr:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + + +local subscribe_request_no_per_zone_sensitivity +local subscribed_attributes_no_per_zone_sensitivity = { + clusters.CameraAvStreamManagement.attributes.AttributeList, +} + +local function test_init_no_per_zone_sensitivity() + test.mock_device.add_test_device(mock_device_no_per_zone_sensitivity) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_per_zone_sensitivity.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_per_zone_sensitivity.id, "init" }) + subscribe_request_no_per_zone_sensitivity = subscribed_attributes_no_per_zone_sensitivity[1]:subscribe(mock_device_no_per_zone_sensitivity) + subscribe_request_no_per_zone_sensitivity:merge(cluster_base.subscribe(mock_device_no_per_zone_sensitivity, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request_no_per_zone_sensitivity:merge(cluster_base.subscribe(mock_device_no_per_zone_sensitivity, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) + for i, attr in ipairs(subscribed_attributes_no_per_zone_sensitivity) do + if i > 1 then subscribe_request_no_per_zone_sensitivity:merge(attr:subscribe(mock_device_no_per_zone_sensitivity)) end + end + test.socket.matter:__expect_send({mock_device_no_per_zone_sensitivity.id, subscribe_request_no_per_zone_sensitivity}) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_per_zone_sensitivity.id, "doConfigure" }) + mock_device_no_per_zone_sensitivity:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local subscribe_request_no_user_defined_zone +local subscribed_attributes_no_user_defined_zone = { + clusters.CameraAvStreamManagement.attributes.AttributeList, +} + +local function test_init_no_user_defined_zone() + test.mock_device.add_test_device(mock_device_no_user_defined_zone) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_user_defined_zone.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_user_defined_zone.id, "init" }) + subscribe_request_no_user_defined_zone = subscribed_attributes_no_user_defined_zone[1]:subscribe(mock_device_no_user_defined_zone) + subscribe_request_no_user_defined_zone:merge(cluster_base.subscribe(mock_device_no_user_defined_zone, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request_no_user_defined_zone:merge(cluster_base.subscribe(mock_device_no_user_defined_zone, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) + for i, attr in ipairs(subscribed_attributes_no_user_defined_zone) do + if i > 1 then subscribe_request_no_user_defined_zone:merge(attr:subscribe(mock_device_no_user_defined_zone)) end + end + test.socket.matter:__expect_send({mock_device_no_user_defined_zone.id, subscribe_request_no_user_defined_zone}) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_user_defined_zone.id, "doConfigure" }) + mock_device_no_user_defined_zone:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local additional_subscribed_attributes = { + clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, + clusters.CameraAvStreamManagement.attributes.ImageRotation, + clusters.CameraAvStreamManagement.attributes.NightVision, + clusters.CameraAvStreamManagement.attributes.NightVisionIllum, + clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal, + clusters.CameraAvStreamManagement.attributes.ImageFlipVertical, + clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn, + clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport, + clusters.CameraAvStreamManagement.attributes.SpeakerMuted, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvStreamManagement.attributes.Viewport, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, + clusters.Chime.attributes.InstalledChimeSounds, + clusters.Chime.attributes.SelectedChime, + clusters.ZoneManagement.attributes.MaxZones, + clusters.ZoneManagement.attributes.Zones, + clusters.ZoneManagement.attributes.Triggers, + clusters.ZoneManagement.attributes.SensitivityMax, + clusters.ZoneManagement.attributes.Sensitivity, + clusters.ZoneManagement.events.ZoneTriggered, + clusters.ZoneManagement.events.ZoneStopped, + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.OccupancySensing.attributes.Occupancy, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete +} + +local expected_metadata = { + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + "localMediaStorage", + "audioRecording", + "cameraPrivacyMode", + "imageControl", + "hdr", + "nightVision", + "mechanicalPanTiltZoom", + "zoneManagement", + "webrtc", + "motionSensor", + "sounds", + } + }, + { + "statusLed", + { + "switch", + "mode" + } + }, + { + "speaker", + { + "audioMute", + "audioVolume" + } + }, + { + "microphone", + { + "audioMute", + "audioVolume" + } + }, + { + "doorbell", + { + "button" + } + } + }, + profile = "camera" +} + +local function update_device_profile() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device:expect_metadata_update(expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.supportedFeatures( + {audio="sendrecv", bundle=true, order="audio/video", supportTrickleICE=true, turnSource="player", video="recvonly"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "zoom", "zoomRange", "presets", "maxPresets"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation", "perZoneSensitivity"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.localMediaStorage.supportedAttributes( + {"localVideoRecording"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.audioRecording.audioRecording("enabled")) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports", "watermark", "onScreenDisplay"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( + {"softRecordingPrivacyMode", "softLivestreamPrivacyMode"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( + {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} + )) + ) + for _, attr in ipairs(additional_subscribed_attributes) do + subscribe_request:merge(attr:subscribe(mock_device)) + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) +end + +local additional_subscribed_attributes_no_per_zone_sensitivity = { + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.Viewport, + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.ZoneManagement.attributes.MaxZones, + clusters.ZoneManagement.attributes.Zones, + clusters.ZoneManagement.attributes.Triggers, + clusters.ZoneManagement.attributes.SensitivityMax, + clusters.ZoneManagement.attributes.Sensitivity, + clusters.ZoneManagement.events.ZoneTriggered, + clusters.ZoneManagement.events.ZoneStopped, + clusters.OnOff.attributes.OnOff, +} + +local function update_device_profile_no_per_zone_sensitivity() + local expected_metadata = { + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + "zoneManagement", + } + }, + { + "statusLed", + { + "switch", + "mode" + } + } + }, + profile = "camera" +} + + test.socket.matter:__queue_receive({ + mock_device_no_per_zone_sensitivity.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_no_per_zone_sensitivity, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device_no_per_zone_sensitivity:expect_metadata_update(expected_metadata) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device_no_per_zone_sensitivity:generate_info_changed({ profile = updated_device_profile })) + + test.socket.capability:__expect_send( + mock_device_no_per_zone_sensitivity:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation"} + )) + ) + + test.socket.capability:__expect_send( + mock_device_no_per_zone_sensitivity:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports"} + )) + ) + + for _, attr in ipairs(additional_subscribed_attributes_no_per_zone_sensitivity) do + subscribe_request_no_per_zone_sensitivity:merge(attr:subscribe(mock_device_no_per_zone_sensitivity)) + end + test.socket.matter:__expect_send({mock_device_no_per_zone_sensitivity.id, subscribe_request_no_per_zone_sensitivity}) +end + +local additional_subscribed_attributes_no_user_defined_zone = { + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.Viewport, + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.OnOff.attributes.OnOff, +} + +local function update_device_profile_no_user_defined_zone() + local expected_metadata = { + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + } + }, + { + "statusLed", + { + "switch", + "mode" + } + } + }, + profile = "camera" + } + + test.socket.matter:__queue_receive({ + mock_device_no_user_defined_zone.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_no_user_defined_zone, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device_no_user_defined_zone:expect_metadata_update(expected_metadata) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device_no_user_defined_zone:generate_info_changed({ profile = updated_device_profile })) + + test.socket.capability:__expect_send( + mock_device_no_user_defined_zone:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports"} + )) + ) + + for _, attr in ipairs(additional_subscribed_attributes_no_user_defined_zone) do + subscribe_request_no_user_defined_zone:merge(attr:subscribe(mock_device_no_user_defined_zone)) + end + test.socket.matter:__expect_send({mock_device_no_user_defined_zone.id, subscribe_request_no_user_defined_zone}) +end + +-- Matter Handler UTs + +test.register_coroutine_test( + "Software version change should trigger camera reprofiling when camera endpoint is present", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } }) + ) + + mock_device:expect_metadata_update({ + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + "localMediaStorage", + "audioRecording", + "cameraPrivacyMode", + "imageControl", + "hdr", + "nightVision", + "mechanicalPanTiltZoom", + "zoneManagement", + "webrtc", + "motionSensor", + "sounds" + } + }, + { + "speaker", + { + "audioMute", + "audioVolume" + } + }, + { + "microphone", + { + "audioMute", + "audioVolume" + } + }, + { + "doorbell", + { + "button" + } + } + }, + profile = "camera" + }) + + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Software version change should initialize camera capabilities when profile is unchanged", + function() + local camera_handler = require "sub_drivers.camera" + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + local button_cfg = require("switch_utils.device_configuration").ButtonCfg + + local match_profile_called = false + local init_called = false + local subscribe_called = false + local configure_buttons_called = false + + local fake_device = { + matter_version = { hardware = 1, software = 3 }, + profile = { id = "camera" }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + }, + { + endpoint_id = DOORBELL_EP, + device_types = { + {device_type_id = 0x0143, device_type_revision = 1} -- Doorbell + } + } + }, + subscribe = function() subscribe_called = true end, + supports_capability = function() return false end, + get_endpoints = function() return { DOORBELL_EP } end, + } + + local original_match_profile = camera_cfg.match_profile + local original_init = camera_cfg.initialize_camera_capabilities + local original_configure_buttons = button_cfg.configure_buttons + + camera_cfg.match_profile = function() + match_profile_called = true + return false + end + camera_cfg.initialize_camera_capabilities = function() init_called = true end + button_cfg.configure_buttons = function() configure_buttons_called = true end + + camera_handler.lifecycle_handlers.infoChanged(nil, fake_device, nil, { + old_st_store = { + matter_version = { hardware = 1, software = 1 }, + profile = fake_device.profile, + } + }) + + camera_cfg.match_profile = original_match_profile + camera_cfg.initialize_camera_capabilities = original_init + button_cfg.configure_buttons = original_configure_buttons + + assert(match_profile_called, "match_profile should be called on software version change") + assert(not init_called, "initialize_camera_capabilities should not be called when capability state is unchanged") + assert(not subscribe_called, "subscribe should not be called when capability state is unchanged") + assert(not configure_buttons_called, "configure_buttons should not be called when capability state is unchanged") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Camera FeatureMap change should reinitialize capabilities when profile is unchanged", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local reconcile_called = false + local original_reconcile = camera_cfg.reconcile_profile_and_capabilities + + camera_cfg.reconcile_profile_and_capabilities = function(_) + reconcile_called = true + return false + end + + test.socket.matter:__queue_receive({ + mock_device.id, + cluster_base.build_test_report_data(mock_device, CAMERA_EP, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID, uint32(0)) + }) + test.wait_for_events() + + camera_cfg.reconcile_profile_and_capabilities = original_reconcile + assert(reconcile_called, "reconcile_profile_and_capabilities should be called") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Camera privacy mode state compare should ignore table metatable differences", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local init_event_count = 0 + local original_match_profile = camera_cfg.match_profile + + camera_cfg.match_profile = function() + return false + end + + local fake_device = { + supports_capability = function(_, capability) + return capability == capabilities.cameraPrivacyMode + end, + get_latest_state = function(_, _, _, attribute_name) + if attribute_name == capabilities.cameraPrivacyMode.supportedAttributes.NAME then + return { "softRecordingPrivacyMode", "softLivestreamPrivacyMode" } + elseif attribute_name == capabilities.cameraPrivacyMode.supportedCommands.NAME then + local commands = { "setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode" } + setmetatable(commands, { + __index = function() + return nil + end + }) + return commands + end + return nil + end, + get_endpoints = function() + return { CAMERA_EP } + end, + emit_event_for_endpoint = function() + init_event_count = init_event_count + 1 + end + } + + camera_cfg.reconcile_profile_and_capabilities(fake_device) + camera_cfg.match_profile = original_match_profile + + assert(init_event_count == 0, "cameraPrivacyMode should not be reinitialized for equal values with metatable differences") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reports mapping to EnabledState capability data type should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + local cluster_to_capability_map = { + {cluster = clusters.CameraAvStreamManagement.server.attributes.HDRModeEnabled, capability = capabilities.hdr.hdr}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipHorizontal, capability = capabilities.imageControl.imageFlipHorizontal}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipVertical, capability = capabilities.imageControl.imageFlipVertical}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftRecordingPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softRecordingPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftLivestreamPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softLivestreamPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.HardPrivacyModeOn, capability = capabilities.cameraPrivacyMode.hardPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalSnapshotRecordingEnabled, capability = capabilities.localMediaStorage.localSnapshotRecording}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalVideoRecordingEnabled, capability = capabilities.localMediaStorage.localVideoRecording} + } + for _, v in ipairs(cluster_to_capability_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("enabled")) + ) + if v.capability == capabilities.imageControl.imageFlipHorizontal then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal"})) + ) + elseif v.capability == capabilities.imageControl.imageFlipVertical then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal", "imageFlipVertical"})) + ) + elseif v.capability == capabilities.cameraPrivacyMode.hardPrivacyMode then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes({"softRecordingPrivacyMode", "softLivestreamPrivacyMode", "hardPrivacyMode"})) + ) + end + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("disabled")) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Night Vision reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + local cluster_to_capability_map = { + {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVision, capability = capabilities.nightVision.nightVision}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVisionIllum, capability = capabilities.nightVision.illumination} + } + for _, v in ipairs(cluster_to_capability_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.OFF) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("off")) + ) + if v.capability == capabilities.nightVision.illumination then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.nightVision.supportedAttributes({"illumination"})) + ) + end + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.ON) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("on")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.AUTO) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("auto")) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Image Rotation reports should generate appropriate events", + function() + local utils = require "st.utils" + update_device_profile() + test.wait_for_events() + local first_value = true + for angle = 0, 400, 50 do + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.ImageRotation:build_test_report_data(mock_device, CAMERA_EP, angle) + }) + local clamped_angle = utils.clamp_value(angle, 0, 359) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.imageRotation(clamped_angle)) + ) + if first_value then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageRotation"})) + ) + first_value = false + end + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Two Way Talk Support reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.HALF_DUPLEX + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.talkback(true)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.talkbackDuplex("halfDuplex")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.FULL_DUPLEX + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.talkback(true)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.talkbackDuplex("fullDuplex")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.NOT_SUPPORTED + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.talkback(false)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Muted reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + local cluster_to_component_map = { + {cluster = clusters.CameraAvStreamManagement.server.attributes.SpeakerMuted, component = "speaker"}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.MicrophoneMuted, component = "microphone"} + } + for _, v in ipairs(cluster_to_component_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioMute.mute("muted")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioMute.mute("unmuted")) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Volume Level reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + local max_vol = 200 + local min_vol = 0 + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + }) + test.wait_for_events() + local cluster_to_component_map = { + { cluster = clusters.CameraAvStreamManagement.server.attributes.SpeakerVolumeLevel, component = "speaker"}, + { cluster = clusters.CameraAvStreamManagement.server.attributes.MicrophoneVolumeLevel, component = "microphone"} + } + for _, v in ipairs(cluster_to_component_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, 130) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioVolume.volume(65)) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, 64) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioVolume.volume(32)) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Status Light Enabled reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.switch.switch.on()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.switch.switch.off()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Status Light Brightness reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.LOW) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.supportedModes( + {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.supportedArguments( + {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("low")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.MEDIUM) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("medium")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.HIGH) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("high")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.AUTO) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("auto")) + ) + end, + { + min_api_version = 17 + } +) + +local function receive_rate_distortion_trade_off_points() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.RateDistortionTradeOffPointsStruct({ + codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = 1920, + height = 1080 + }), + min_bit_rate = 5000000 + }), + clusters.CameraAvStreamManagement.types.RateDistortionTradeOffPointsStruct({ + codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.HEVC, + resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = 3840, + height = 2160 + }), + min_bit_rate = 20000000 + }) + } + ) + }) +end + +local function receive_max_encoded_pixel_rate() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate:build_test_report_data( + mock_device, CAMERA_EP, 124416000) -- 1080p @ 60 fps or 4K @ 15 fps + }) +end + +local function receive_min_viewport() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = 1920, + height = 1080 + }) + ) + }) +end + +local function receive_video_sensor_params() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.VideoSensorParamsStruct({ + sensor_width = 7360, + sensor_height = 4912, + max_fps = 60, + max_hdrfps = 30 + }) + ) + }) +end + +local function emit_min_viewport() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraViewportSettings.minViewportResolution({ + width = 1920, + height = 1080, + })) + ) +end + +local function emit_video_sensor_parameters() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraViewportSettings.videoSensorParameters({ + width = 7360, + height = 4912, + maxFPS = 60 + })) + ) +end + +local function emit_supported_resolutions() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedResolutions({ + { + width = 1920, + height = 1080, + fps = 60 + }, + { + width = 3840, + height = 2160, + fps = 15 + }, + { + width = 7360, + height = 4912, + fps = 0 + } + })) + ) +end + +-- Test receiving RateDistortionTradeOffPoints, MaxEncodedPixelRate, and VideoSensorParams in various orders +-- to ensure that cameraViewportSettings and videoStreamSettings capabilities are updated as expected. Note that +-- cameraViewportSettings.videoSensorParameters is set in the VideoSensorParams handler and +-- videoStreamSettings.supportedResolutions is emitted after all three attributes are received. + +test.register_coroutine_test( + "Rate Distortion Trade Off Points, MaxEncodedPixelRate, MinViewport, VideoSensorParams reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + receive_rate_distortion_trade_off_points() + receive_max_encoded_pixel_rate() + receive_min_viewport() + emit_min_viewport() + receive_video_sensor_params() + emit_video_sensor_parameters() + emit_supported_resolutions() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Rate Distortion Trade Off Points, MinViewport, VideoSensorParams, MaxEncodedPixelRate reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + receive_rate_distortion_trade_off_points() + receive_min_viewport() + emit_min_viewport() + receive_video_sensor_params() + emit_video_sensor_parameters() + receive_max_encoded_pixel_rate() + emit_supported_resolutions() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "MaxEncodedPixelRate, MinViewport, VideoSensorParams, Rate Distortion Trade Off Points reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + receive_max_encoded_pixel_rate() + receive_min_viewport() + emit_min_viewport() + receive_video_sensor_params() + emit_video_sensor_parameters() + receive_rate_distortion_trade_off_points() + emit_supported_resolutions() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PTZ Position reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax:build_test_report_data(mock_device, CAMERA_EP, 150) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin:build_test_report_data(mock_device, CAMERA_EP, -150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.panRange({value = {minimum = -150, maximum = 150}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax:build_test_report_data(mock_device, CAMERA_EP, 80) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin:build_test_report_data(mock_device, CAMERA_EP, -80) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tiltRange({value = {minimum = -80, maximum = 80}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax:build_test_report_data(mock_device, CAMERA_EP, 70) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoomRange({value = {minimum = 1, maximum = 70}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 10, tilt = 20, zoom = 30}) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(30)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PTZ Presets reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets:build_test_report_data( + mock_device, CAMERA_EP, {{preset_id = 1, name = "Preset 1", settings = {pan = 10, tilt = 20, zoom = 30}}, + {preset_id = 2, name = "Preset 2", settings = {pan = -55, tilt = 80, zoom = 60}}} + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.presets({ + { id = 1, label = "Preset 1", pan = 10, tilt = 20, zoom = 30}, + { id = 2, label = "Preset 2", pan = -55, tilt = 80, zoom = 60} + })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Max Presets reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets:build_test_report_data(mock_device, CAMERA_EP, 10) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.maxPresets(10)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Max Zones reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.MaxZones:build_test_report_data(mock_device, CAMERA_EP, 10) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.maxZones(10)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Zones reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Zones:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneInformationStruct({ + zone_id = 1, + zone_type = clusters.ZoneManagement.types.ZoneTypeEnum.TWODCART_ZONE, + zone_source = clusters.ZoneManagement.types.ZoneSourceEnum.MFG, + two_d_cartesian_zone = clusters.ZoneManagement.types.TwoDCartesianZoneStruct({ + name = "Zone 1", + use = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 0, y = 0 }), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1920, y = 1080 }) + }, + color = "#FFFFFF" + }) + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.zones({ + { + id = 1, + name = "Zone 1", + type = "2DCartesian", + polygonVertices = { + {vertex = {x = 0, y = 0}}, + {vertex = {x = 1920, y = 1080}} + }, + source = "manufacturer", + use = "motion", + color = "#FFFFFF" + } + })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Triggers reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Triggers:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneTriggerControlStruct({ + zone_id = 1, + initial_duration = 8, + augmentation_duration = 4, + max_duration = 20, + blind_duration = 3, + sensitivity = 4 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({ + { + zoneId = 1, + initialDuration = 8, + augmentationDuration = 4, + maxDuration = 20, + blindDuration = 3, + sensitivity = 4 + } + })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Sensitivity reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.SensitivityMax:build_test_report_data(mock_device, CAMERA_EP, 7) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.sensitivityRange({ minimum = 1, maximum = 7}, + {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Sensitivity:build_test_report_data(mock_device, CAMERA_EP, 5) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.sensitivity(5, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Chime reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Chime.attributes.InstalledChimeSounds:build_test_report_data(mock_device, CAMERA_EP, { + clusters.Chime.types.ChimeSoundStruct({chime_id = 1, name = "Sound 1"}), + clusters.Chime.types.ChimeSoundStruct({chime_id = 2, name = "Sound 2"}) + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.sounds.supportedSounds({ + {id = 1, label = "Sound 1"}, + {id = 2, label = "Sound 2"}, + }, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Chime.attributes.SelectedChime:build_test_report_data(mock_device, CAMERA_EP, 2) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.sounds.selectedSound(2))) + end, + { + min_api_version = 17 + } +) + +-- Event Handler UTs + +test.register_coroutine_test( + "Zone events should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 3, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}, {zoneId = 3}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneStopped:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventStoppedReasonEnum.ACTION_STOPPED + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 3}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Duplicate ZoneTriggered events should not duplicate triggeredZones state", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}})) + ) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneStopped:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventStoppedReasonEnum.ACTION_STOPPED + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button events should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.server.events.InitialPress:build_test_event_report(mock_device, DOORBELL_EP, {new_position = 1}) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.server.events.MultiPressComplete:build_test_event_report(mock_device, DOORBELL_EP, { + new_position = 1, + total_number_of_presses_counted = 2, + previous_position = 0 + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("doorbell", capabilities.button.button.double({state_change = true})) + ) + end, + { + min_api_version = 17 + } +) + +-- Capability Handler UTs + +test.register_coroutine_test( + "Set night vision commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local command_to_attribute_map = { + ["setNightVision"] = clusters.CameraAvStreamManagement.attributes.NightVision, + ["setIllumination"] = clusters.CameraAvStreamManagement.attributes.NightVisionIllum + } + for cmd, attr in pairs(command_to_attribute_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "nightVision", component = "main", command = cmd, args = { "off" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.OFF) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "nightVision", component = "main", command = cmd, args = { "on" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.ON) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "nightVision", component = "main", command = cmd, args = { "auto" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.AUTO) + }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set enabled commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local command_to_attribute_map = { + ["setHdr"] = { capability = "hdr", attr = clusters.CameraAvStreamManagement.attributes.HDRModeEnabled}, + ["setImageFlipHorizontal"] = { capability = "imageControl", attr = clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal}, + ["setImageFlipVertical"] = { capability = "imageControl", attr = clusters.CameraAvStreamManagement.attributes.ImageFlipVertical}, + ["setSoftLivestreamPrivacyMode"] = { capability = "cameraPrivacyMode", attr = clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled}, + ["setSoftRecordingPrivacyMode"] = { capability = "cameraPrivacyMode", attr = clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled}, + ["setLocalSnapshotRecording"] = { capability = "localMediaStorage", attr = clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled}, + ["setLocalVideoRecording"] = { capability = "localMediaStorage", attr = clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled} + } + for i, v in pairs(command_to_attribute_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = v.capability, component = "main", command = i, args = { "enabled" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, v.attr:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = v.capability, component = "main", command = i, args = { "disabled" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, v.attr:write(mock_device, CAMERA_EP, false) + }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set image rotation command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "imageControl", component = "main", command = "setImageRotation", args = { 10 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.ImageRotation:write(mock_device, CAMERA_EP, 10) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "imageControl", component = "main", command = "setImageRotation", args = { 257 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.ImageRotation:write(mock_device, CAMERA_EP, 257) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set mute commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "setMute", args = { "muted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "setMute", args = { "unmuted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "mute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "unmute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "setMute", args = { "muted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "setMute", args = { "unmuted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "mute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "unmute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, false) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set Volume command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local max_vol = 200 + local min_vol = 5 + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + }) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "speaker", command = "setVolume", args = { 0 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 5) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "speaker", command = "setVolume", args = { 35 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 73) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "microphone", command = "setVolume", args = { 77 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 155) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "microphone", command = "setVolume", args = { 100 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 200) + }) + + ---- test volumeUp command + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 103) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(50)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "speaker", command = "volumeUp", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 104) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 104) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(51)) + ) + + -- test volumeDown command + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("microphone", capabilities.audioVolume.volume(100)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "microphone", command = "volumeDown", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 198) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 198) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("microphone", capabilities.audioVolume.volume(99)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set Mode command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local mode_to_enum_map = { + ["low"] = clusters.Global.types.ThreeLevelAutoEnum.LOW, + ["medium"] = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM, + ["high"] = clusters.Global.types.ThreeLevelAutoEnum.HIGH, + ["auto"] = clusters.Global.types.ThreeLevelAutoEnum.AUTO + } + for i, v in pairs(mode_to_enum_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "speaker", command = "setMode", args = { i } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:write(mock_device, CAMERA_EP, v) + }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set Status LED commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "statusLed", command = "on", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "statusLed", command = "off", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, false) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set Relative PTZ commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "panRelative", args = { 10 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 10, 0, 0) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "tiltRelative", args = { -35 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 0, -35, 0) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "zoomRelative", args = { 80 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 0, 0, 80) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set PTZ commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setPanTiltZoom", args = { 10, 20, 30 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 10, 20, 30) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 10, tilt = 20, zoom = 30}) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(30)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setPan", args = { 50 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, 20, 30) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 50, tilt = 20, zoom = 30}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(50)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setTilt", args = { -44 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, -44, 30) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 50, tilt = -44, zoom = 30}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(-44)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setZoom", args = { 5 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, -44, 5) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 50, tilt = -44, zoom = 5}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(5)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Preset commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "savePreset", args = { 1, "Preset 1" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSavePreset(mock_device, CAMERA_EP, 1, "Preset 1") + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "removePreset", args = { 1 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRemovePreset(mock_device, CAMERA_EP, 1) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "moveToPreset", args = { 2 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZMoveToPreset(mock_device, CAMERA_EP, 2) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Sound commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "sounds", component = "main", command = "setSelectedSound", args = { 1 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.Chime.attributes.SelectedChime:write(mock_device, CAMERA_EP, 1) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "sounds", component = "main", command = "playSound", args = {} }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.Chime.server.commands.PlayChimeSound(mock_device, CAMERA_EP) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Zone Management zone commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local use_map = { + ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, + ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + } + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "newZone", args = { + i .. " zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}} }, i, "#FFFFFF" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) + }, + color = "#FFFFFF" + } + ) + ) + }) + end + local zone_id = 1 + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "updateZone", args = { + zone_id, "updated " .. i .. " zone", {{value = {x = 50, y = 50}}, {value = {x = 1000, y = 1000}} }, i, "red" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.UpdateTwoDCartesianZone(mock_device, CAMERA_EP, + zone_id, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = "updated " .. i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 50, y = 50 }), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1000, y = 1000 }) + }, + color = "red" + } + ) + ) + }) + zone_id = zone_id + 1 + end + for i = 1, 3 do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeZone", args = { i } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, i) + }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Zone Management zone commands should send the appropriate commands - missing optional color argument", + function() + update_device_profile() + test.wait_for_events() + local use_map = { + ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, + ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + } + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "newZone", args = { + i .. " zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}} }, i + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) + }, + } + ) + ) + }) + end + local zone_id = 1 + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "updateZone", args = { + zone_id, "updated " .. i .. " zone", {{value = {x = 50, y = 50}}, {value = {x = 1000, y = 1000}} }, i, "red" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.UpdateTwoDCartesianZone(mock_device, CAMERA_EP, + zone_id, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = "updated " .. i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 50, y = 50 }), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1000, y = 1000 }) + }, + color = "red" + } + ) + ) + }) + zone_id = zone_id + 1 + end + for i = 1, 3 do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeZone", args = { i } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, i) + }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Zone Management trigger commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + + -- Create the trigger + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 10, 3, 15, 3, 5 + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { + zone_id = 1, + initial_duration = 10, + augmentation_duration = 3, + max_duration = 15, + blind_duration = 3, + sensitivity = 5 + }) + }) + + -- The device reports the Triggers attribute with the newly created trigger and the capability is updated + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Triggers:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneTriggerControlStruct({ + zone_id = 1, initial_duration = 10, augmentation_duration = 3, max_duration = 15, blind_duration = 3, sensitivity = 5 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({{ + zoneId = 1, initialDuration = 10, augmentationDuration = 3, maxDuration = 15, blindDuration = 3, sensitivity = 5 + }})) + ) + test.wait_for_events() + + -- Update trigger, note that some arguments are optional. In this case, + -- blindDuration is not specified in the capability command. + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 8, 7, 25, 3, 1 + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { + zone_id = 1, + initial_duration = 8, + augmentation_duration = 7, + max_duration = 25, + blind_duration = 3, + sensitivity = 1 + }) + }) + + -- Remove the trigger + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeTrigger", args = { 1 } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveTrigger(mock_device, CAMERA_EP, 1) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Removing a zone with an existing trigger should send RemoveTrigger followed by RemoveZone", + function() + update_device_profile() + test.wait_for_events() + + -- Create a zone + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "newZone", args = { + "motion zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}}}, "motion", "#FFFFFF" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct({ + name = "motion zone", + use = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) + }, + color = "#FFFFFF" + }) + ) + }) + + -- Create a trigger + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 10, 3, 15, 3, 5 + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { + zone_id = 1, + initial_duration = 10, + augmentation_duration = 3, + max_duration = 15, + blind_duration = 3, + sensitivity = 5 + }) + }) + + -- Receive the Triggers attribute update from the device reflecting the new trigger + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Triggers:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneTriggerControlStruct({ + zone_id = 1, initial_duration = 10, augmentation_duration = 3, + max_duration = 15, blind_duration = 3, sensitivity = 5 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({{ + zoneId = 1, initialDuration = 10, augmentationDuration = 3, + maxDuration = 15, blindDuration = 3, sensitivity = 5 + }})) + ) + test.wait_for_events() + + -- Receive removeZone command: since a trigger exists for zone 1, RemoveTrigger is sent first, then RemoveZone + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeZone", args = { 1 } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveTrigger(mock_device, CAMERA_EP, 1) + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, 1) + }) + test.wait_for_events() + + -- Receive the updated Zones attribute from the device with the zone removed + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Zones:build_test_report_data(mock_device, CAMERA_EP, {}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.zones({value = {}})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setStream with label and viewport changes should emit capability event", + function() + update_device_profile() + test.wait_for_events() + -- Set up an existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 3, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 1920, + height = 1080, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1920, y = 1080 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change label and viewport only + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 3, + "liveStream", -- type + "My Stream", -- label + { width = 1920, height = 1080, fps = 30 }, -- resolution + { upperLeftVertex = {x = 100, y = 100}, lowerRightVertex = {x = 1820, y = 980} }, -- viewport + "disabled", -- watermark + "disabled" -- onScreenDisplay + }} + }) + -- Should send DPTZSetViewport command + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.DPTZSetViewport(mock_device, CAMERA_EP, + 3, + clusters.Global.types.ViewportStruct({ + x1 = 100, + x2 = 1820, + y1 = 100, + y2 = 980 + }) + ) + }) + -- Should emit updated capability directly, no stream reallocation + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "My Stream", + type = "liveStream", + resolution = { + width = 1920, + height = 1080, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 100, y = 100 }, + lowerRightVertex = { x = 1820, y = 980 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setStream with only watermark/OSD changes should use VideoStreamModify", + function() + update_device_profile() + test.wait_for_events() + -- Set up an existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 1, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 360}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 360}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = true, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 640, + height = 360, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 640, y = 360 } + }, + watermark = "enabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change watermark and OSD only + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 1, + "liveStream", -- type + "Stream 1", -- label + { width = 640, height = 360, fps = 30 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 640, y = 360} }, -- viewport + "disabled", -- watermark + "enabled" -- onScreenDisplay + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamModify(mock_device, CAMERA_EP, + 1, false, true + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setStream with only label change should emit capability event", + function() + update_device_profile() + test.wait_for_events() + -- Set up existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 2, + stream_usage = clusters.Global.types.StreamUsageEnum.RECORDING, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 15, + max_frame_rate = 30, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 2, + data = { + label = "Stream 1", + type = "clipRecording", + resolution = { + width = 1280, + height = 720, + fps = 15 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change label only + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 2, + "clipRecording", -- type + "Recording Stream", -- label + { width = 1280, height = 720, fps = 15 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 1280, y = 720} }, -- viewport + "disabled", -- watermark + "disabled" -- onScreenDisplay + }} + }) + -- Should emit updated capability directly, no stream reallocation + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 2, + data = { + label = "Recording Stream", + type = "clipRecording", + resolution = { + width = 1280, + height = 720, + fps = 15 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + end +) + +test.register_coroutine_test( + "setStream with only viewport change should send DPTZSetViewport command", + function() + update_device_profile() + test.wait_for_events() + -- Set up existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 5, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 3840, height = 2160}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 3840, height = 2160}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = true, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 5, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 3840, + height = 2160, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 3840, y = 2160 } + }, + watermark = "disabled", + onScreenDisplay = "enabled" + } + } + })) + ) + test.wait_for_events() + -- Change only viewport + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 5, + "liveStream", -- type + "Stream 1", -- label + { width = 3840, height = 2160, fps = 30 }, -- resolution + { upperLeftVertex = {x = 500, y = 500}, lowerRightVertex = {x = 3340, y = 1660} }, -- viewport + "disabled", -- watermark + "enabled" -- onScreenDisplay + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.DPTZSetViewport(mock_device, CAMERA_EP, + 5, + clusters.Global.types.ViewportStruct({ + x1 = 500, + x2 = 3340, + y1 = 500, + y2 = 1660 + }) + ) + }) + -- Should emit updated capability directly, no stream reallocation + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 5, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 3840, + height = 2160, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 500, y = 500 }, + lowerRightVertex = { x = 3340, y = 1660 } + }, + watermark = "disabled", + onScreenDisplay = "enabled" + } + } + })) + ) + end +) + +test.register_coroutine_test( + "setStream with resolution change should trigger reallocation", + function() + update_device_profile() + test.wait_for_events() + -- Set up existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 1, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = true, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 1280, + height = 720, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "enabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change resolution and reallocate stream + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 1, + "liveStream", -- type + "HD Stream", -- label + { width = 1920, height = 1080, fps = 30 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 1280, y = 720} }, -- viewport + "enabled", -- watermark + "disabled" -- onScreenDisplay + }} + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "HD Stream", + type = "liveStream", + resolution = { + width = 1280, + height = 720, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "enabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamDeallocate(mock_device, CAMERA_EP, 1) + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamAllocate(mock_device, CAMERA_EP, + clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + 30, + 60, + clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + 10000, + 2000000, + 4000, + true, + false + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 1, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "HD Stream", + type = "liveStream", + resolution = { + width = 1920, + height = 1080, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1920, y = 1080 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + end +) + +test.register_coroutine_test( + "Stream label should persist across attribute reports", + function() + update_device_profile() + test.wait_for_events() + -- Set up existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 3, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { width = 640, height = 480, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 640, y = 480 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change label + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 3, + "liveStream", -- type + "My Camera", -- label + { width = 640, height = 480, fps = 30 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 640, y = 480} }, -- viewport + "disabled", -- watermark + "disabled" -- onScreenDisplay + }} + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "My Camera", + type = "liveStream", + resolution = { width = 640, height = 480, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 640, y = 480 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Simulate another AllocatedVideoStreams report + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 3, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + -- Should preserve the custom label from capability state + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "My Camera", + type = "liveStream", + resolution = { width = 640, height = 480, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 640, y = 480 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + end +) + +test.register_coroutine_test( + "DPTZStreams attribute should update viewports in capability", + function() + update_device_profile() + test.wait_for_events() + -- Set up multiple existing streams + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 1, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }), + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 2, + stream_usage = clusters.Global.types.StreamUsageEnum.RECORDING, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 15, + max_frame_rate = 30, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { width = 1920, height = 1080, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 1920, y = 1080 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + }, + { + streamId = 2, + data = { + label = "Stream 2", + type = "clipRecording", + resolution = { width = 1280, height = 720, fps = 15 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 1280, y = 720 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvSettingsUserLevelManagement.types.DPTZStruct({ + video_stream_id = 1, + viewport = clusters.Global.types.ViewportStruct({ + x1 = 200, + x2 = 1720, + y1 = 100, + y2 = 980 + }) + }), + clusters.CameraAvSettingsUserLevelManagement.types.DPTZStruct({ + video_stream_id = 2, + viewport = clusters.Global.types.ViewportStruct({ + x1 = 50, + x2 = 1230, + y1 = 50, + y2 = 670 + }) + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { width = 1920, height = 1080, fps = 30 }, + viewport = { upperLeftVertex = { x = 200, y = 100 }, lowerRightVertex = { x = 1720, y = 980 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + }, + { + streamId = 2, + data = { + label = "Stream 2", + type = "clipRecording", + resolution = { width = 1280, height = 720, fps = 15 }, + viewport = { upperLeftVertex = { x = 50, y = 50 }, lowerRightVertex = { x = 1230, y = 670 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + end +) + +test.register_coroutine_test( + "Camera profile should not update for an unchanged Status Light AttributeList report", + function() + update_device_profile() + test.wait_for_events() + + local camera_cfg = require("sub_drivers.camera.camera_utils.device_configuration") + local original_reconcile = camera_cfg.reconcile_profile_and_capabilities + camera_cfg.reconcile_profile_and_capabilities = function(...) return false end + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + test.wait_for_events() + + camera_cfg.reconcile_profile_and_capabilities = original_reconcile + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Camera profile should update for a changed Status Light AttributeList report", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID) + }) + }) + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds", } + }, + { "statusLed", + { "switch" } -- only switch capability remains + }, + { "speaker", + { "audioMute", "audioVolume" } + }, + { "microphone", + { "audioMute", "audioVolume" } + }, + { "doorbell", + { "button" } + } + }, + profile = "camera" + } + mock_device:expect_metadata_update(updated_expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Camera profile should include zoneManagement when USER_DEFINED feature is present", + function() + update_device_profile_no_per_zone_sensitivity() + test.wait_for_events() + end, + { + test_init = test_init_no_per_zone_sensitivity, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Camera profile should not include zoneManagement when USER_DEFINED feature is missing", + function() + update_device_profile_no_user_defined_zone() + test.wait_for_events() + end, + { + test_init = test_init_no_user_defined_zone, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Zone Management trigger command should be ignored when zoneManagement capability is not exposed", + function() + update_device_profile_no_user_defined_zone() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device_no_user_defined_zone.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 10, 3, 15, 3, 5 + }} + }) + test.wait_for_events() + end, + { + test_init = test_init_no_user_defined_zone, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Zone Management setSensitivity command should be ignored when zoneManagement capability is not exposed", + function() + update_device_profile_no_user_defined_zone() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device_no_user_defined_zone.id, + { capability = "zoneManagement", component = "main", command = "setSensitivity", args = { 5 } } + }) + test.wait_for_events() + end, + { + test_init = test_init_no_user_defined_zone, + min_api_version = 17 + } +) + +-- run the tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua new file mode 100644 index 0000000000..3acf4129f9 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua @@ -0,0 +1,475 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local version = require "version" + +if version.api < 11 then + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +local endpoints = { + ROOT_EP = 0, + IRRIGATION_SYSTEM_EP = 1, + VALVE_1_EP = 2, + VALVE_2_EP = 3, + VALVE_3_EP = 4 +} + +-- Mock device representing an irrigation system with 3 valve endpoints +local mock_irrigation_system = test.mock_device.build_test_matter_device({ + label = "Matter Irrigation System", + profile = t_utils.get_profile_definition("irrigation-system.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = endpoints.ROOT_EP, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = endpoints.IRRIGATION_SYSTEM_EP, + clusters = { + {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.FlowMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0040, device_type_revision = 1} -- Irrigation System + } + }, + { + endpoint_id = endpoints.VALVE_1_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + }, + { + endpoint_id = endpoints.VALVE_2_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + }, + { + endpoint_id = endpoints.VALVE_3_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + } + } +}) + +local mock_children = {} +for _, endpoint in ipairs(mock_irrigation_system.endpoints) do + if endpoint.endpoint_id == 3 or endpoint.endpoint_id == 4 then + local child_data = { + profile = t_utils.get_profile_definition("water-valve-level.yml"), + device_network_id = string.format("%s:%d", mock_irrigation_system.id, endpoint.endpoint_id), + parent_device_id = mock_irrigation_system.id, + parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) + } + mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) + end +end + +local subscribe_request + +local expected_metadata = { + optional_component_capabilities = { { "main", { "level", "flowMeasurement", "operationalState", } } }, + profile = "irrigation-system" +} + +local function test_init() + test.mock_device.add_test_device(mock_irrigation_system) + local cluster_subscribe_list = { + clusters.ValveConfigurationAndControl.attributes.CurrentState, + clusters.ValveConfigurationAndControl.attributes.CurrentLevel, + } + subscribe_request = cluster_subscribe_list[1]:subscribe(mock_irrigation_system) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_irrigation_system)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "init" }) + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "doConfigure" }) + mock_irrigation_system:expect_metadata_update(expected_metadata) + mock_irrigation_system:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end + for i = 3,4 do + mock_irrigation_system:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("Matter Irrigation System %d", i - 1), + profile = "irrigation-system", + parent_device_id = mock_irrigation_system.id, + parent_assigned_child_key = string.format("%d", i) + }) + end + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) +end +test.set_test_init_function(test_init) + + +local additional_subscribed_attributes = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.OperationalState, +} + +local function update_device_profile() + local updated_device_profile = t_utils.get_profile_definition( + "irrigation-system.yml", { enabled_optional_capabilities = expected_metadata.optional_component_capabilities } + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_irrigation_system:generate_info_changed({ profile = updated_device_profile })) + for _, attr in ipairs(additional_subscribed_attributes) do + subscribe_request:merge(attr:subscribe(mock_irrigation_system)) + end + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) +end + +test.register_coroutine_test( + "Parent device: Open command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "open", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP) + }) + end +) + +test.register_coroutine_test( + "Parent device: Close command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "close", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_1_EP) + }) + end +) + +test.register_coroutine_test( + "Parent device: Set level command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "level", component = "main", command = "setLevel", args = { 75 } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP, nil, 75) + }) + end +) + +test.register_coroutine_test( + "Parent device: Current state closed should generate closed event", + function() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_1_EP, + 0 + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.valve.valve.closed()) + ) + end +) + +test.register_coroutine_test( + "Parent device: Current level reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_1_EP, + 60 + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.level.level(60)) + ) + end +) + +test.register_coroutine_test( + "Flow reports should generate correct messages", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_irrigation_system, 1, 20 * 10) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message( + "main", + capabilities.flowMeasurement.flow({ value = 20.0, unit = "m^3/h" }) + ) + ) + end +) + +test.register_coroutine_test( + "Min and max flow attributes set capability constraint", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_irrigation_system, 1, 20) + }) + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_irrigation_system, 1, 5000) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message( + "main", + capabilities.flowMeasurement.flowRange({ + value = { minimum = 2.0, maximum = 500.0 }, + unit = "m^3/h" + }) + ) + ) + end +) + +test.register_coroutine_test( + "Child device valve 2: Open command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_2_EP].id, + { capability = "valve", component = "main", command = "open", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP) + }) + end +) + +test.register_coroutine_test( + "Child device valve 2: Set level command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_2_EP].id, + { capability = "level", component = "main", command = "setLevel", args = { 40 } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP, nil, 40) + }) + end +) + +test.register_coroutine_test( + "Child device valve 2: Current state closed should generate closed event", + function() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_2_EP, + 0 + ) + }) + test.socket.capability:__expect_send( + mock_children[endpoints.VALVE_2_EP]:generate_test_message("main", capabilities.valve.valve.closed()) + ) + end +) + +test.register_coroutine_test( + "Child device valve 3: Close command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_3_EP].id, + { capability = "valve", component = "main", command = "close", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_3_EP) + }) + end +) + +test.register_coroutine_test( + "Child device valve 3: Current level reports should generate appropriate events", + function() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_3_EP, + 100 + ) + }) + test.socket.capability:__expect_send( + mock_children[endpoints.VALVE_3_EP]:generate_test_message("main", capabilities.level.level(100)) + ) + end +) + +test.register_coroutine_test( + "OperationalState attribute running should generate running event", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data( + mock_irrigation_system, + endpoints.IRRIGATION_SYSTEM_EP, + clusters.OperationalState.types.OperationalStateEnum.RUNNING + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.operationalState.operationalState.running()) + ) + end +) + +test.register_coroutine_test( + "OperationalState OperationalError UNABLE_TO_COMPLETE_OPERATION should generate unableToCompleteOperation event", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data( + mock_irrigation_system, + endpoints.IRRIGATION_SYSTEM_EP, { error_state_id = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION } + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.operationalState.operationalState.unableToCompleteOperation()) + ) + end +) + +test.register_coroutine_test( + "OperationalState resume command should send Resume and re-read state/error to irrigation system EP", + function() + update_device_profile() + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "operationalState", component = "main", command = "resume", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.server.commands.Resume(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalState:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalError:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + end +) + +test.register_coroutine_test( + "OperationalState pause command should send Pause and re-read state/error to irrigation system EP", + function() + update_device_profile() + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "operationalState", component = "main", command = "pause", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.server.commands.Pause(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalState:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalError:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + end +) + +test.register_coroutine_test( + "Child device doConfigure: update child profile based on its endpoint configuration", + function() + test.socket.device_lifecycle:__queue_receive({ mock_children[endpoints.VALVE_2_EP].id, "doConfigure" }) + mock_children[endpoints.VALVE_2_EP]:expect_metadata_update({ + profile = "irrigation-system", + optional_component_capabilities = {{"main", {"level"}}} + }) + mock_children[endpoints.VALVE_2_EP]:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) + end +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua new file mode 100644 index 0000000000..32023a36cb --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua @@ -0,0 +1,307 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.generated.zap_clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" + +local mock_device_ep1 = 1 +local mock_device_ep2 = 2 + +local mock_device = test.mock_device.build_test_matter_device({ + label = "Matter Fan Light", + profile = t_utils.get_profile_definition("fan-modular.yml", + {enabled_optional_capabilities = {{"main", {"fanSpeedPercent", "fanMode"}}}}), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + matter_version = { + software = 1, + hardware = 1, + }, + 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 = mock_device_ep1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + }, + device_types = { + {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light + } + }, + { + endpoint_id = mock_device_ep2, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 15}, + }, + device_types = { + {device_type_id = 0x002B, device_type_revision = 1,} -- Fan + } + } + } +}) + +local mock_device_capabilities_disabled = test.mock_device.build_test_matter_device({ + label = "Matter Fan Light", + profile = t_utils.get_profile_definition("fan-modular.yml", + {enabled_optional_capabilities = {{"main", {}}}}), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + matter_version = { + software = 1, + hardware = 1, + }, + 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 = mock_device_ep2, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 15}, + }, + device_types = { + {device_type_id = 0x002B, device_type_revision = 1,} -- Fan + } + } + } +}) + +local CLUSTER_SUBSCRIBE_LIST ={ + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, +} + +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-color-level.yml"), + device_network_id = string.format("%s:%d", mock_device.id, 4), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", mock_device_ep1) +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, mock_device_ep1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, mock_device_ep1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Fan Light 1", + profile = "light-color-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", mock_device_ep1) + }) + mock_device:expect_metadata_update({ profile = "fan-modular", optional_component_capabilities = {{"main", {"fanSpeedPercent", "fanMode"}}} }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + local cluster_subscribe_list ={ + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_capabilities_disabled) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_capabilities_disabled)) end + end + test.socket.device_lifecycle:__queue_receive(mock_device_capabilities_disabled:generate_info_changed( + {profile = {id = "00000000-1111-2222-3333-000000000004", components = { main = {capabilities={["fanSpeedPercent"] = {id="fanSpeedPercent", version=1}, ["fanMode"] = {id="fanMode", version=1}, ["firmwareUpdate"] = {id="firmwareUpdate", version=1}, ["refresh"] = {id="refresh", version=1}}}}}}) + ) + test.socket.matter:__expect_send({mock_device_capabilities_disabled.id, subscribe_request}) + end, + { test_init = function() test.mock_device.add_test_device(mock_device_capabilities_disabled) end } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_capabilities_disabled:generate_info_changed({})) + end, + { test_init = function() test.mock_device.add_test_device(mock_device_capabilities_disabled) end } +) + +local FanMode = clusters.FanControl.attributes.FanMode +test.register_message_test( + "Fan mode reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanMode:build_test_report_data(mock_device, mock_device_ep2, FanMode.SMART) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode.auto()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanMode:build_test_report_data(mock_device, mock_device_ep2, FanMode.AUTO) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode.auto()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanMode:build_test_report_data(mock_device, mock_device_ep2, FanMode.MEDIUM) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("medium")) + } + }, + { + min_api_version = 17 + } +) + +local FanModeSequence = clusters.FanControl.attributes.FanModeSequence +test.register_message_test( + "Fan mode sequence reports should generate the appropriate supported modes", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanModeSequence:build_test_report_data(mock_device, mock_device_ep2, FanModeSequence.OFF_ON) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({"off", "high"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanModeSequence:build_test_report_data(mock_device, mock_device_ep2, FanModeSequence.OFF_LOW_MED_HIGH_AUTO) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({"off", "low", "medium", "high", "auto"}, {visibility={displayed=false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability command setFanMode should be handled", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanMode", component = "main", command = "setFanMode", args = { "low" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, mock_device_ep2, FanMode.LOW) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability command setPercent should be handled", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanSpeedPercent", component = "main", command = "setPercent", args = { 64 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentSetting:write(mock_device, mock_device_ep2, 64) + } + } + }, + { + min_api_version = 17 + } +) + +-- run the tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua new file mode 100644 index 0000000000..d7d5efde97 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua @@ -0,0 +1,1000 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local utils = require "st.utils" +local dkjson = require "dkjson" +local clusters = require "st.matter.generated.zap_clusters" +local button_attr = capabilities.button.button +local uint32 = require "st.matter.data_types.Uint32" + +local mock_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("5-button.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = {}, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1}, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 20, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 30, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 50, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 60, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + }, +}) + +local mock_device_battery = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("5-button-battery.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = {}, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1}, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + }, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 20, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 30, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 50, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 60, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + }, + }) + +local function expect_configure_buttons(device) + test.socket.capability:__expect_send(device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.matter:__expect_send({device.id, clusters.Switch.attributes.MultiPressMax:read(device, 50)}) + test.socket.matter:__expect_send({device.id, clusters.Switch.attributes.MultiPressMax:read(device, 60)}) +end + +local function update_profile() + test.socket.matter:__queue_receive({mock_device_battery.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device_battery, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + expect_configure_buttons(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "5-button-battery" }) +end + +-- All messages queued and expectations set are done before the driver is actually run +local function test_init() + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + -- we dont want the integration test framework to generate init/doConfigure, we are doing that here + -- so we can set the proper expectations on those events. + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) -- make sure the cache is populated + + -- init results in subscription interaction + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + --doConfigure sets the provisioning state to provisioned + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + expect_configure_buttons(mock_device) + mock_device:expect_metadata_update({ profile = "5-button" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_battery() + local CLUSTER_SUBSCRIBE_LIST = { + clusters.PowerSource.server.attributes.AttributeList, + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device_battery) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_battery) + + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Simulate the profile change update taking affect and the device info changing", + function() + test.socket.matter:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() + local device_info_copy = utils.deep_copy(mock_device_battery.raw_st_data) + device_info_copy.profile.id = "5-buttons-battery" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "infoChanged", device_info_json }) + -- due to the AttributeList being processed in update_profile, setting profiling_data.BATTERY_SUPPORT, + -- subsequent subscriptions will not include AttributeList. + local UPDATED_CLUSTER_SUBSCRIBE_LIST = { + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + local updated_subscribe_request = UPDATED_CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device_battery) + for i, clus in ipairs(UPDATED_CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then updated_subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + test.socket.matter:__expect_send({mock_device_battery.id, updated_subscribe_request}) + expect_configure_buttons(mock_device_battery) + end, + { + test_init = test_init_battery, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle received BatPercentRemaining from device.", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device_battery.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device_battery, 10, 150 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end, + { + test_init = test_init_battery, + min_api_version = 17 + } +) + +test.register_message_test( + "Handle single press sequence, no hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} --move to position 1 + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle single press sequence for short release-supported button", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 20, {new_position = 1} --move to position 1 + ), + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 20, {previous_position = 0} --move to position 1 + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for emulated hold on short-release-only button", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 20, {new_position = 1} + ) + }) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 20, {previous_position = 0} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.held({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 30, {new_position = 1} + ) + }) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 30, {previous_position = 0} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on multi button", -- pushes should only be generated from multiPressComplete events + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 50, {new_position = 1} + ) + }) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 50, {previous_position = 0} + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a multi press on multi button", + function () + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 60, {previous_position = 0} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_device, 60, {new_position = 1, current_number_of_presses_counted = 2} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 2, previous_position = 1} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button5", button_attr.double({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 30, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 30, {new_position = 1} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.held({state_change = true}))) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 30, {previous_position = 0} + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on multi button", + function () + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button5", button_attr.held({state_change = true}))) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 60, {previous_position = 0} + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle single press sequence, with hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle release after short press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + }, + { -- this is a double event because the test device in this test shouldn't support the above event + -- but we handle it anyway + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle release after long press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 2 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 10, 2 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 3 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 60, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button5", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 10, 7 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle double press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle multi press for 4 times", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 4, previous_position=0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 2 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 50, 2 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button4", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle a long press including MultiPressComplete", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button5", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + ) + } + } + -- no double event + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle long press followed by single press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button5", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button5", button_attr.pushed({state_change = true})) + } + }, + { + min_api_version = 17 + } +) + +local function reset_battery_profiling_info() + local fields = require "switch_utils.fields" + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) +end + +test.register_coroutine_test( + "Test profile does not change to button-battery when battery percent remaining attribute (attribute ID 12) is not available", + function() + reset_battery_profiling_info() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(10)}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change to button-batteryLevel when battery percent remaining attribute (attribute ID 14) is available", + function() + reset_battery_profiling_info() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32( + clusters.PowerSource.attributes.BatChargeLevel.ID + )}) + } + ) + expect_configure_buttons(mock_device) + mock_device:expect_metadata_update({ profile = "5-button-batteryLevel" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change to button-battery when battery percent remaining attribute (attribute ID 12) is available", + function() + reset_battery_profiling_info() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32( + clusters.PowerSource.attributes.BatPercentRemaining.ID + )}) + } + ) + expect_configure_buttons(mock_device) + mock_device:expect_metadata_update({ profile = "5-button-battery" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua new file mode 100644 index 0000000000..44ee11861e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua @@ -0,0 +1,816 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local clusters = require "st.matter.generated.zap_clusters" +local button_attr = capabilities.button.button + +local mock_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("6-button-motion.yml"), -- on a real device we would switch to this, rather than fingerprint to it + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = {}, + device_types = {} + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 20, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 30, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 40, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 50, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 60, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 70, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- OccupancySensor + } + }, + }, +} +) + +-- add device for each mock device +local CLUSTER_SUBSCRIBE_LIST ={ + clusters.OccupancySensing.attributes.Occupancy, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, +} + +local function expect_configure_buttons() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("button4", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 50)}) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 60)}) +end + +-- All messages queued and expectations set are done before the driver is actually run +local function test_init() + -- we dont want the integration test framework to generate init/doConfigure, we are doing that here + -- so we can set the proper expectations on those events. + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) -- make sure the cache is populated + + -- added sets a bunch of fields on the device, and calls init + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + -- init results in subscription interaction + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + --doConfigure sets the provisioning state to provisioned + mock_device:expect_metadata_update({ profile = "6-button-motion" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + expect_configure_buttons() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + -- simulate the profile change update taking affect and the device info changing + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + device_info_copy.profile.id = "6-buttons-motion" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + expect_configure_buttons() +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Handle single press sequence, no hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} --move to position 1? + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + } +}, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Handle single press sequence for short release-supported button", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 20, {new_position = 1} --move to position 1? + ), + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 20, {previous_position = 0} --move to position 1? + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press + } +}, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test( + "Handle single press sequence for emulated hold on short-release-only button", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 20, {new_position = 1} + ) + }) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 20, {previous_position = 0} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.held({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 30, {new_position = 1} + ) + }) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 30, {previous_position = 0} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on multi button", -- pushes should only be generated from multiPressComplete events + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 50, {new_position = 1} + ) + }) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 50, {previous_position = 0} + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle single press sequence for a multi press on multi button", + function () + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 60, {previous_position = 0} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_device, 60, {new_position = 1, current_number_of_presses_counted = 2} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 2, previous_position = 1} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button6", button_attr.double({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 40, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 40, {new_position = 1} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button4", button_attr.held({state_change = true}))) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 40, {previous_position = 0} + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Occupancy reports should generate correct messages", + function () + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 70, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 70, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on multi button", + function () + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button6", button_attr.held({state_change = true}))) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 60, {previous_position = 0} + ) + }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle single press sequence, with hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + } +}, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Handle release after short press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + }, + { -- this is a double event because the test device in this test shouldn't support the above event + -- but we handle it anyway + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle release after long press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 2 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 10, 2 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of 3 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 60, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button6", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 10, 7 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle double press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) + }, + +}, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Handle multi press for 4 times", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 4, previous_position=0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) + }, + +}, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Receiving a max press attribute of 2 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 50, 2 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button5", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle a long press including MultiPressComplete", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button6", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + ) + } + } + -- no double event +}, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Handle long press followed by single press", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button6", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button6", button_attr.pushed({state_change = true})) + } + }, + { + min_api_version = 17 + } +) +-- run the tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua new file mode 100644 index 0000000000..70f9666542 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua @@ -0,0 +1,492 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local clusters = require "st.matter.generated.zap_clusters" + +local button_attr = capabilities.button.button + +local mock_device_ep1 = 1 +local mock_device_ep2 = 2 +local mock_device_ep3 = 3 +local mock_device_ep4 = 4 +local mock_device_ep5 = 5 + +local mock_device = test.mock_device.build_test_matter_device({ + label = "Matter Switch", + profile = t_utils.get_profile_definition("light-level-3-button.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + matter_version = {hardware = 1, software = 1}, + 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 = mock_device_ep1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light + } + }, + { + endpoint_id = mock_device_ep2, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = mock_device_ep3, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = mock_device_ep4, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = mock_device_ep5, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 31} + }, + device_types = { + {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light + } + }, + } +}) + +local mock_device_mcd_unsupported_switch_device_type = test.mock_device.build_test_matter_device({ + label = "Matter Switch", + profile = t_utils.get_profile_definition("button.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0103, device_type_revision = 1} -- OnOff Switch + } + }, + { + endpoint_id = 20, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 30, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + } +}) + +local child_profile = t_utils.get_profile_definition("light-color-level.yml") +local child_data = { + profile = child_profile, + device_network_id = string.format("%s:%d", mock_device.id, mock_device_ep5), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", mock_device_ep5) +} +local mock_child = test.mock_device.build_test_child_device(child_data) + +-- add device for each mock device +local CLUSTER_SUBSCRIBE_LIST_NO_CHILD ={ + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, +} + +local CLUSTER_SUBSCRIBE_LIST_WITH_CHILD ={ + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, +} + +local function expect_configure_buttons() + test.socket.capability:__expect_send(mock_device:generate_test_message("button1", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) +end + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) -- make sure the cache is populated + + -- added sets a bunch of fields on the device, and calls init + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_NO_CHILD[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_NO_CHILD) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, mock_device_ep1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, mock_device_ep5, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, mock_device_ep5, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ profile = "light-level-3-button" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 2", + profile = "light-color-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", mock_device_ep5) + }) + expect_configure_buttons() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) +end + +-- All messages queued and expectations set are done before the driver is actually run +local function test_init_mcd_unsupported_switch_device_type() + -- we dont want the integration test framework to generate init/doConfigure, we are doing that here + -- so we can set the proper expectations on those events. + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_mcd_unsupported_switch_device_type) -- make sure the cache is populated +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Main switch component: switch capability should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "on", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, mock_device_ep1) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, mock_device_ep1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "First button component: Handle single press sequence, no hold", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, mock_device_ep2, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button1", button_attr.pushed({state_change = true})) --should send initial press + } +}, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Second button component: Handle single press sequence for short release-supported button", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, mock_device_ep3, {new_position = 1} + ), + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, mock_device_ep3, {previous_position = 0} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) + } +}, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test( + "Third button component: Handle single press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, mock_device_ep4, {new_position = 1} + ) + }) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, mock_device_ep4, {previous_position = 0} + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test MCD configuration not including switch for unsupported switch device type, create child device instead", + function() + local unsup_mock_device = mock_device_mcd_unsupported_switch_device_type + -- added sets a bunch of fields on the device, and calls init + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(unsup_mock_device) + for _, cluster in ipairs(CLUSTER_SUBSCRIBE_LIST) do + subscribe_request:merge(cluster:subscribe(unsup_mock_device)) + end + + test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "init" }) + test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "doConfigure" }) + unsup_mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 1", + profile = "switch-binary", + parent_device_id = unsup_mock_device.id, + parent_assigned_child_key = string.format("%d", 7) + }) + test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + unsup_mock_device:expect_metadata_update({ profile = "2-button" }) + unsup_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + test.wait_for_events() + + local updated_device_profile = t_utils.get_profile_definition("2-button.yml") + test.socket.device_lifecycle:__queue_receive(unsup_mock_device:generate_info_changed({ profile = updated_device_profile })) + + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(unsup_mock_device) + for i, cluster in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(unsup_mock_device)) + end + end + test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) + + test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + end, + { + test_init = test_init_mcd_unsupported_switch_device_type, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test driver switched event", + function() + test.mock_device.add_test_device(mock_child) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_WITH_CHILD[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + mock_child:expect_metadata_update({ profile = "light-color-level" }) + mock_device:expect_metadata_update({ profile = "light-level-3-button" }) + expect_configure_buttons() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test info changed event with parent device profile update", + function() + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_NO_CHILD[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_NO_CHILD) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + local updated_device_profile = t_utils.get_profile_definition("light-level-3-button.yml") + updated_device_profile.id = "updated device profile id" + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + expect_configure_buttons() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test info changed event with matter_version update", + function() + test.mock_device.add_test_device(mock_child) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } })) -- bump sw to 2 + mock_child:expect_metadata_update({ profile = "light-color-level" }) + mock_device:expect_metadata_update({ profile = "light-level-3-button" }) + expect_configure_buttons() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test child device initialization, and that subscriptions are initialized correctly", + function () + test.mock_device.add_test_device(mock_child) + test.socket.matter:__expect_send({mock_device.id, clusters.OnOff.attributes.OnOff:read(mock_device)}) + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "init" }) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_WITH_CHILD[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + mock_child:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "doConfigure" }) + end, + { + min_api_version = 17 + } +) + +-- run the tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_device_configuration.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_device_configuration.lua new file mode 100644 index 0000000000..d27ee2c383 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_device_configuration.lua @@ -0,0 +1,303 @@ +-- Copyright © 2025 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.matter.clusters" + +test.disable_startup_messages() + + +local generic_manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 } +local generic_matter_version = { hardware = 1, software = 1 } +local root_endpoint = { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } +} + + +local mock_device_onoff_switch_as_server = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0103, device_type_revision = 1} -- On/Off Light Switch + } + } + } +}) + +test.register_coroutine_test( + "Test profile change on init for onoff parent cluster as server", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_server.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_server.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_server.id, "doConfigure" }) + mock_device_onoff_switch_as_server:expect_metadata_update({ profile = "switch-binary" }) + mock_device_onoff_switch_as_server:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_onoff_switch_as_server) end, + min_api_version = 17 + } +) + + +local mock_device_onoff_switch_as_client = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "CLIENT", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0103, device_type_revision = 1} -- On/Off Light Switch + } + } + } +}) + +test.register_coroutine_test( + "Test init for onoff parent cluster as client", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_client.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_client.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_client.id, "doConfigure" }) + mock_device_onoff_switch_as_client:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_onoff_switch_as_client) end, + min_api_version = 17 + } +) + + +local mock_device_dimmer_switch_as_server = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = 0x0104, device_type_revision = 1} -- Dimmer Switch + } + } + } +}) + +test.register_coroutine_test( + "Test profile change on init for dimmer parent cluster as server", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer_switch_as_server.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer_switch_as_server.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer_switch_as_server.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_dimmer_switch_as_server.id, + clusters.LevelControl.attributes.Options:write(mock_device_dimmer_switch_as_server, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_dimmer_switch_as_server:expect_metadata_update({ profile = "switch-level" }) + mock_device_dimmer_switch_as_server:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_dimmer_switch_as_server) end, + min_api_version = 17 + } +) + + +local mock_device_plug_with_switch_profile_vendor_override = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("switch-binary.yml"), + manufacturer_info = { vendor_id = 0x142B, product_id = 0x1003}, -- this device has a vendor override to join as a switch instead of a plug + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x010A, device_type_revision = 1} -- OnOff PlugIn Unit + } + } + } +}) + +test.register_coroutine_test( + "Test init for device with requiring the switch category as a vendor override", + function() + local mock_device = mock_device_plug_with_switch_profile_vendor_override + local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "switch-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_plug_with_switch_profile_vendor_override) end, + min_api_version = 17 + } +) + + +local mock_device_color_dimmer = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "CLIENT", feature_map = 2}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "CLIENT", feature_map = 31}, + + }, + device_types = { + {device_type_id = 0x0105, device_type_revision = 1} -- Color Dimmer Switch + } + } + } +}) + +test.register_coroutine_test( + "Test profile change on init for color dimmer device type as server", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "doConfigure" }) + mock_device_color_dimmer:expect_metadata_update({ profile = "switch-color-level" }) + mock_device_color_dimmer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_color_dimmer) end, + min_api_version = 17 + } +) + + +local mock_device_mounted_on_off_control = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("switch-binary.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + + }, + device_types = { + {device_type_id = 0x010F, device_type_revision = 1} -- Mounted On/Off Control + } + } + } +}) + +test.register_coroutine_test( + "Test init for mounted onoff control", + function() + local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device_mounted_on_off_control) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "init" }) + test.socket.matter:__expect_send({mock_device_mounted_on_off_control.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_mounted_on_off_control.id, + clusters.LevelControl.attributes.Options:write(mock_device_mounted_on_off_control, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_mounted_on_off_control:expect_metadata_update({ profile = "switch-binary" }) + mock_device_mounted_on_off_control:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_mounted_on_off_control) end, + min_api_version = 17 + } +) + + +local mock_device_mounted_dimmable_load_control = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("switch-level.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + + }, + device_types = { + {device_type_id = 0x0110, device_type_revision = 1} -- Mounted Dimmable Load Control + } + } + } +}) + +test.register_coroutine_test( + "Test init for mounted dimmable load control", + function() + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.LevelControl.attributes.MaxLevel, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_mounted_dimmable_load_control) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_mounted_dimmable_load_control)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "init" }) + test.socket.matter:__expect_send({mock_device_mounted_dimmable_load_control.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_mounted_dimmable_load_control.id, + clusters.LevelControl.attributes.Options:write(mock_device_mounted_dimmable_load_control, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_mounted_dimmable_load_control:expect_metadata_update({ profile = "switch-level" }) + mock_device_mounted_dimmable_load_control:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_mounted_dimmable_load_control) end, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_parent_child.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_parent_child.lua new file mode 100644 index 0000000000..608112d61c --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_parent_child.lua @@ -0,0 +1,878 @@ +-- 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 capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" + +test.disable_startup_messages() + +local generic_manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 } +local generic_matter_version = { hardware = 1, software = 1 } +local root_endpoint = { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } +} + +local parent_ep_id = 10 +local dimmable_ep_id = 30 +local extended_color_ep_id = 50 + +-- this parent device would fingerprint as light-color-level, since the most feature-rich endpoint is the extended color one, +-- but it should re-configure to light-binary in doConfigure +local mock_device = test.mock_device.build_test_matter_device({ + label = "Matter Switch", + profile = t_utils.get_profile_definition("light-color-level.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = parent_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light + } + }, + { + endpoint_id = extended_color_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + }, + device_types = { + {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light + } + }, + { + endpoint_id = dimmable_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 2}, -- On/Off Light + {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light + } + }, + } +}) + +local child_profiles = { + [dimmable_ep_id] = t_utils.get_profile_definition("light-level.yml"), + [extended_color_ep_id] = t_utils.get_profile_definition("light-color-level.yml"), +} + +local mock_children = {} +for i, endpoint in ipairs(mock_device.endpoints) do + if endpoint.endpoint_id ~= parent_ep_id and endpoint.endpoint_id ~= 0 then + local child_data = { + profile = child_profiles[endpoint.endpoint_id], + device_network_id = string.format("%s:%d", mock_device.id, endpoint.endpoint_id), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) + } + mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) + end +end + +local function handle_init_event(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, + } + local expected_subscriptions = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + expected_subscriptions:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, expected_subscriptions}) +end + +local function handle_do_configure_event(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, extended_color_ep_id, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, extended_color_ep_id, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, dimmable_ep_id, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + + mock_device:expect_metadata_update({ profile = "light-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 2", + profile = "light-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", dimmable_ep_id) + }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 3", + profile = "light-color-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", extended_color_ep_id) + }) +end + +local function test_init_for_lifecycle_tests() + test.mock_device.add_test_device(mock_device) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end +end + +-- due to device copy logic in the integration tests, we need to handle init and doConfigure before generating an infoChanged event +local function test_init_for_generate_info_changed_tests() + test.mock_device.add_test_device(mock_device) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end + handle_init_event(mock_device) + handle_do_configure_event(mock_device) +end + +local function test_init_for_post_configure_tests() + test.mock_device.add_test_device(mock_device) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end + local FIND_CHILD_KEY = "__find_child_fn" + mock_device:set_field(FIND_CHILD_KEY, switch_utils.find_child, { persist = false }) + mock_device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, { persist = false }) +end + +test.set_test_init_function(test_init_for_post_configure_tests) + +test.register_coroutine_test( + "Handle initial init lifecycle event, before children are created", + function() + handle_init_event(mock_device) + test.wait_for_events() + assert(mock_device:get_field(fields.profiling_data.POWER_TOPOLOGY) == false, "Device should be marked as not needing to configure power topology") + assert(mock_device:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as having no battery") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle doConfigure lifecycle event", + function() + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + handle_do_configure_event(mock_device) + test.wait_for_events() + local FIND_CHILD_KEY = "__find_child_fn" + assert(type(mock_device:get_field(FIND_CHILD_KEY)) == "function", "Child find function should be stored in doConfigure") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test info changed event with matter_version update", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } })) -- bump to 2 + mock_children[dimmable_ep_id]:expect_metadata_update({ profile = "light-level" }) + mock_children[extended_color_ep_id]:expect_metadata_update({ profile = "light-color-level" }) + mock_device:expect_metadata_update({ profile = "light-binary" }) + end, + { + test_init = test_init_for_generate_info_changed_tests, + min_api_version = 17 + } +) + + +test.register_message_test( + "Dimmable Child: Current level cluster reports generate switch level events appropriately", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, dimmable_ep_id, 50) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[dimmable_ep_id]:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Children: Level Control Min and max attributes set switch level constraints appropriately", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, dimmable_ep_id, 1) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, dimmable_ep_id, 254) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[dimmable_ep_id]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 1, maximum = 100})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, extended_color_ep_id, 127) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, extended_color_ep_id, 203) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 50, maximum = 80})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Extended Color Child: SetColor command should be handled correctly", + { + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColor(mock_device, extended_color_ep_id, 15182, 21547, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_device, extended_color_ep_id) + } + }, + } +) + +test.register_message_test( + "Extended Color Child: X and Y color values should report hue and saturation once both have been received", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, extended_color_ep_id, 15091) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, extended_color_ep_id, 21547) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorControl.hue(50)) + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorControl.saturation(72)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Extended Color Child: colorTemperatureRange, setColorTemperature, colorTemperature, stepColorTemperatureByPercent handled appropriately", + { + -- colorTemperatureRange testing + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, extended_color_ep_id, 153) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, extended_color_ep_id, 555) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) + }, + + -- setColorTemperature testing + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[extended_color_ep_id].id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, extended_color_ep_id, 555, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + } + }, -- 555 is expected since it is re-bounded by the given range + + -- colorTemperature testing + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, extended_color_ep_id, 555) + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) + }, + + -- stepColorTemperatureByPercent testing + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[extended_color_ep_id].id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device, extended_color_ep_id, clusters.ColorControl.types.StepModeEnum.DOWN, 80, fields.DEFAULT_STEP_TRANSITION_TIME, 153, 555, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Parent: switch capability <-> On Off cluster should handle events appropriately", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "on", args = { } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, parent_ep_id) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, parent_ep_id, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Children: switch capability <-> On Off Cluster should handle events appropriately", + { + { + channel = "capability", + direction = "receive", + message = { + mock_children[dimmable_ep_id].id, + { capability = "switch", component = "main", command = "on", args = { } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[dimmable_ep_id].id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, dimmable_ep_id) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, dimmable_ep_id, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[dimmable_ep_id]:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "switch", component = "main", command = "on", args = { } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[extended_color_ep_id].id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, extended_color_ep_id) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, extended_color_ep_id, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +local dimmable_child_plug_ep_id = 30 + +local mock_plug = test.mock_device.build_test_matter_device({ + label = "Matter Plug", + profile = t_utils.get_profile_definition("plug-level.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = parent_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + { + endpoint_id = dimmable_child_plug_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.DIMMABLE_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + } +}) + +local function handle_init_event_for_plug(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel + } + local expected_subscriptions = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + expected_subscriptions:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, expected_subscriptions}) +end + +local function handle_do_configure_event_for_plug(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, dimmable_child_plug_ep_id, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ profile = "plug-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Plug 2", + profile = "plug-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", dimmable_child_plug_ep_id) + }) +end + +test.register_coroutine_test( + "Plug: Handle initial init lifecycle event, before children are created", + function() + handle_init_event_for_plug(mock_plug) + test.wait_for_events() + assert(mock_plug:get_field(fields.profiling_data.POWER_TOPOLOGY) == false, "Device should be marked as not needing to configure power topology") + assert(mock_plug:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as having no battery") + end, + { + test_init = function() + test.mock_device.add_test_device(mock_plug) + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Plug: Handle doConfigure lifecycle event", + function() + mock_plug:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_plug:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + handle_do_configure_event_for_plug(mock_plug) + test.wait_for_events() + local FIND_CHILD_KEY = "__find_child_fn" + assert(type(mock_plug:get_field(FIND_CHILD_KEY)) == "function", "Child find function should be stored in doConfigure") + end, + { + test_init = function() + test.mock_device.add_test_device(mock_plug) + end, + min_api_version = 17 + } +) + + +local overriden_plug_child_ep_id = 30 + +-- This device overrides both its parent and child profiles to become the Switch category +local mock_plug_profile_override = test.mock_device.build_test_matter_device({ + label = "Matter Plug", + profile = t_utils.get_profile_definition("switch-binary.yml"), + manufacturer_info = { vendor_id = 0x1321, product_id = 0x000C }, -- this Sonoff device has an overloaded profile only for its children + endpoints = { + root_endpoint, + { + endpoint_id = parent_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + { + endpoint_id = overriden_plug_child_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + } +}) + + +local function handle_do_configure_event_for_plug_with_profile_override(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "switch-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Plug 2", + profile = "switch-binary", -- overriden profile for Sonoff device + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", overriden_plug_child_ep_id) + }) +end + +test.register_coroutine_test( + "Plug with Overriden Profile: Handle doConfigure lifecycle event", + function() + mock_plug_profile_override:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_plug_profile_override:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + handle_do_configure_event_for_plug_with_profile_override(mock_plug_profile_override) + end, + { + test_init = function() + test.mock_device.add_test_device(mock_plug_profile_override) + end, + min_api_version = 17 + } +) + +local mock_switch = test.mock_device.build_test_matter_device({ + label = "Matter Switch", + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.SWITCH.DIMMER, device_type_revision = 1} + } + }, + { + endpoint_id = 10, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.SWITCH.ON_OFF_LIGHT, device_type_revision = 1} + } + }, + { + endpoint_id = 20, -- this endpoint should not generate a child device since it only has a client OnOff cluster + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "CLIENT", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.SWITCH.ON_OFF_LIGHT, device_type_revision = 1} + } + }, + { + endpoint_id = 30, -- this endpoint should profile correctly, though it is not a switch + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.ON_OFF, device_type_revision = 2} + } + }, + { + endpoint_id = 40, -- this endpoint should generate a switch-binary child device since it has a SERVER OnOff cluster,though the device type is unknown. + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = 0x0304, device_type_revision = 2} -- Pump Controller + } + } + } +}) + +test.register_coroutine_test( + "Switch Profile: Handle doConfigure lifecycle event", + function() + mock_switch:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_switch:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.socket.device_lifecycle:__queue_receive({ mock_switch.id, "doConfigure" }) + test.socket.matter:__expect_send({ mock_switch.id, clusters.LevelControl.attributes.Options:write(mock_switch, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) }) + test.socket.matter:__expect_send({ mock_switch.id, clusters.LevelControl.attributes.Options:write(mock_switch, 40, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) }) + mock_switch:expect_metadata_update({ profile = "switch-level" }) + mock_switch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_switch:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 2", + profile = "switch-binary", + parent_device_id = mock_switch.id, + parent_assigned_child_key = string.format("%d", 10) + }) + + -- client cluster only endpoint (20) should not generate a child device + + mock_switch:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 3", + profile = "light-binary", + parent_device_id = mock_switch.id, + parent_assigned_child_key = string.format("%d", 30) + }) + + mock_switch:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 4", + profile = "switch-binary", + parent_device_id = mock_switch.id, + parent_assigned_child_key = string.format("%d", 40) + }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_switch) end, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua new file mode 100644 index 0000000000..eabc9246f7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua @@ -0,0 +1,93 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("3-button-battery-temperature-humidity.yml"), + matter_version = {hardware = 1, software = 1}, + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1}, + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1}, + } + }, + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end + +test.register_coroutine_test("Read appropriate attribute values after tempOffset preference change", function() + local report = clusters.TemperatureMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device,1, 2000) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { tempOffset = "2" } })) + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.temperatureMeasurement.temperature({ + value = 20.0, + unit = "C" + }))) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {tempOffset = "5"}})) + test.socket.matter:__expect_send({mock_device.id, clusters.TemperatureMeasurement.attributes.MeasuredValue:read(mock_device)}) + + test.wait_for_events() + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.temperatureMeasurement.temperature({ + value = 20.0, + unit = "C" + }))) +end, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test("Read appropriate attribute values after humidityOffset preference change", function() + local report = clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device,2, 2000) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { humidityOffset = "0" } })) + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.relativeHumidityMeasurement.humidity({ + value = 20 + }))) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {humidityOffset = "5"}})) + test.socket.matter:__expect_send({mock_device.id, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(mock_device)}) + + test.wait_for_events() + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.relativeHumidityMeasurement.humidity({ + value = 20 + }))) +end, +{ + min_api_version = 17 +} +) + +test.set_test_init_function(test_init) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua index 3e47e0261b..e68f1d0101 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua @@ -1,25 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" +local fields = require "switch_utils.fields" local clusters = require "st.matter.clusters" local TRANSITION_TIME = 0 local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 +local HANDLE_COMMAND_IF_OFF = 0x01 local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("switch-color-level.yml"), @@ -40,38 +30,15 @@ local mock_device = test.mock_device.build_test_matter_device({ { endpoint_id = 1, clusters = { - { - cluster_id = clusters.OnOff.ID, - cluster_type = "SERVER", - cluster_revision = 1, - feature_map = 0, --u32 bitmap - }, + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 31}, {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} }, device_types = { - {device_type_id = 0x0100, device_type_revision = 1} -- On/Off Light - } - } - } -}) - -local mock_device_no_hue_sat = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("switch-color-level.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 1} -- On/Off Light + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.ON_OFF, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.COLOR_TEMPERATURE, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.EXTENDED_COLOR, device_type_revision = 1} } } } @@ -86,33 +53,67 @@ local cluster_subscribe_list = { clusters.ColorControl.attributes.CurrentSaturation, clusters.ColorControl.attributes.CurrentX, clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, clusters.ColorControl.attributes.ColorTemperatureMireds, clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.ColorMode, } local function test_init() - test.socket.matter:__set_channel_ordering("relaxed") + test.mock_device.add_test_device(mock_device) + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) for i, cluster in ipairs(cluster_subscribe_list) do if i > 1 then subscribe_request:merge(cluster:subscribe(mock_device)) end end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) - subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_no_hue_sat) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_no_hue_sat)) - end - end - test.socket.matter:__expect_send({mock_device_no_hue_sat.id, subscribe_request}) - test.mock_device.add_test_device(mock_device_no_hue_sat) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end + test.set_test_init_function(test_init) +test.register_coroutine_test( + "doConfigure properly sets Extended Color Light and does not attempt to switch profiles", + function() + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.LevelControl.attributes.Options:write(mock_device, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ColorControl.attributes.Options:write(mock_device, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init creates accurate subscription for Extended Color Light", + function() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + end, + { + min_api_version = 17 + } +) + test.register_message_test( "On command should send the appropriate commands", { @@ -124,6 +125,14 @@ test.register_message_test( { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "matter", direction = "send", @@ -132,6 +141,9 @@ test.register_message_test( clusters.OnOff.server.commands.On(mock_device, 1) } } + }, + { + min_api_version = 17 } ) @@ -146,6 +158,14 @@ test.register_message_test( { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "matter", direction = "send", @@ -154,18 +174,24 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, 1) } } + }, + { + min_api_version = 17 } ) + +local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) +local sat = math.floor((50 * 0xFE) / 100.0 + 0.5) test.register_message_test( - "Set level command should send the appropriate commands", + "Set Hue command should send MoveToHue", { { channel = "capability", direction = "receive", message = { mock_device.id, - { capability = "switchLevel", component = "main", command = "setLevel", args = {20,20} } + { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } } }, { @@ -173,284 +199,268 @@ test.register_message_test( direction = "send", message = { mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 1, math.floor(20/100.0 * 254), 20, 0 ,0) + clusters.ColorControl.server.commands.MoveToHue(mock_device, 1, hue, 0, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Set Saturation command should send MoveToSaturation", + { { - channel = "matter", + channel = "capability", direction = "receive", message = { mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 1) + { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } } }, { channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) - } - }, - { - channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(20)) - }, - { - channel = "matter", - direction = "receive", message = { mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, true) + clusters.ColorControl.server.commands.MoveToSaturation(mock_device, 1, sat, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + min_api_version = 17 } ) test.register_message_test( - "Current level reports should generate appropriate events", + "Set color temperature should send the appropriate commands", { { - channel = "matter", + channel = "capability", direction = "receive", message = { mock_device.id, - clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } } }, { - channel = "capability", + channel = "devices", direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) - }, - } -) - -test.register_message_test( - "Set color command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", message = { - mock_device_no_hue_sat.id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } } } + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } } }, { channel = "matter", direction = "send", message = { - mock_device_no_hue_sat.id, - clusters.ColorControl.server.commands.MoveToColor(mock_device_no_hue_sat, 1, 15182, 21547, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 556, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, { channel = "matter", direction = "receive", message = { - mock_device_no_hue_sat.id, - clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_device_no_hue_sat, 1) + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, 1) } }, { - channel = "matter", - direction = "receive", + channel = "devices", + direction = "send", message = { - mock_device_no_hue_sat.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } }, { channel = "matter", direction = "receive", message = { - mock_device_no_hue_sat.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + mock_device.id, + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 556) } }, { channel = "capability", direction = "send", - message = mock_device_no_hue_sat:generate_test_message("main", capabilities.colorControl.hue(50)) + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) }, - { - channel = "capability", - direction = "send", - message = mock_device_no_hue_sat:generate_test_message("main", capabilities.colorControl.saturation(72)) - } + }, + { + min_api_version = 17 } ) -local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) -local sat = math.floor((50 * 0xFE) / 100.0 + 0.5) - test.register_message_test( - "Set color command should send huesat commands when supported", + "Do not report when receiving a color temperature of 0 mireds", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) - } - }, - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 0) } }, { - channel = "matter", + channel = "devices", direction = "send", message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } - }, + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min and max color temperature attributes set capability constraint", + { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) } }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) - }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) } + }, + { + min_api_version = 17 } ) test.register_message_test( - "Set Hue command should send MoveToHue", + "Min and max color temperature attributes set capability constraint using improved temperature conversion rounding", { { - channel = "capability", + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) } }, { channel = "matter", - direction = "send", + direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToHue(mock_device, 1, hue, 0, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) + } + }, + { + min_api_version = 17 } ) test.register_message_test( - "Set Saturation command should send MoveToSaturation", + "Device reports mireds outside of supported range, set capability to min/max value in kelvin", { { - channel = "capability", + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) } }, { channel = "matter", - direction = "send", + direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToSaturation(mock_device, 1, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) } }, - } -) - -test.register_message_test( - "Set color temperature should send the appropriate commands", - { { channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) + }, + { + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 160) } }, { - channel = "matter", + channel = "devices", direction = "send", message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(6000)) + }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, 1) + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 370) } }, { - channel = "matter", - direction = "receive", + channel = "devices", + direction = "send", message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 556) + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(2800)) + } + }, + { + min_api_version = 17 } ) test.register_message_test( - "X and Y color values should report hue and saturation once both have been received", + "Capability sets color temp outside of supported range, value sent to device is limited to min/max value in mireds", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) } }, { @@ -458,64 +468,77 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) }, { channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {6100} } + } + }, + { + channel = "devices", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(72)) - } - } -) - -test.register_message_test( - "X and Y color values have 0 value", - { + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } + } + }, { channel = "matter", - direction = "receive", + direction = "send", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 0) + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 165, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, { - channel = "matter", + channel = "capability", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 0) + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {2700} } } }, { - channel = "capability", + channel = "devices", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(33)) + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } + } }, { - channel = "capability", + channel = "matter", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 365, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) + } } + }, + { + min_api_version = 17 } ) - test.register_message_test( - "Y and X color values should report hue and saturation once both have been received", + "Min color temperature outside of range, capability not sent", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 50) } }, { @@ -523,45 +546,49 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(72)) } + }, + { + min_api_version = 17 } ) test.register_message_test( - "Do not report when receiving a color temperature of 0 mireds", + "Max color temperature outside of range, capability not sent", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 0) + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 1100) } } + }, + { + min_api_version = 17 } ) test.register_message_test( - "Min and max color temperature attributes set capability constraint", + "Min and max level attributes set capability constraint", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 5) } }, { @@ -569,26 +596,29 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) + message = mock_device:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 2, maximum = 4})) } + }, + { + min_api_version = 17 } ) test.register_message_test( - "Min color temperature outside of range, capability not sent", + "Min level attribute outside of range for lighting feature device (min level = 1), capability not sent", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 50) + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 0) } }, { @@ -596,21 +626,304 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) } } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "colorControl capability sent based on CurrentX and CurrentY due to ColorMode", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.ColorMode:build_test_report_data(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) + } + ) + local read_x_y = clusters.ColorControl.attributes.CurrentX:read() + read_x_y:merge(clusters.ColorControl.attributes.CurrentY:read()) + test.socket.matter:__expect_send( + { + mock_device.id, + read_x_y + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(50) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(72) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.capability:__queue_receive( + {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + ) + local read_request = cluster_subscribe_list[1]:read(mock_device) + for i, attr in ipairs(cluster_subscribe_list) do + if i > 1 then read_request:merge(attr:read(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, read_request}) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + + +local function set_color_mode(device, endpoint, color_mode) + test.socket.matter:__queue_receive({ + device.id, + clusters.ColorControl.attributes.ColorMode:build_test_report_data( + device, endpoint, color_mode) + }) + local read_req + if color_mode == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then + read_req = clusters.ColorControl.attributes.CurrentHue:read() + read_req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) + else -- color_mode = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY + read_req = clusters.ColorControl.attributes.CurrentX:read() + read_req:merge(clusters.ColorControl.attributes.CurrentY:read()) + end + test.socket.matter:__expect_send({device.id, read_req}) +end + + + +local function test_init_x_y_color_mode() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) +end + +test.register_coroutine_test( + "X and Y color values should report hue and saturation once both have been received", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(50) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(72) + ) + ) + end, + { + test_init = test_init_x_y_color_mode, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "X and Y color values have 0 value", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 0) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(33) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(100) + ) + ) + end, + { + test_init = test_init_x_y_color_mode, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Y and X color values should report hue and saturation once both have been received", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(50) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(72) + ) + ) + end, + { + test_init = test_init_x_y_color_mode, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "colorControl capability sent based on CurrentHue and CurrentSaturation due to ColorMode", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.ColorMode:build_test_report_data(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION) + } + ) + local read_hue_sat = clusters.ColorControl.attributes.CurrentHue:read() + read_hue_sat:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) + test.socket.matter:__expect_send( + { + mock_device.id, + read_hue_sat + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, 0xFE), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(100) + ) + ) + test.socket.devices:__expect_send( + { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, 0xFE), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(100) + ) + ) + test.socket.devices:__expect_send( + { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + } + ) + end, + { + test_init = test_init_x_y_color_mode, + min_api_version = 17 } ) + + +local function test_init_with_hue_sat_color_mode_set() + test.mock_device.add_test_device(mock_device) + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION) +end + test.register_message_test( - "Max color temperature outside of range, capability not sent", + "Set color command should send huesat commands when supported", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) + clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, { @@ -618,21 +931,28 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 1100) + clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) } - } - } -) - -test.register_message_test( - "Min and max level attributes set capability constraint", - { + }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 5) + clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } } }, { @@ -640,26 +960,56 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) + clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 2, maximum = 4})) - } + message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + } + }, + }, + { + test_init = test_init_with_hue_sat_color_mode_set, + min_api_version = 17 } ) +hue = 0xFE +sat = 0xFE test.register_message_test( - "Min level attribute outside of range for lighting feature device (min level = 1), capability not sent", + "Set color command should clamp invalid huesat values", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 0) + clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 110, saturation = 110 } } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, { @@ -667,10 +1017,213 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) + clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorControl.hue(100)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + } + }, + }, + { + test_init = test_init_with_hue_sat_color_mode_set, + min_api_version = 17 + } +) + + + + +local mock_color_device_no_hue_sat = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-color-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.EXTENDED_COLOR, device_type_revision = 1} } } } +}) + +local function test_init_no_hue_sat() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_color_device_no_hue_sat) +end + +test.register_coroutine_test( + "Set color command should send the appropriate commands", function() + test.socket.capability:__queue_receive( + { + mock_color_device_no_hue_sat.id, + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } }}, + } + ) + test.socket.matter:__expect_send( + { + mock_color_device_no_hue_sat.id, + clusters.ColorControl.server.commands.MoveToColor(mock_color_device_no_hue_sat, 1, 15182, 21547, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) + } + ) + test.socket.matter:__queue_receive( + { + mock_color_device_no_hue_sat.id, + clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_color_device_no_hue_sat, 1) + } + ) + test.socket.matter:__queue_receive( + { + mock_color_device_no_hue_sat.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_color_device_no_hue_sat, 1, 15091) + } + ) + test.socket.matter:__queue_receive( + { + mock_color_device_no_hue_sat.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_color_device_no_hue_sat, 1, 21547) + } + ) + test.socket.capability:__expect_send( + mock_color_device_no_hue_sat:generate_test_message( + "main", capabilities.colorControl.hue(50) + ) + ) + test.socket.capability:__expect_send( + mock_color_device_no_hue_sat:generate_test_message( + "main", capabilities.colorControl.saturation(72) + ) + ) + end, + { + test_init = test_init_no_hue_sat, + min_api_version = 17 + } +) + + + +local mock_device_color_temp = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.ON_OFF, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.COLOR_TEMPERATURE, device_type_revision = 1} + } + } + } +}) + +local function test_init_color_temp() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_color_temp) +end + +test.register_coroutine_test( + "doConfigure properly sets Color Temperature Light and does not attempt to switch profiles", + function() + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_color_temp.id, + clusters.LevelControl.attributes.Options:write(mock_device_color_temp, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + test.socket.matter:__expect_send({ + mock_device_color_temp.id, + clusters.ColorControl.attributes.Options:write(mock_device_color_temp, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_color_temp:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_color_temp, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init creates accurate subscription for Color Temperature Light", + function() + local cluster_subscribe_list_color_temp = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds + } + local subscribe_request = cluster_subscribe_list_color_temp[1]:subscribe(mock_device_color_temp) + for i, cluster in ipairs(cluster_subscribe_list_color_temp) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_color_temp)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "init" }) + test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) + end, + { + test_init = test_init_color_temp, + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua new file mode 100644 index 0000000000..788a00d76e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua @@ -0,0 +1,238 @@ +-- Copyright © 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 11 then + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("water-valve-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device should be added with correct subscription and profile", + function() + local cluster_subscribe_list = { + clusters.ValveConfigurationAndControl.attributes.CurrentState, + clusters.ValveConfigurationAndControl.attributes.CurrentLevel + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "water-valve-level" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Open command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "valve", component = "main", command = "open", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_device, 1) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Close command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "valve", component = "main", command = "close", args = { } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_device, 1) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Set level command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "level", component = "main", command = "setLevel", args = { 25 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_device, 1, nil, 25) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "level", component = "main", command = "setLevel", args = { 0 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_device, 1) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Current state reports should generate appropriate events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data(mock_device, 1, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + }, + }, + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data(mock_device, 1, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data(mock_device, 1, 2) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Current level reports should generate appropriate events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.level.level(50)) + }, + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua index ca4fa7118f..5b1b569b25 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -77,7 +66,7 @@ local mock_2switch = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { @@ -86,7 +75,7 @@ local mock_2switch = test.mock_device.build_test_matter_device({ {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0100, device_type_revision = 2, -- On/Off Light + {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light } }, { @@ -95,7 +84,7 @@ local mock_2switch = test.mock_device.build_test_matter_device({ {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0100, device_type_revision = 2, -- On/Off Light + {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light } }, } @@ -115,7 +104,7 @@ local mock_3switch_non_sequential = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { @@ -124,7 +113,7 @@ local mock_3switch_non_sequential = test.mock_device.build_test_matter_device({ {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0100, device_type_revision = 2, -- On/Off Light + {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light } }, { @@ -133,7 +122,7 @@ local mock_3switch_non_sequential = test.mock_device.build_test_matter_device({ {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0100, device_type_revision = 2, -- On/Off Light + {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light } }, { @@ -142,7 +131,7 @@ local mock_3switch_non_sequential = test.mock_device.build_test_matter_device({ {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0100, device_type_revision = 2, -- On/Off Light + {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light } }, } @@ -156,6 +145,8 @@ local function test_init_mock_3switch() } test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_3switch) + + -- the following subscribe is due to the init event sent by the test framework. test.socket.matter:__expect_send({mock_3switch.id, subscribe_request}) test.mock_device.add_test_device(mock_3switch) end @@ -168,6 +159,7 @@ local function test_init_mock_2switch() } test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_2switch) + test.socket.matter:__expect_send({mock_2switch.id, subscribe_request}) test.mock_device.add_test_device(mock_2switch) end @@ -180,6 +172,7 @@ local function test_init_mock_3switch_non_sequential() } test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_3switch_non_sequential) + test.socket.matter:__expect_send({mock_3switch_non_sequential.id, subscribe_request}) test.mock_device.add_test_device(mock_3switch_non_sequential) end @@ -205,7 +198,10 @@ test.register_message_test( } } }, - { test_init = test_init_mock_3switch } + { + test_init = test_init_mock_3switch, + min_api_version = 17 + } ) -- The custom "test_init" function also checks that the appropriate profile is switched on init @@ -229,7 +225,10 @@ test.register_message_test( } } }, - { test_init = test_init_mock_2switch } + { + test_init = test_init_mock_2switch, + min_api_version = 17 + } ) -- The custom "test_init" function also checks that the appropriate profile is switched on init @@ -253,7 +252,11 @@ test.register_message_test( } } }, - { test_init = test_init_mock_3switch_non_sequential } + { + test_init = test_init_mock_3switch_non_sequential, + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua deleted file mode 100644 index 9aa2c2f72c..0000000000 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua +++ /dev/null @@ -1,469 +0,0 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local capabilities = require "st.capabilities" - -local clusters = require "st.matter.clusters" -local TRANSITION_TIME = 0 -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 - -local parent_ep = 10 -local child1_ep = 20 -local child2_ep = 30 - -local mock_device = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - 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 = parent_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light - } - }, - { - endpoint_id = child1_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2}, -- On/Off Light - {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light - } - }, - { - endpoint_id = child2_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - }, - device_types = { - {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light - } - }, - } -}) - -local child_profiles = { - [child1_ep] = t_utils.get_profile_definition("light-level.yml"), - [child2_ep] = t_utils.get_profile_definition("light-color-level.yml") -} - -local mock_children = {} -for i, endpoint in ipairs(mock_device.endpoints) do - if endpoint.endpoint_id ~= parent_ep and endpoint.endpoint_id ~= 0 then - local child_data = { - profile = child_profiles[endpoint.endpoint_id], - device_network_id = string.format("%s:%d", mock_device.id, endpoint.endpoint_id), - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) - } - mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) - end -end - -local function test_init() - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.mock_device.add_test_device(mock_device) - for _, child in pairs(mock_children) do - test.mock_device.add_test_device(child) - end - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "light-level", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child1_ep) - }) - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 3", - profile = "light-color-level", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child2_ep) - }) -end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Parent device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, parent_ep) - }, - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, parent_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - } - } -) - -test.register_message_test( - "First child device: switch capability switch should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child1_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child1_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child1_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switch.switch.on()) - } - } -) - -test.register_message_test( - "Second child device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child2_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.switch.switch.on()) - } - } -) - -test.register_message_test( - "Current level reports should generate appropriate events", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, child1_ep, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) - }, - } -) - -test.register_message_test( - "Set color temperature should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, child2_ep, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, child2_ep, 556) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, - } -) - -test.register_message_test( - "X and Y color values should report hue and saturation once both have been received", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, child2_ep, 15091) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, child2_ep, 21547) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.saturation(72)) - } - } -) - -test.register_message_test( - "Set color command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColor(mock_device, child2_ep, 15182, 21547, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, child2_ep, 15091) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, child2_ep, 21547) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.saturation(72)) - } - } -) - -test.register_message_test( - "Min and max level attributes set capability constraint for child devices", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, child1_ep, 1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, child1_ep, 254) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 1, maximum = 100})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, child2_ep, 127) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, child2_ep, 203) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 50, maximum = 80})) - } - } -) - -test.register_message_test( - "Min and max color temp attributes set capability constraint for child devices", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, child2_ep, 153) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, child2_ep, 555) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua deleted file mode 100644 index f239500d20..0000000000 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua +++ /dev/null @@ -1,231 +0,0 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local capabilities = require "st.capabilities" - -local clusters = require "st.matter.clusters" - -local child_profile = t_utils.get_profile_definition("plug-binary.yml") -local parent_ep = 10 -local child1_ep = 20 -local child2_ep = 30 - -local mock_device = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("plug-binary.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - 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 = parent_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x010A, device_type_revision = 2} -- On/Off Plug - } - }, - { - endpoint_id = child1_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x010A, device_type_revision = 2} -- On/Off Plug - } - }, - { - endpoint_id = child2_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x010A, device_type_revision = 2} -- On/Off Plug - } - }, - } -}) - -local mock_children = {} -for i, endpoint in ipairs(mock_device.endpoints) do - if endpoint.endpoint_id ~= parent_ep and endpoint.endpoint_id ~= 0 then - local child_data = { - profile = child_profile, - device_network_id = string.format("%s:%d", mock_device.id, endpoint.endpoint_id), - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) - } - mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) - end -end - -local function test_init() - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.mock_device.add_test_device(mock_device) - for _, child in pairs(mock_children) do - test.mock_device.add_test_device(child) - end - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "plug-binary", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child1_ep) - }) - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 3", - profile = "plug-binary", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child2_ep) - }) -end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Parent device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, parent_ep) - }, - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, parent_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - } - } -) - -test.register_message_test( - "First child device: switch capability switch should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child1_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child1_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child1_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switch.switch.on()) - } - } -) - -test.register_message_test( - "Second child device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child2_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.switch.switch.on()) - } - } -) - -test.register_coroutine_test( - "Added should call refresh for child devices", function() - test.socket.matter:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_children[child1_ep].id, "added" }) - local req = clusters.OnOff.attributes.OnOff:read(mock_children[child1_ep]) - test.socket.matter:__expect_send({mock_device.id, req}) - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_stateless_step.lua b/drivers/SmartThings/matter-switch/src/test/test_stateless_step.lua new file mode 100644 index 0000000000..a92c547ac7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_stateless_step.lua @@ -0,0 +1,217 @@ +-- 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.matter.clusters" + +local mock_device_color_temp = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light + {device_type_id = 0x010C, device_type_revision = 1} -- Color Temperature Light + } + } + } +}) + +local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, +} + +local function test_init() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_color_temp) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_color_temp)) + end + end + test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_color_temp) +end +test.set_test_init_function(test_init) + +local fields = require "switch_utils.fields" + +test.register_message_test( + "Color Temperature Step Command Test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device_color_temp, 1, clusters.ColorControl.types.StepModeEnum.DOWN, 60, fields.DEFAULT_STEP_TRANSITION_TIME, fields.DEFAULT_MIRED_MIN, fields.DEFAULT_MIRED_MAX, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 90 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device_color_temp, 1, clusters.ColorControl.types.StepModeEnum.DOWN, 271, fields.DEFAULT_STEP_TRANSITION_TIME, fields.DEFAULT_MIRED_MIN, fields.DEFAULT_MIRED_MAX, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device_color_temp, 1, clusters.ColorControl.types.StepModeEnum.UP, 151, fields.DEFAULT_STEP_TRANSITION_TIME, fields.DEFAULT_MIRED_MIN, fields.DEFAULT_MIRED_MAX, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + } + }, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Level Step Command Test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.LevelControl.server.commands.Step(mock_device_color_temp, 1, clusters.LevelControl.types.StepModeEnum.UP, 64, fields.DEFAULT_STEP_TRANSITION_TIME, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.LevelControl.server.commands.Step(mock_device_color_temp, 1, clusters.LevelControl.types.StepModeEnum.DOWN, 127, fields.DEFAULT_STEP_TRANSITION_TIME, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 100 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.LevelControl.server.commands.Step(mock_device_color_temp, 1, clusters.LevelControl.types.StepModeEnum.UP, 254, fields.DEFAULT_STEP_TRANSITION_TIME, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_third_reality_subdrivers.lua b/drivers/SmartThings/matter-switch/src/test/test_third_reality_subdrivers.lua new file mode 100644 index 0000000000..a32ce8f8ff --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_third_reality_subdrivers.lua @@ -0,0 +1,533 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local dkjson = require "dkjson" +local utils = require "st.utils" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("garage-door-battery.yml"), + manufacturer_info = {vendor_id = 0x1407, product_id = 0x1098}, + 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.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.Feature.BATTERY, + }, + }, + device_types = { + {device_type_id = 0x010A, device_type_revision = 1} -- On/Off Plug-in Unit + } + } + } +}) + +-- The subscribe list matches the profile capabilities: +-- doorControl -> OnOff.attributes.OnOff +-- battery -> PowerSource.attributes.BatPercentRemaining +-- The subdriver overrides device_init, so the main driver's extend_device("subscribe", ...) +-- is not called; the default device:subscribe() is used, which does not add AttributeList. +local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.PowerSource.attributes.BatPercentRemaining, +} + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + -- added lifecycle: subdriver overrides device_added to a no-op so no subscribe here + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + + -- init lifecycle: device_init subscribes + test.socket.device_lifecycle:__queue_receive({mock_device.id, "init"}) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + -- doConfigure: sets battery support field and updates profile metadata + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({profile = "garage-door-battery"}) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) +end + +test.set_test_init_function(test_init) + +-- ── Attribute handler tests ────────────────────────────────────────────────── + +test.register_message_test( + "OnOff true should emit door.open", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.open()) + } + } +) + +test.register_message_test( + "OnOff false should emit door.closed", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.closed()) + } + } +) + +-- ── Capability command tests ───────────────────────────────────────────────── + +test.register_message_test( + "doorControl open command should emit opening then send OnOff.On", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "doorControl", component = "main", command = "open", args = {}} + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.opening()) + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, 1) + } + } + } +) + +test.register_message_test( + "doorControl close command should emit closing then send OnOff.Off", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "doorControl", component = "main", command = "close", args = {}} + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.closing()) + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, 1) + } + } + } +) + +-- ── Battery attribute tests ─────────────────────────────────────────────────── + +test.register_message_test( + "BatPercentRemaining report should emit correct battery percentage", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(mock_device, 1, 200) + } + }, + { + channel = "capability", + direction = "send", + -- BatPercentRemaining is in units of 0.5%, so 200 = 100% + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + } +) + +test.register_message_test( + "BatPercentRemaining report of 150 should emit 75% battery", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(mock_device, 1, 150) + } + }, + { + channel = "capability", + direction = "send", + -- 150 * 0.5 = 75% + message = mock_device:generate_test_message("main", capabilities.battery.battery(75)) + } + } +) + +-- ── Profile / driverSwitched tests ──────────────────────────────────────────── + +test.register_coroutine_test( + "doConfigure should set garage-door-battery profile", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({profile = "garage-door-battery"}) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) + end, + {min_api_version = 17} +) + +test.register_coroutine_test( + "driverSwitched should restore garage-door-battery profile", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "driverSwitched"}) + mock_device:expect_metadata_update({profile = "garage-door-battery"}) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) + end, + {min_api_version = 17} +) + + +local mock_device_misprofiled = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-binary.yml"), + manufacturer_info = {vendor_id = 0x1407, product_id = 0x1098}, + 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.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.Feature.BATTERY, + }, + }, + device_types = { + {device_type_id = 0x010A, device_type_revision = 1} -- On/Off Plug-in Unit + } + } + } +}) + +test.register_coroutine_test( + "doConfigure should correct profile if misprofiled", + function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_misprofiled) + + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_misprofiled) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_misprofiled)) end + end + + -- added lifecycle: subdriver overrides device_added to a no-op so no subscribe here + test.socket.device_lifecycle:__queue_receive({mock_device_misprofiled.id, "added"}) + + -- init lifecycle: device_init subscribes + test.socket.device_lifecycle:__queue_receive({mock_device_misprofiled.id, "init"}) + test.socket.matter:__expect_send({mock_device_misprofiled.id, subscribe_request}) + + -- doConfigure: sets battery support field and updates profile metadata + test.socket.device_lifecycle:__queue_receive({mock_device_misprofiled.id, "doConfigure"}) + mock_device_misprofiled:expect_metadata_update({profile = "garage-door-battery"}) + mock_device_misprofiled:expect_metadata_update({provisioning_state = "PROVISIONED"}) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_misprofiled) end, + } +) + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("12-button-keyboard.yml"), + manufacturer_info = {vendor_id = 0x1407, product_id = 0x1388}, + 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.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 2, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 4, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 5, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 6, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 7, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + +local function configure_buttons() + for key = 1, 12 do + local component = "F" .. key + if key == 1 then component = "main" end + test.socket.capability:__expect_send(mock_device:generate_test_message(component, capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + end +end + +local function test_init_mk1() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.Switch.events.InitialPress + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "12-button-keyboard" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + configure_buttons() + + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + device_info_copy.profile.id = "12-buttons-keyboard" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) + configure_buttons() + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) +end + +test.register_coroutine_test( + "Handle single press sequence", + function() + for key = 1, 12 do + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(mock_device, key, {new_position = 1}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(key == 1 and "main" or "F" .. key, capabilities.button.button.pushed({state_change = true})) + ) + end + end, + { + test_init = test_init_mk1, + min_api_version = 17 + } +) + +-- run the tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/fingerprints.yml b/drivers/SmartThings/matter-thermostat/fingerprints.yml index 6a711970d5..814261790e 100644 --- a/drivers/SmartThings/matter-thermostat/fingerprints.yml +++ b/drivers/SmartThings/matter-thermostat/fingerprints.yml @@ -1,49 +1,213 @@ +matterManufacturer: + - id: "matter/samsung-room-air-conditioner" + deviceLabel: Samsung Room Air Conditioner + vendorId: 0x10e1 + productId: 0x1023 + deviceProfileName: room-air-conditioner + #Aidot + - id: "Leedarson AiDot Air Purifier" + deviceLabel: AiDot Air Purifier + vendorId: 0x1396 + productId: 0x043A + deviceProfileName: air-purifier-hepa + #Bosch + - id: "4617/12306" + deviceLabel: Radiator Thermostat II [M] + vendorId: 0x1209 + productId: 0x3012 + deviceProfileName: thermostat-heating-only-nostate + #Cync + - id: "4921/121" + deviceLabel: Cync Fan Switch + vendorId: 0x1339 + productId: 0x0079 + deviceProfileName: fan-generic + #Eve + - id: "4874/79" + deviceLabel: Eve Thermo + vendorId: 0x130A + productId: 0x004F + deviceProfileName: thermostat-heating-only-nostate + - id: "4874/125" + deviceLabel: Eve Thermo + vendorId: 0x130A + productId: 0x007D + deviceProfileName: thermostat-heating-only-nostate + - id: "4874/127" + deviceLabel: Eve Thermostat (Europe) + vendorId: 0x130A + productId: 0x007F + deviceProfileName: thermostat-heating-only-nostate-nobattery + #Habi + - id: "5415/1" + deviceLabel: habi Matter Wireless Room Thermostat + vendorId: 0x1527 + productId: 0x0001 + deviceProfileName: thermostat-heating-only-batteryLevel + - id: "5415/2" + deviceLabel: habi Smart TRV + vendorId: 0x1527 + productId: 0x0002 + deviceProfileName: thermostat-heating-only-batteryLevel + #Leviton + - id: "4251/4101" + deviceLabel: Decora Smart Wi-Fi (2ndGen) Fan Speed Controller + vendorId: 0x109B + productId: 0x1005 + deviceProfileName: fan-generic + #Lux + - id: "4614/1" + deviceLabel: LUX TQ1 Smart Thermostat + vendorId: 0x1206 + productId: 0x0001 + deviceProfileName: thermostat-nostate-nobattery + - id: "4614/17" + deviceLabel: LUX TQX Smart Thermostat + vendorId: 0x1206 + productId: 0x0011 + deviceProfileName: thermostat-humidity-nostate-nobattery + #Meross + - id: "4933/57345" + deviceLabel: Smart Wi-Fi Thermostat + vendorId: 0x1345 + productId: 0xE001 + deviceProfileName: thermostat-fan-nostate-nobattery + #Siterwell + - id: "4736/769" + deviceLabel: Siterwell Radiator Thermostat + vendorId: 0x1280 + productId: 0x0301 + deviceProfileName: thermostat-heating-only-nostate-nobattery + #Tado + - id: "4942/1" + deviceLabel: Smart Radiator Thermostat X + vendorId: 0x134E + productId: 0x0001 + deviceProfileName: thermostat-humidity-heating-only-nostate-nobattery + - id: "4942/2" + deviceLabel: Smart Thermostat X + vendorId: 0x134E + productId: 0x0002 + deviceProfileName: thermostat-humidity-heating-only-nostate-nobattery + #Taruie + - id: "5151/4101" + deviceLabel: TARUIE AC Remote + vendorId: 0x141F + productId: 0x1005 + deviceProfileName: thermostat-humidity + #720 + - id: "5482/1" + deviceLabel: xCREAS Smart Air Purifier Cyclone400 + vendorId: 0x156A + productId: 0x0001 + deviceProfileName: air-purifier-hepa-wind-aqs-pm25-meas-pm25-level + #Gerlsair + - id: "5493/1" + deviceLabel: Smart AC Controller + vendorId: 0x1575 + productId: 0x0001 + deviceProfileName: thermostat-modular matterGeneric: - id: "matter/hvac/heatcool" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0300 # HVAC Heating/Cooling Unit - deviceProfileName: thermostat + deviceProfileName: thermostat-nostate-nobattery - id: "matter/hvac/thermostat" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0301 # Thermostat - deviceProfileName: thermostat + deviceProfileName: thermostat-nostate-nobattery - id: "matter/hvac/heatcool/humidity" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0300 # HVAC Heating/Cooling Unit - id: 0x0307 # Humidity Sensor - deviceProfileName: thermostat-humidity - - id: "matter/hvac/themostat/humidity" + deviceProfileName: thermostat-humidity-nostate-nobattery + - id: "matter/hvac/thermostat/humidity" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0301 # Thermostat - id: 0x0307 # Humidity Sensor - deviceProfileName: thermostat-humidity + deviceProfileName: thermostat-humidity-nostate-nobattery - id: "matter/hvac/heatcool/fan" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0300 # HVAC Heating/Cooling Unit - id: 0x002B # Fan - deviceProfileName: thermostat-fan + deviceProfileName: thermostat-fan-nostate-nobattery - id: "matter/hvac/thermostat/fan" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0301 # Thermostat - id: 0x002B # Fan - deviceProfileName: thermostat-fan + deviceProfileName: thermostat-fan-nostate-nobattery - id: "matter/hvac/heatcool/humidity/fan" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0300 # HVAC Heating/Cooling Unit - id: 0x0307 # Humidity Sensor - id: 0x002B # Fan - deviceProfileName: thermostat-humidity-fan - - id: "matter/hvac/themostat/humidity/fan" + deviceProfileName: thermostat-humidity-fan-nostate-nobattery + - id: "matter/hvac/thermostat/humidity/fan" deviceLabel: Matter Thermostat deviceTypes: - id: 0x0301 # Thermostat - id: 0x0307 # Humidity Sensor - id: 0x002B # Fan - deviceProfileName: thermostat-humidity-fan + deviceProfileName: thermostat-humidity-fan-nostate-nobattery + - id: "matter/room-air-conditioner" + deviceLabel: Matter Room Air Conditioner + deviceTypes: + - id: 0x0072 + deviceProfileName: room-air-conditioner-fan-heating-cooling-nostate + - id: "matter/fan" + deviceLabel: Matter Fan + deviceTypes: + - id: 0x002B + deviceProfileName: fan-generic + - id: "matter/air-purifier" + deviceLabel: Matter Air Purifier + deviceTypes: + - id: 0x002D # Air Purifier + deviceProfileName: air-purifier + - id: "matter/air-purifier/quality-sensor" + deviceLabel: Matter Air Purifier & Quality Sensor + deviceTypes: + - id: 0x002D # Air Purifier + - id: 0x002C # Air Quality Sensor + deviceProfileName: air-purifier-aqs + - id: "matter/ap/aqs/thermostat/humidity" + deviceLabel: Matter Air Purifier & Quality Sensor + deviceTypes: + - id: 0x002D # Air Purifier + - id: 0x002C # Air Quality Sensor + - id: 0x0301 # Thermostat + - id: 0x0307 # Humidity Sensor + deviceProfileName: air-purifier-thermostat-nostate-humidity-aqs + - id: "matter/ap/aqs/temperature/humidity" + deviceLabel: Matter Air Purifier & Quality Sensor + deviceTypes: + - id: 0x002D # Air Purifier + - id: 0x002C # Air Quality Sensor + - id: 0x0302 # Temperature Sensor + - id: 0x0307 # Humidity Sensor + deviceProfileName: air-purifier-temperature-humidity-aqs + - id: "matter/water-heater" + deviceLabel: Matter Water Heater + deviceTypes: + - id: 0x050F # Water Heater + deviceProfileName: water-heater + - id: "matter/heat-pump" + deviceLabel: Matter Heat Pump + deviceTypes: + - id: 0x0309 # Heat Pump + - id: 0x0510 # Electrical Sensor + deviceProfileName: heat-pump + - id: "matter/heat-pump/thermostat" + deviceLabel: Matter Heat Pump + deviceTypes: + - id: 0x0309 # Heat Pump + - id: 0x0510 # Electrical Sensor + - id: 0x0301 # Thermostat + deviceProfileName: heat-pump-thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock-wind.yml new file mode 100644 index 0000000000..55f9910fad --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock-wind.yml @@ -0,0 +1,28 @@ +name: air-purifier-ac-rock-wind +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock.yml new file mode 100644 index 0000000000..5b9b5fd3d7 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-rock.yml @@ -0,0 +1,26 @@ +name: air-purifier-ac-rock +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-wind.yml new file mode 100644 index 0000000000..16a1f63951 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac-wind.yml @@ -0,0 +1,26 @@ +name: air-purifier-ac-wind +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac.yml new file mode 100644 index 0000000000..ad70bc15ef --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-ac.yml @@ -0,0 +1,24 @@ +name: air-purifier-ac +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-aqs.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-aqs.yml new file mode 100644 index 0000000000..3a9d7b1f0b --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-aqs.yml @@ -0,0 +1,16 @@ +name: air-purifier-aqs +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml new file mode 100644 index 0000000000..f568aaac1d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml @@ -0,0 +1,61 @@ +name: air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: windMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatMode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: dustSensor + version: 1 + - id: formaldehydeMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: dustHealthConcern + version: 1 + - id: fineDustHealthConcern + version: 1 + - id: formaldehydeHealthConcern + version: 1 + - id: nitrogenDioxideHealthConcern + version: 1 + - id: tvocHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa Filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated Carbon Filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind.yml new file mode 100644 index 0000000000..76a7cad7d5 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock-wind.yml @@ -0,0 +1,37 @@ +name: air-purifier-hepa-ac-rock-wind +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock.yml new file mode 100644 index 0000000000..e32e5aa68d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-rock.yml @@ -0,0 +1,35 @@ +name: air-purifier-hepa-ac-rock +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-temperature-humidity-fan-aqs-pm25-tvoc-meas.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-temperature-humidity-fan-aqs-pm25-tvoc-meas.yml new file mode 100644 index 0000000000..a234da4467 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-temperature-humidity-fan-aqs-pm25-tvoc-meas.yml @@ -0,0 +1,43 @@ +name: air-purifier-hepa-ac-temperature-humidity-fan-aqs-pm25-tvoc-meas +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: temperatureMeasurement + version: 1 + - id: fineDustSensor + version: 1 + - id: tvocMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa Filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated Carbon Filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-thermostat-humidity-fan-nostate-nobattery-aqs-pm25-tvoc-meas.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-thermostat-humidity-fan-nostate-nobattery-aqs-pm25-tvoc-meas.yml new file mode 100644 index 0000000000..707806c317 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-thermostat-humidity-fan-nostate-nobattery-aqs-pm25-tvoc-meas.yml @@ -0,0 +1,49 @@ +name: air-purifier-hepa-ac-thermostat-humidity-fan-nostate-nobattery-aqs-pm25-tvoc-meas +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: fineDustSensor + version: 1 + - id: tvocMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa Filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated Carbon Filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-wind.yml new file mode 100644 index 0000000000..ac2a97c339 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac-wind.yml @@ -0,0 +1,35 @@ +name: air-purifier-hepa-ac-wind +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac.yml new file mode 100644 index 0000000000..afb3999bca --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-ac.yml @@ -0,0 +1,33 @@ +name: air-purifier-hepa-ac +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier +- id: activatedCarbonFilter + label: Activated carbon filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs-pm25-meas.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs-pm25-meas.yml new file mode 100644 index 0000000000..f06a6913de --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs-pm25-meas.yml @@ -0,0 +1,28 @@ +name: air-purifier-hepa-aqs-pm25-meas +components: +- id: main + label: Main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fineDustSensor + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs.yml new file mode 100644 index 0000000000..c49c09ee0e --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-aqs.yml @@ -0,0 +1,26 @@ +name: air-purifier-hepa-aqs +components: +- id: main + label: Main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock-wind.yml new file mode 100644 index 0000000000..d93eac02af --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock-wind.yml @@ -0,0 +1,28 @@ +name: air-purifier-hepa-rock-wind +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock.yml new file mode 100644 index 0000000000..46cfa0ca9c --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-rock.yml @@ -0,0 +1,26 @@ +name: air-purifier-hepa-rock +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind-aqs-pm25-meas-pm25-level.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind-aqs-pm25-meas-pm25-level.yml new file mode 100644 index 0000000000..3f014f0b91 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind-aqs-pm25-meas-pm25-level.yml @@ -0,0 +1,32 @@ +name: air-purifier-hepa-wind-aqs-pm25-meas-pm25-level +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: fineDustSensor + version: 1 + - id: fineDustHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa Filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier \ No newline at end of file diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind.yml new file mode 100644 index 0000000000..43a812dbe9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa-wind.yml @@ -0,0 +1,26 @@ +name: air-purifier-hepa-wind +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa.yml new file mode 100644 index 0000000000..e095051220 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-hepa.yml @@ -0,0 +1,24 @@ +name: air-purifier-hepa +components: +- id: main + label: Main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier +- id: hepaFilter + label: Hepa filter + capabilities: + - id: filterState + version: 1 + - id: filterStatus + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml new file mode 100644 index 0000000000..7cb292c3b1 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml @@ -0,0 +1,130 @@ +name: air-purifier-modular +components: + - id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + optional: true + - id: windMode + version: 1 + optional: true + - id: thermostatHeatingSetpoint + version: 1 + optional: true + - id: thermostatCoolingSetpoint + version: 1 + optional: true + - id: thermostatMode + version: 1 + optional: true + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + optional: true + - id: temperatureMeasurement + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: airQualityHealthConcern + version: 1 + optional: true + - id: carbonMonoxideMeasurement + version: 1 + optional: true + - id: carbonMonoxideHealthConcern + version: 1 + optional: true + - id: carbonDioxideMeasurement + version: 1 + optional: true + - id: carbonDioxideHealthConcern + version: 1 + optional: true + - id: nitrogenDioxideMeasurement + version: 1 + optional: true + - id: nitrogenDioxideHealthConcern + version: 1 + optional: true + - id: ozoneMeasurement + version: 1 + optional: true + - id: ozoneHealthConcern + version: 1 + optional: true + - id: formaldehydeMeasurement + version: 1 + optional: true + - id: formaldehydeHealthConcern + version: 1 + optional: true + - id: veryFineDustSensor + version: 1 + optional: true + - id: veryFineDustHealthConcern + version: 1 + optional: true + - id: fineDustSensor + version: 1 + optional: true + - id: fineDustHealthConcern + version: 1 + optional: true + - id: dustSensor + version: 1 + optional: true + - id: dustHealthConcern + version: 1 + optional: true + - id: radonMeasurement + version: 1 + optional: true + - id: radonHealthConcern + version: 1 + optional: true + - id: tvocMeasurement + version: 1 + optional: true + - id: tvocHealthConcern + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier + - id: activatedCarbonFilter + optional: true + capabilities: + - id: filterState + version: 1 + optional: true + - id: filterStatus + version: 1 + optional: true + categories: + - name: AirPurifier + - id: hepaFilter + optional: true + capabilities: + - id: filterState + version: 1 + optional: true + - id: filterStatus + version: 1 + optional: true + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock-wind.yml new file mode 100644 index 0000000000..3ced08a409 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock-wind.yml @@ -0,0 +1,18 @@ +name: air-purifier-rock-wind +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock.yml new file mode 100644 index 0000000000..c35867d2d1 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-rock.yml @@ -0,0 +1,16 @@ +name: air-purifier-rock +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-temperature-humidity-aqs.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-temperature-humidity-aqs.yml new file mode 100644 index 0000000000..7305fcfc45 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-temperature-humidity-aqs.yml @@ -0,0 +1,20 @@ +name: air-purifier-temperature-humidity-aqs +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-humidity-aqs.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-humidity-aqs.yml new file mode 100644 index 0000000000..58e285e33b --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-humidity-aqs.yml @@ -0,0 +1,35 @@ +name: air-purifier-thermostat-humidity-aqs +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: relativeHumidityMeasurement + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-nostate-humidity-aqs.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-nostate-humidity-aqs.yml new file mode 100644 index 0000000000..0e4cd24f7c --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-thermostat-nostate-humidity-aqs.yml @@ -0,0 +1,26 @@ +name: air-purifier-thermostat-nostate-humidity-aqs +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: airQualityHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-wind.yml new file mode 100644 index 0000000000..c8f3a6280c --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-wind.yml @@ -0,0 +1,16 @@ +name: air-purifier-wind +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier.yml new file mode 100644 index 0000000000..1447fdd396 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier.yml @@ -0,0 +1,14 @@ +name: air-purifier +components: +- id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/fan-generic.yml b/drivers/SmartThings/matter-thermostat/profiles/fan-generic.yml new file mode 100644 index 0000000000..b58204ce69 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/fan-generic.yml @@ -0,0 +1,17 @@ +name: fan-generic +components: +- id: main + capabilities: + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan +metadata: + mnmn: SmartThingsEdge + vid: generic-fan diff --git a/drivers/SmartThings/matter-thermostat/profiles/fan-rock-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/fan-rock-wind.yml new file mode 100644 index 0000000000..b87076b9ff --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/fan-rock-wind.yml @@ -0,0 +1,21 @@ +name: fan-rock-wind +components: +- id: main + capabilities: + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan +metadata: + mnmn: SmartThingsEdge + vid: generic-fan-rock-wind diff --git a/drivers/SmartThings/matter-thermostat/profiles/fan-rock.yml b/drivers/SmartThings/matter-thermostat/profiles/fan-rock.yml new file mode 100644 index 0000000000..f4aa4a8654 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/fan-rock.yml @@ -0,0 +1,19 @@ +name: fan-rock +components: +- id: main + capabilities: + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan +metadata: + mnmn: SmartThingsEdge + vid: generic-fan-rock diff --git a/drivers/SmartThings/matter-thermostat/profiles/fan-wind.yml b/drivers/SmartThings/matter-thermostat/profiles/fan-wind.yml new file mode 100644 index 0000000000..71a6597406 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/fan-wind.yml @@ -0,0 +1,19 @@ +name: fan-wind +components: +- id: main + capabilities: + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan +metadata: + mnmn: SmartThingsEdge + vid: generic-fan-wind diff --git a/drivers/SmartThings/matter-thermostat/profiles/fan.yml b/drivers/SmartThings/matter-thermostat/profiles/fan.yml new file mode 100644 index 0000000000..e86941d625 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/fan.yml @@ -0,0 +1,20 @@ +# Deprecated: do not use this profile for device fingerprinting. +name: fan +components: +- id: main + capabilities: + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan +metadata: + mnmn: SmartThingsEdge + vid: generic-fan-wind diff --git a/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat-humidity.yml b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat-humidity.yml new file mode 100644 index 0000000000..0adca2daea --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat-humidity.yml @@ -0,0 +1,46 @@ +name: heat-pump-thermostat-humidity-thermostat-humidity +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HeatPump +- id: thermostatOne + label: Thermostat 1 + capabilities: + - id: thermostatMode + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump +- id: thermostatTwo + label: Thermostat 2 + capabilities: + - id: thermostatMode + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump diff --git a/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat.yml b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat.yml new file mode 100644 index 0000000000..47b9203694 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity-thermostat.yml @@ -0,0 +1,44 @@ +name: heat-pump-thermostat-humidity-thermostat +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HeatPump +- id: thermostatOne + label: Thermostat 1 + capabilities: + - id: thermostatMode + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump +- id: thermostatTwo + label: Thermostat 2 + capabilities: + - id: thermostatMode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump diff --git a/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity.yml b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity.yml new file mode 100644 index 0000000000..84aa49caf2 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-humidity.yml @@ -0,0 +1,31 @@ +name: heat-pump-thermostat-humidity +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HeatPump +- id: thermostatOne + label: Thermostat 1 + capabilities: + - id: thermostatMode + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump diff --git a/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat-humidity.yml b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat-humidity.yml new file mode 100644 index 0000000000..a41ac01165 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat-humidity.yml @@ -0,0 +1,44 @@ +name: heat-pump-thermostat-thermostat-humidity +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HeatPump +- id: thermostatOne + label: Thermostat 1 + capabilities: + - id: thermostatMode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump +- id: thermostatTwo + label: Thermostat 2 + capabilities: + - id: thermostatMode + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump diff --git a/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat.yml b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat.yml new file mode 100644 index 0000000000..418fd21fe0 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat-thermostat.yml @@ -0,0 +1,42 @@ +name: heat-pump-thermostat-thermostat +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HeatPump +- id: thermostatOne + label: Thermostat 1 + capabilities: + - id: thermostatMode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump +- id: thermostatTwo + label: Thermostat 2 + capabilities: + - id: thermostatMode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump diff --git a/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat.yml b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat.yml new file mode 100644 index 0000000000..d4c0ed43fa --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/heat-pump-thermostat.yml @@ -0,0 +1,29 @@ +name: heat-pump-thermostat +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HeatPump +- id: thermostatOne + label: Thermostat 1 + capabilities: + - id: thermostatMode + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + categories: + - name: HeatPump diff --git a/drivers/SmartThings/matter-thermostat/profiles/heat-pump.yml b/drivers/SmartThings/matter-thermostat/profiles/heat-pump.yml new file mode 100644 index 0000000000..fc50bd6745 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/heat-pump.yml @@ -0,0 +1,16 @@ +name: heat-pump +components: +- id: main + capabilities: + - id: energyMeter + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HeatPump diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling-nostate.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling-nostate.yml new file mode 100644 index 0000000000..b1cf2fb916 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling-nostate.yml @@ -0,0 +1,24 @@ +name: room-air-conditioner-fan-heating-cooling-nostate +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling.yml new file mode 100644 index 0000000000..c039b7fba4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-heating-cooling.yml @@ -0,0 +1,33 @@ +name: room-air-conditioner-fan-heating-cooling +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-wind-heating-cooling.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-wind-heating-cooling.yml new file mode 100644 index 0000000000..f756f810bf --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-fan-wind-heating-cooling.yml @@ -0,0 +1,35 @@ +name: room-air-conditioner-fan-wind-heating-cooling +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-heating-cooling.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-heating-cooling.yml new file mode 100644 index 0000000000..f3a5430eff --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-heating-cooling.yml @@ -0,0 +1,29 @@ +name: room-air-conditioner-heating-cooling +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-fan-heating-cooling.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-fan-heating-cooling.yml new file mode 100644 index 0000000000..e03bbd60db --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-fan-heating-cooling.yml @@ -0,0 +1,35 @@ +name: room-air-conditioner-humidity-fan-heating-cooling +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-heating-cooling.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-heating-cooling.yml new file mode 100644 index 0000000000..1b88065c6a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-humidity-heating-cooling.yml @@ -0,0 +1,34 @@ +name: room-air-conditioner-humidity-heating-cooling +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: airConditionerFanMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner + diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-modular.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-modular.yml new file mode 100644 index 0000000000..940d864ad9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-modular.yml @@ -0,0 +1,38 @@ +name: room-air-conditioner-modular +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + optional: true + - id: thermostatCoolingSetpoint + version: 1 + optional: true + - id: thermostatOperatingState + version: 1 + optional: true + - id: airConditionerFanMode + version: 1 + optional: true + - id: fanSpeedPercent + version: 1 + optional: true + - id: windMode + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner + diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner.yml new file mode 100644 index 0000000000..16a451f46c --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner.yml @@ -0,0 +1,38 @@ +name: room-air-conditioner +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: airConditionerFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: windMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-batteryLevel.yml new file mode 100644 index 0000000000..d280cb9678 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-batteryLevel.yml @@ -0,0 +1,29 @@ +name: thermostat-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-batteryLevel.yml new file mode 100644 index 0000000000..d9a42dd967 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-batteryLevel.yml @@ -0,0 +1,26 @@ +name: thermostat-cooling-only-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nobattery.yml new file mode 100644 index 0000000000..45858cfdc9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nobattery.yml @@ -0,0 +1,24 @@ +name: thermostat-cooling-only-nobattery +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nostate-batteryLevel.yml new file mode 100644 index 0000000000..cfca01665f --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only-nostate-batteryLevel.yml @@ -0,0 +1,18 @@ +name: thermostat-cooling-only-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only.yml index bfc97b2a46..7bb752d6e4 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-cooling-only.yml @@ -10,6 +10,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-batteryLevel.yml new file mode 100644 index 0000000000..d4d34c5dca --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-batteryLevel.yml @@ -0,0 +1,31 @@ +name: thermostat-fan-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-batteryLevel.yml new file mode 100644 index 0000000000..218c40fad3 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-batteryLevel.yml @@ -0,0 +1,28 @@ +name: thermostat-fan-cooling-only-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-nostate-batteryLevel.yml new file mode 100644 index 0000000000..1eaa9ad010 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only-nostate-batteryLevel.yml @@ -0,0 +1,20 @@ +name: thermostat-fan-cooling-only-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only.yml index 26ad79c36f..4fbe304a2c 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-cooling-only.yml @@ -12,6 +12,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-batteryLevel.yml new file mode 100644 index 0000000000..0c92a889c5 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-batteryLevel.yml @@ -0,0 +1,28 @@ +name: thermostat-fan-heating-only-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-nostate-batteryLevel.yml new file mode 100644 index 0000000000..3f76c1f143 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only-nostate-batteryLevel.yml @@ -0,0 +1,20 @@ +name: thermostat-fan-heating-only-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only.yml index cc6428d2d8..fa82f6199a 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-heating-only.yml @@ -12,6 +12,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-nostate-batteryLevel.yml new file mode 100644 index 0000000000..422e754cf4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan-nostate-batteryLevel.yml @@ -0,0 +1,22 @@ +name: thermostat-fan-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan.yml index 38fbb10d54..430502ea25 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-fan.yml @@ -14,6 +14,13 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-batteryLevel.yml new file mode 100644 index 0000000000..9139f69354 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-batteryLevel.yml @@ -0,0 +1,26 @@ +name: thermostat-heating-only-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nobattery.yml new file mode 100644 index 0000000000..a5c946b6c1 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nobattery.yml @@ -0,0 +1,24 @@ +name: thermostat-heating-only-nobattery +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nostate-batteryLevel.yml new file mode 100644 index 0000000000..715e3fd2ea --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only-nostate-batteryLevel.yml @@ -0,0 +1,18 @@ +name: thermostat-heating-only-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only.yml index a1abea80a1..19903b050b 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-heating-only.yml @@ -10,6 +10,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-batteryLevel.yml new file mode 100644 index 0000000000..627c63e1d5 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-batteryLevel.yml @@ -0,0 +1,31 @@ +name: thermostat-humidity-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-batteryLevel.yml new file mode 100644 index 0000000000..9a9466c375 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-batteryLevel.yml @@ -0,0 +1,28 @@ +name: thermostat-humidity-cooling-only-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-batteryLevel.yml new file mode 100644 index 0000000000..611ccc11ec --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-batteryLevel.yml @@ -0,0 +1,20 @@ +name: thermostat-humidity-cooling-only-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-nobattery.yml index 12e93cc09f..8eaf7a5835 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-nobattery.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate-nobattery.yml @@ -16,3 +16,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate.yml index 79ea468ff6..80b6b1515f 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only-nostate.yml @@ -18,3 +18,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only.yml index fe8ff1cd34..b49be4625a 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-cooling-only.yml @@ -10,6 +10,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling - id: relativeHumidityMeasurement version: 1 - id: battery @@ -20,3 +26,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-batteryLevel.yml new file mode 100644 index 0000000000..b19dff7de4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-batteryLevel.yml @@ -0,0 +1,33 @@ +name: thermostat-humidity-fan-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate-nobattery.yml index 52cc208aa5..006db089b5 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate-nobattery.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate-nobattery.yml @@ -18,3 +18,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate.yml index ab6a9d25a3..4af2f53d89 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only-nostate.yml @@ -20,3 +20,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only.yml index 0281333788..30d950eb33 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-cooling-only.yml @@ -12,6 +12,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling - id: relativeHumidityMeasurement version: 1 - id: battery @@ -22,3 +28,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-batteryLevel.yml new file mode 100644 index 0000000000..e268395cc8 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-batteryLevel.yml @@ -0,0 +1,30 @@ +name: thermostat-humidity-fan-heating-only-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-batteryLevel.yml new file mode 100644 index 0000000000..8562237f19 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-batteryLevel.yml @@ -0,0 +1,22 @@ +name: thermostat-humidity-fan-heating-only-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-nobattery.yml index 0b179572c5..1e40d88196 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-nobattery.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate-nobattery.yml @@ -18,3 +18,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate.yml index b5d776bff1..61fc412308 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only-nostate.yml @@ -20,3 +20,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only.yml index dce5101409..2261c791fd 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-heating-only.yml @@ -12,6 +12,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating - id: relativeHumidityMeasurement version: 1 - id: battery @@ -22,3 +28,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-batteryLevel.yml new file mode 100644 index 0000000000..3ab5a21861 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-batteryLevel.yml @@ -0,0 +1,24 @@ +name: thermostat-humidity-fan-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-nobattery.yml index 198814e49c..347a329dd4 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-nobattery.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate-nobattery.yml @@ -20,3 +20,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate.yml index f5ebc9e86e..7178129b20 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan-nostate.yml @@ -22,3 +22,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan.yml index b01df0cfa5..21ab1166a3 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-fan.yml @@ -14,6 +14,13 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating - id: relativeHumidityMeasurement version: 1 - id: battery @@ -24,3 +31,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-batteryLevel.yml new file mode 100644 index 0000000000..13bcef5390 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-batteryLevel.yml @@ -0,0 +1,28 @@ +name: thermostat-humidity-heating-only-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-batteryLevel.yml new file mode 100644 index 0000000000..788e2f46b2 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-batteryLevel.yml @@ -0,0 +1,20 @@ +name: thermostat-humidity-heating-only-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-nobattery.yml index f189163979..700c8e9e25 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-nobattery.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate-nobattery.yml @@ -16,3 +16,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate.yml index 19c790bd33..bf792aabed 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only-nostate.yml @@ -18,3 +18,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only.yml index 850063964d..3c60ca9967 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-heating-only.yml @@ -10,6 +10,12 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - heating - id: relativeHumidityMeasurement version: 1 - id: battery @@ -20,3 +26,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-batteryLevel.yml new file mode 100644 index 0000000000..14232bdbb5 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-batteryLevel.yml @@ -0,0 +1,22 @@ +name: thermostat-humidity-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-nobattery.yml index 8006623980..2c710df101 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-nobattery.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate-nobattery.yml @@ -18,3 +18,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate.yml index b8a092a2b8..8c39c5dfc5 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity-nostate.yml @@ -20,3 +20,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity.yml index 01e16de8ca..8c8a20a870 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-humidity.yml @@ -12,6 +12,13 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating - id: relativeHumidityMeasurement version: 1 - id: battery @@ -22,3 +29,4 @@ components: version: 1 categories: - name: Thermostat + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml new file mode 100644 index 0000000000..7d3dd309f2 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml @@ -0,0 +1,47 @@ +name: thermostat-modular +components: + - id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: fanMode + version: 1 + optional: true + - id: fanSpeedPercent + version: 1 + optional: true + - id: fanOscillationMode + version: 1 + optional: true + - id: windMode + version: 1 + optional: true + - id: thermostatFanMode + version: 1 + optional: true + - id: thermostatHeatingSetpoint + version: 1 + optional: true + - id: thermostatCoolingSetpoint + version: 1 + optional: true + - id: thermostatOperatingState + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-nobattery.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-nobattery.yml new file mode 100644 index 0000000000..ecbd592df7 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-nobattery.yml @@ -0,0 +1,27 @@ +name: thermostat-nobattery +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-nostate-batteryLevel.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-nostate-batteryLevel.yml new file mode 100644 index 0000000000..acf853d712 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-nostate-batteryLevel.yml @@ -0,0 +1,20 @@ +name: thermostat-nostate-batteryLevel +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: thermostatCoolingSetpoint + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat.yml index 4aa939d307..2fd511dca9 100644 --- a/drivers/SmartThings/matter-thermostat/profiles/thermostat.yml +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat.yml @@ -12,6 +12,13 @@ components: version: 1 - id: thermostatOperatingState version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-thermostat/profiles/water-heater-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-thermostat/profiles/water-heater-power-energy-powerConsumption.yml new file mode 100644 index 0000000000..6329c1e8bc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/water-heater-power-energy-powerConsumption.yml @@ -0,0 +1,113 @@ +name: water-heater-power-energy-powerConsumption +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: mode + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterHeater +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/list/command/supportedValues + value: supportedArguments.value + - component: main + capability: powerMeter + version: 1 + - component: main + capability: energyMeter + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: powerMeter + version: 1 + - component: main + capability: energyMeter + version: 1 + actions: + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: energyMeter + version: 1 diff --git a/drivers/SmartThings/matter-thermostat/profiles/water-heater.yml b/drivers/SmartThings/matter-thermostat/profiles/water-heater.yml new file mode 100644 index 0000000000..a515f5366a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/water-heater.yml @@ -0,0 +1,92 @@ +name: water-heater +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterHeater +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/list/command/supportedValues + value: supportedArguments.value + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + actions: + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/init.lua new file mode 100644 index 0000000000..7bfdfd7248 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/init.lua @@ -0,0 +1,105 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ActivatedCarbonFilterMonitoringServerAttributes = require "embedded_clusters.ActivatedCarbonFilterMonitoring.server.attributes" +local ActivatedCarbonFilterMonitoringServerCommands = require "embedded_clusters.ActivatedCarbonFilterMonitoring.server.commands" +local ActivatedCarbonFilterMonitoringTypes = require "embedded_clusters.ActivatedCarbonFilterMonitoring.types" + +local ActivatedCarbonFilterMonitoring = {} + +ActivatedCarbonFilterMonitoring.ID = 0x0072 +ActivatedCarbonFilterMonitoring.NAME = "ActivatedCarbonFilterMonitoring" +ActivatedCarbonFilterMonitoring.server = {} +ActivatedCarbonFilterMonitoring.client = {} +ActivatedCarbonFilterMonitoring.server.attributes = ActivatedCarbonFilterMonitoringServerAttributes:set_parent_cluster(ActivatedCarbonFilterMonitoring) +ActivatedCarbonFilterMonitoring.server.commands = ActivatedCarbonFilterMonitoringServerCommands:set_parent_cluster(ActivatedCarbonFilterMonitoring) +ActivatedCarbonFilterMonitoring.types = ActivatedCarbonFilterMonitoringTypes + +function ActivatedCarbonFilterMonitoring:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Condition", + [0x0001] = "DegradationDirection", + [0x0002] = "ChangeIndication", + [0x0003] = "InPlaceIndicator", + [0x0004] = "LastChangedTime", + [0x0005] = "ReplacementProductList", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ActivatedCarbonFilterMonitoring:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ResetCondition", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ActivatedCarbonFilterMonitoring.attribute_direction_map = { + ["Condition"] = "server", + ["DegradationDirection"] = "server", + ["ChangeIndication"] = "server", + ["InPlaceIndicator"] = "server", + ["LastChangedTime"] = "server", + ["ReplacementProductList"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +ActivatedCarbonFilterMonitoring.command_direction_map = { + ["ResetCondition"] = "server", +} + +ActivatedCarbonFilterMonitoring.FeatureMap = ActivatedCarbonFilterMonitoring.types.Feature + +function ActivatedCarbonFilterMonitoring.are_features_supported(feature, feature_map) + if (ActivatedCarbonFilterMonitoring.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ActivatedCarbonFilterMonitoring.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ActivatedCarbonFilterMonitoring.NAME)) + end + return ActivatedCarbonFilterMonitoring[direction].attributes[key] +end +ActivatedCarbonFilterMonitoring.attributes = {} +setmetatable(ActivatedCarbonFilterMonitoring.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ActivatedCarbonFilterMonitoring.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ActivatedCarbonFilterMonitoring.NAME)) + end + return ActivatedCarbonFilterMonitoring[direction].commands[key] +end +ActivatedCarbonFilterMonitoring.commands = {} +setmetatable(ActivatedCarbonFilterMonitoring.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return ActivatedCarbonFilterMonitoring.server.events[key] +end +ActivatedCarbonFilterMonitoring.events = {} +setmetatable(ActivatedCarbonFilterMonitoring.events, event_helper_mt) + +setmetatable(ActivatedCarbonFilterMonitoring, {__index = cluster_base}) + +return ActivatedCarbonFilterMonitoring + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua new file mode 100644 index 0000000000..df65143e66 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeIndication = { + ID = 0x0002, + NAME = "ChangeIndication", + base_type = require "embedded_clusters.ActivatedCarbonFilterMonitoring.types.ChangeIndicationEnum", +} + +function ChangeIndication:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function ChangeIndication:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChangeIndication:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChangeIndication:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeIndication:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ChangeIndication:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeIndication, {__call = ChangeIndication.new_value, __index = ChangeIndication.base_type}) +return ChangeIndication + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua new file mode 100644 index 0000000000..361e423a1c --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/Condition.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Condition = { + ID = 0x0000, + NAME = "Condition", + base_type = require "st.matter.data_types.Uint8", +} + +function Condition:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Condition:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Condition:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Condition:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Condition, {__call = Condition.new_value, __index = Condition.base_type}) +return Condition + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/init.lua new file mode 100644 index 0000000000..1ecdf6a931 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ActivatedCarbonFilterMonitoring.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ActivatedCarbonFilterMonitoringServerAttributes = {} + +function ActivatedCarbonFilterMonitoringServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ActivatedCarbonFilterMonitoringServerAttributes, attr_mt) + +return ActivatedCarbonFilterMonitoringServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/ResetCondition.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/ResetCondition.lua new file mode 100644 index 0000000000..1ac942f780 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/ResetCondition.lua @@ -0,0 +1,94 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ResetCondition = {} + +ResetCondition.NAME = "ResetCondition" +ResetCondition.ID = 0x0000 +ResetCondition.field_defs = { +} + +function ResetCondition:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ResetCondition:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ResetCondition, + __tostring = ResetCondition.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ResetCondition:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ResetCondition:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ResetCondition:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ResetCondition, {__call = ResetCondition.init}) + +return ResetCondition + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/init.lua new file mode 100644 index 0000000000..ec343da578 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/server/commands/init.lua @@ -0,0 +1,26 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ActivatedCarbonFilterMonitoring.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local ActivatedCarbonFilterMonitoringServerCommands = {} + +function ActivatedCarbonFilterMonitoringServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ActivatedCarbonFilterMonitoringServerCommands, command_mt) + +return ActivatedCarbonFilterMonitoringServerCommands + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua new file mode 100644 index 0000000000..6b5ab62494 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/ChangeIndicationEnum.lua @@ -0,0 +1,36 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ChangeIndicationEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.OK] = "OK", + [self.WARNING] = "WARNING", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.OK = 0x00 +new_mt.__index.WARNING = 0x01 +new_mt.__index.CRITICAL = 0x02 + +ChangeIndicationEnum.OK = 0x00 +ChangeIndicationEnum.WARNING = 0x01 +ChangeIndicationEnum.CRITICAL = 0x02 + +ChangeIndicationEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ChangeIndicationEnum, new_mt) + +return ChangeIndicationEnum + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/Feature.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/Feature.lua new file mode 100644 index 0000000000..906e769676 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/Feature.lua @@ -0,0 +1,101 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.CONDITION = 0x0001 +Feature.WARNING = 0x0002 +Feature.REPLACEMENT_PRODUCT_LIST = 0x0004 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + CONDITION = 0x0001, + WARNING = 0x0002, + REPLACEMENT_PRODUCT_LIST = 0x0004, +} + +Feature.is_condition_set = function(self) + return (self.value & self.CONDITION) ~= 0 +end + +Feature.set_condition = function(self) + if self.value ~= nil then + self.value = self.value | self.CONDITION + else + self.value = self.CONDITION + end +end + +Feature.unset_condition = function(self) + self.value = self.value & (~self.CONDITION & self.BASE_MASK) +end + +Feature.is_warning_set = function(self) + return (self.value & self.WARNING) ~= 0 +end + +Feature.set_warning = function(self) + if self.value ~= nil then + self.value = self.value | self.WARNING + else + self.value = self.WARNING + end +end + +Feature.unset_warning = function(self) + self.value = self.value & (~self.WARNING & self.BASE_MASK) +end + +Feature.is_replacement_product_list_set = function(self) + return (self.value & self.REPLACEMENT_PRODUCT_LIST) ~= 0 +end + +Feature.set_replacement_product_list = function(self) + if self.value ~= nil then + self.value = self.value | self.REPLACEMENT_PRODUCT_LIST + else + self.value = self.REPLACEMENT_PRODUCT_LIST + end +end + +Feature.unset_replacement_product_list = function(self) + self.value = self.value & (~self.REPLACEMENT_PRODUCT_LIST & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.CONDITION | + Feature.WARNING | + Feature.REPLACEMENT_PRODUCT_LIST + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_condition_set = Feature.is_condition_set, + set_condition = Feature.set_condition, + unset_condition = Feature.unset_condition, + is_warning_set = Feature.is_warning_set, + set_warning = Feature.set_warning, + unset_warning = Feature.unset_warning, + is_replacement_product_list_set = Feature.is_replacement_product_list_set, + set_replacement_product_list = Feature.set_replacement_product_list, + unset_replacement_product_list = Feature.unset_replacement_product_list, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/init.lua new file mode 100644 index 0000000000..7cdc2d1561 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ActivatedCarbonFilterMonitoring/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.ActivatedCarbonFilterMonitoring.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ActivatedCarbonFilterMonitoringTypes = {} + +setmetatable(ActivatedCarbonFilterMonitoringTypes, types_mt) + +return ActivatedCarbonFilterMonitoringTypes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/init.lua new file mode 100644 index 0000000000..4f6d19cc9f --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/init.lua @@ -0,0 +1,62 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local AirQualityServerAttributes = require "embedded_clusters.AirQuality.server.attributes" +local AirQualityTypes = require "embedded_clusters.AirQuality.types" + +local AirQuality = {} + +AirQuality.ID = 0x005B +AirQuality.NAME = "AirQuality" +AirQuality.server = {} +AirQuality.client = {} +AirQuality.server.attributes = AirQualityServerAttributes:set_parent_cluster(AirQuality) +AirQuality.types = AirQualityTypes + +function AirQuality:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "AirQuality", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +-- Attribute Mapping +AirQuality.attribute_direction_map = { + ["AirQuality"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +AirQuality.FeatureMap = AirQuality.types.Feature + +function AirQuality.are_features_supported(feature, feature_map) + if (AirQuality.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = AirQuality.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, AirQuality.NAME)) + end + return AirQuality[direction].attributes[key] +end +AirQuality.attributes = {} +setmetatable(AirQuality.attributes, attribute_helper_mt) + +setmetatable(AirQuality, {__index = cluster_base}) + +return AirQuality + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..a1e56c6597 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua new file mode 100644 index 0000000000..1beb6218f9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AirQuality.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AirQuality = { + ID = 0x0000, + NAME = "AirQuality", + base_type = require "embedded_clusters.AirQuality.types.AirQualityEnum", +} + +function AirQuality:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function AirQuality:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AirQuality:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AirQuality:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AirQuality:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AirQuality:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(AirQuality, {__call = AirQuality.new_value, __index = AirQuality.base_type}) +return AirQuality + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..238b50ade3 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/AttributeList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AttributeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AttributeList.element_type) + end +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) +return AttributeList + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/EventList.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/EventList.lua new file mode 100644 index 0000000000..719f17a231 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/EventList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local EventList = { + ID = 0xFFFA, + NAME = "EventList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function EventList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, EventList.element_type) + end +end + +function EventList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function EventList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EventList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function EventList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function EventList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function EventList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(EventList, {__call = EventList.new_value, __index = EventList.base_type}) +return EventList + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/init.lua new file mode 100644 index 0000000000..50295b081a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.AirQuality.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local AirQualityServerAttributes = {} + +function AirQualityServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(AirQualityServerAttributes, attr_mt) + +return AirQualityServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua new file mode 100644 index 0000000000..c2c255614a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/AirQualityEnum.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local AirQualityEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN] = "UNKNOWN", + [self.GOOD] = "GOOD", + [self.FAIR] = "FAIR", + [self.MODERATE] = "MODERATE", + [self.POOR] = "POOR", + [self.VERY_POOR] = "VERY_POOR", + [self.EXTREMELY_POOR] = "EXTREMELY_POOR", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN = 0x00 +new_mt.__index.GOOD = 0x01 +new_mt.__index.FAIR = 0x02 +new_mt.__index.MODERATE = 0x03 +new_mt.__index.POOR = 0x04 +new_mt.__index.VERY_POOR = 0x05 +new_mt.__index.EXTREMELY_POOR = 0x06 + +AirQualityEnum.UNKNOWN = 0x00 +AirQualityEnum.GOOD = 0x01 +AirQualityEnum.FAIR = 0x02 +AirQualityEnum.MODERATE = 0x03 +AirQualityEnum.POOR = 0x04 +AirQualityEnum.VERY_POOR = 0x05 +AirQualityEnum.EXTREMELY_POOR = 0x06 + +AirQualityEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(AirQualityEnum, new_mt) + +return AirQualityEnum + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/Feature.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/Feature.lua new file mode 100644 index 0000000000..906a09a2bb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/Feature.lua @@ -0,0 +1,123 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.FAIR = 0x0001 +Feature.MODERATE = 0x0002 +Feature.VERY_POOR = 0x0004 +Feature.EXTREMELY_POOR = 0x0008 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + FAIR = 0x0001, + MODERATE = 0x0002, + VERY_POOR = 0x0004, + EXTREMELY_POOR = 0x0008, +} + +Feature.is_fair_set = function(self) + return (self.value & self.FAIR) ~= 0 +end + +Feature.set_fair = function(self) + if self.value ~= nil then + self.value = self.value | self.FAIR + else + self.value = self.FAIR + end +end + +Feature.unset_fair = function(self) + self.value = self.value & (~self.FAIR & self.BASE_MASK) +end + +Feature.is_moderate_set = function(self) + return (self.value & self.MODERATE) ~= 0 +end + +Feature.set_moderate = function(self) + if self.value ~= nil then + self.value = self.value | self.MODERATE + else + self.value = self.MODERATE + end +end + +Feature.unset_moderate = function(self) + self.value = self.value & (~self.MODERATE & self.BASE_MASK) +end + +Feature.is_very_poor_set = function(self) + return (self.value & self.VERY_POOR) ~= 0 +end + +Feature.set_very_poor = function(self) + if self.value ~= nil then + self.value = self.value | self.VERY_POOR + else + self.value = self.VERY_POOR + end +end + +Feature.unset_very_poor = function(self) + self.value = self.value & (~self.VERY_POOR & self.BASE_MASK) +end + +Feature.is_extremely_poor_set = function(self) + return (self.value & self.EXTREMELY_POOR) ~= 0 +end + +Feature.set_extremely_poor = function(self) + if self.value ~= nil then + self.value = self.value | self.EXTREMELY_POOR + else + self.value = self.EXTREMELY_POOR + end +end + +Feature.unset_extremely_poor = function(self) + self.value = self.value & (~self.EXTREMELY_POOR & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.FAIR | + Feature.MODERATE | + Feature.VERY_POOR | + Feature.EXTREMELY_POOR + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_fair_set = Feature.is_fair_set, + set_fair = Feature.set_fair, + unset_fair = Feature.unset_fair, + is_moderate_set = Feature.is_moderate_set, + set_moderate = Feature.set_moderate, + unset_moderate = Feature.unset_moderate, + is_very_poor_set = Feature.is_very_poor_set, + set_very_poor = Feature.set_very_poor, + unset_very_poor = Feature.unset_very_poor, + is_extremely_poor_set = Feature.is_extremely_poor_set, + set_extremely_poor = Feature.set_extremely_poor, + unset_extremely_poor = Feature.unset_extremely_poor, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/init.lua new file mode 100644 index 0000000000..b77d67de82 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/AirQuality/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.AirQuality.types." .. key) + end + return types_mt.__types_cache[key] +end + +local AirQualityTypes = {} + +setmetatable(AirQualityTypes, types_mt) + +return AirQualityTypes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..4de97147e4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local CarbonDioxideConcentrationMeasurementServerAttributes = require "embedded_clusters.CarbonDioxideConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local CarbonDioxideConcentrationMeasurement = {} + +CarbonDioxideConcentrationMeasurement.ID = 0x040D +CarbonDioxideConcentrationMeasurement.NAME = "CarbonDioxideConcentrationMeasurement" +CarbonDioxideConcentrationMeasurement.server = {} +CarbonDioxideConcentrationMeasurement.client = {} +CarbonDioxideConcentrationMeasurement.server.attributes = CarbonDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(CarbonDioxideConcentrationMeasurement) +CarbonDioxideConcentrationMeasurement.types = ConcentrationMeasurement.types + +function CarbonDioxideConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function CarbonDioxideConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +CarbonDioxideConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +CarbonDioxideConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function CarbonDioxideConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = CarbonDioxideConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, CarbonDioxideConcentrationMeasurement.NAME)) + end + return CarbonDioxideConcentrationMeasurement[direction].attributes[key] +end +CarbonDioxideConcentrationMeasurement.attributes = {} +setmetatable(CarbonDioxideConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(CarbonDioxideConcentrationMeasurement, {__index = cluster_base}) + +return CarbonDioxideConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..0206213e6f --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonDioxideConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.CarbonDioxideConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local CarbonDioxideConcentrationMeasurementServerAttributes = {} + +function CarbonDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(CarbonDioxideConcentrationMeasurementServerAttributes, attr_mt) + +return CarbonDioxideConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..a6e1f24d1d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local CarbonMonoxideConcentrationMeasurementServerAttributes = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local CarbonMonoxideConcentrationMeasurement = {} + +CarbonMonoxideConcentrationMeasurement.ID = 0x040C +CarbonMonoxideConcentrationMeasurement.NAME = "CarbonMonoxideConcentrationMeasurement" +CarbonMonoxideConcentrationMeasurement.server = {} +CarbonMonoxideConcentrationMeasurement.client = {} +CarbonMonoxideConcentrationMeasurement.server.attributes = CarbonMonoxideConcentrationMeasurementServerAttributes:set_parent_cluster(CarbonMonoxideConcentrationMeasurement) +CarbonMonoxideConcentrationMeasurement.types = ConcentrationMeasurement.types + +function CarbonMonoxideConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function CarbonMonoxideConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +CarbonMonoxideConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +CarbonMonoxideConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function CarbonMonoxideConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = CarbonMonoxideConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, CarbonMonoxideConcentrationMeasurement.NAME)) + end + return CarbonMonoxideConcentrationMeasurement[direction].attributes[key] +end +CarbonMonoxideConcentrationMeasurement.attributes = {} +setmetatable(CarbonMonoxideConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(CarbonMonoxideConcentrationMeasurement, {__index = cluster_base}) + +return CarbonMonoxideConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..1a7e7b508c --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/CarbonMonoxideConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.CarbonMonoxideConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local CarbonMonoxideConcentrationMeasurementServerAttributes = {} + +function CarbonMonoxideConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(CarbonMonoxideConcentrationMeasurementServerAttributes, attr_mt) + +return CarbonMonoxideConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..cb4cffa2d2 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/init.lua @@ -0,0 +1,111 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ConcentrationMeasurementServerAttributes = require "embedded_clusters.ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurementTypes = require "embedded_clusters.ConcentrationMeasurement.types" + +local ConcentrationMeasurement = {} + +ConcentrationMeasurement.ID = 0x040C +ConcentrationMeasurement.NAME = "CarbonMonoxideConcentrationMeasurement" +ConcentrationMeasurement.server = {} +ConcentrationMeasurement.client = {} +ConcentrationMeasurement.server.attributes = ConcentrationMeasurementServerAttributes:set_parent_cluster(ConcentrationMeasurement) +ConcentrationMeasurement.types = ConcentrationMeasurementTypes + +function ConcentrationMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "MeasuredValue", + [0x0001] = "MinMeasuredValue", + [0x0002] = "MaxMeasuredValue", + [0x0003] = "PeakMeasuredValue", + [0x0004] = "PeakMeasuredValueWindow", + [0x0005] = "AverageMeasuredValue", + [0x0006] = "AverageMeasuredValueWindow", + [0x0007] = "Uncertainty", + [0x0008] = "MeasurementUnit", + [0x0009] = "MeasurementMedium", + [0x000A] = "LevelValue", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ConcentrationMeasurement:get_server_command_by_id(command_id) + local server_id_map = { + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ConcentrationMeasurement.attribute_direction_map = { + ["MeasuredValue"] = "server", + ["MinMeasuredValue"] = "server", + ["MaxMeasuredValue"] = "server", + ["PeakMeasuredValue"] = "server", + ["PeakMeasuredValueWindow"] = "server", + ["AverageMeasuredValue"] = "server", + ["AverageMeasuredValueWindow"] = "server", + ["Uncertainty"] = "server", + ["MeasurementUnit"] = "server", + ["MeasurementMedium"] = "server", + ["LevelValue"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +ConcentrationMeasurement.command_direction_map = { +} + +ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function ConcentrationMeasurement.are_features_supported(feature, feature_map) + if (ConcentrationMeasurement.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ConcentrationMeasurement.NAME)) + end + return ConcentrationMeasurement[direction].attributes[key] +end +ConcentrationMeasurement.attributes = {} +setmetatable(ConcentrationMeasurement.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ConcentrationMeasurement.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ConcentrationMeasurement.NAME)) + end + return ConcentrationMeasurement[direction].commands[key] +end +ConcentrationMeasurement.commands = {} +setmetatable(ConcentrationMeasurement.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return ConcentrationMeasurement.server.events[key] +end +ConcentrationMeasurement.events = {} +setmetatable(ConcentrationMeasurement.events, event_helper_mt) + +setmetatable(ConcentrationMeasurement, {__index = cluster_base}) + +return ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..e4f88c8491 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,73 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function LevelValue:read(device, endpoint_id, cluster_id) + return cluster_base.read( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + + +function LevelValue:subscribe(device, endpoint_id, cluster_id) + return cluster_base.subscribe( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status, + cluster_id +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + cluster_id, + self.ID, + data, + status + ) +end + +function LevelValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..2ab739841f --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,73 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function MeasuredValue:read(device, endpoint_id, cluster_id) + return cluster_base.read( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + + +function MeasuredValue:subscribe(device, endpoint_id, cluster_id) + return cluster_base.subscribe( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status, + cluster_id +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + cluster_id, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..0fa14745c0 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,72 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function MeasurementUnit:read(device, endpoint_id, cluster_id) + return cluster_base.read( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function MeasurementUnit:subscribe(device, endpoint_id, cluster_id) + return cluster_base.subscribe( + device, + endpoint_id, + cluster_id, + self.ID, + nil --event_id + ) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status, + cluster_id +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + cluster_id, + self.ID, + data, + status + ) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..19cde9aa55 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ConcentrationMeasurementServerAttributes = {} + +function ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ConcentrationMeasurementServerAttributes, attr_mt) + +return ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua new file mode 100644 index 0000000000..0bb19bec62 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/Feature.lua @@ -0,0 +1,167 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.NUMERIC_MEASUREMENT = 0x0001 +Feature.LEVEL_INDICATION = 0x0002 +Feature.MEDIUM_LEVEL = 0x0004 +Feature.CRITICAL_LEVEL = 0x0008 +Feature.PEAK_MEASUREMENT = 0x0010 +Feature.AVERAGE_MEASUREMENT = 0x0020 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + NUMERIC_MEASUREMENT = 0x0001, + LEVEL_INDICATION = 0x0002, + MEDIUM_LEVEL = 0x0004, + CRITICAL_LEVEL = 0x0008, + PEAK_MEASUREMENT = 0x0010, + AVERAGE_MEASUREMENT = 0x0020, +} + +Feature.is_numeric_measurement_set = function(self) + return (self.value & self.NUMERIC_MEASUREMENT) ~= 0 +end + +Feature.set_numeric_measurement = function(self) + if self.value ~= nil then + self.value = self.value | self.NUMERIC_MEASUREMENT + else + self.value = self.NUMERIC_MEASUREMENT + end +end + +Feature.unset_numeric_measurement = function(self) + self.value = self.value & (~self.NUMERIC_MEASUREMENT & self.BASE_MASK) +end + +Feature.is_level_indication_set = function(self) + return (self.value & self.LEVEL_INDICATION) ~= 0 +end + +Feature.set_level_indication = function(self) + if self.value ~= nil then + self.value = self.value | self.LEVEL_INDICATION + else + self.value = self.LEVEL_INDICATION + end +end + +Feature.unset_level_indication = function(self) + self.value = self.value & (~self.LEVEL_INDICATION & self.BASE_MASK) +end + +Feature.is_medium_level_set = function(self) + return (self.value & self.MEDIUM_LEVEL) ~= 0 +end + +Feature.set_medium_level = function(self) + if self.value ~= nil then + self.value = self.value | self.MEDIUM_LEVEL + else + self.value = self.MEDIUM_LEVEL + end +end + +Feature.unset_medium_level = function(self) + self.value = self.value & (~self.MEDIUM_LEVEL & self.BASE_MASK) +end + +Feature.is_critical_level_set = function(self) + return (self.value & self.CRITICAL_LEVEL) ~= 0 +end + +Feature.set_critical_level = function(self) + if self.value ~= nil then + self.value = self.value | self.CRITICAL_LEVEL + else + self.value = self.CRITICAL_LEVEL + end +end + +Feature.unset_critical_level = function(self) + self.value = self.value & (~self.CRITICAL_LEVEL & self.BASE_MASK) +end + +Feature.is_peak_measurement_set = function(self) + return (self.value & self.PEAK_MEASUREMENT) ~= 0 +end + +Feature.set_peak_measurement = function(self) + if self.value ~= nil then + self.value = self.value | self.PEAK_MEASUREMENT + else + self.value = self.PEAK_MEASUREMENT + end +end + +Feature.unset_peak_measurement = function(self) + self.value = self.value & (~self.PEAK_MEASUREMENT & self.BASE_MASK) +end + +Feature.is_average_measurement_set = function(self) + return (self.value & self.AVERAGE_MEASUREMENT) ~= 0 +end + +Feature.set_average_measurement = function(self) + if self.value ~= nil then + self.value = self.value | self.AVERAGE_MEASUREMENT + else + self.value = self.AVERAGE_MEASUREMENT + end +end + +Feature.unset_average_measurement = function(self) + self.value = self.value & (~self.AVERAGE_MEASUREMENT & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.NUMERIC_MEASUREMENT | + Feature.LEVEL_INDICATION | + Feature.MEDIUM_LEVEL | + Feature.CRITICAL_LEVEL | + Feature.PEAK_MEASUREMENT | + Feature.AVERAGE_MEASUREMENT + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_numeric_measurement_set = Feature.is_numeric_measurement_set, + set_numeric_measurement = Feature.set_numeric_measurement, + unset_numeric_measurement = Feature.unset_numeric_measurement, + is_level_indication_set = Feature.is_level_indication_set, + set_level_indication = Feature.set_level_indication, + unset_level_indication = Feature.unset_level_indication, + is_medium_level_set = Feature.is_medium_level_set, + set_medium_level = Feature.set_medium_level, + unset_medium_level = Feature.unset_medium_level, + is_critical_level_set = Feature.is_critical_level_set, + set_critical_level = Feature.set_critical_level, + unset_critical_level = Feature.unset_critical_level, + is_peak_measurement_set = Feature.is_peak_measurement_set, + set_peak_measurement = Feature.set_peak_measurement, + unset_peak_measurement = Feature.unset_peak_measurement, + is_average_measurement_set = Feature.is_average_measurement_set, + set_average_measurement = Feature.set_average_measurement, + unset_average_measurement = Feature.unset_average_measurement, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua new file mode 100644 index 0000000000..02b4f727df --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/LevelValueEnum.lua @@ -0,0 +1,42 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LevelValueEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN] = "UNKNOWN", + [self.LOW] = "LOW", + [self.MEDIUM] = "MEDIUM", + [self.HIGH] = "HIGH", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN = 0x00 +new_mt.__index.LOW = 0x01 +new_mt.__index.MEDIUM = 0x02 +new_mt.__index.HIGH = 0x03 +new_mt.__index.CRITICAL = 0x04 + +LevelValueEnum.UNKNOWN = 0x00 +LevelValueEnum.LOW = 0x01 +LevelValueEnum.MEDIUM = 0x02 +LevelValueEnum.HIGH = 0x03 +LevelValueEnum.CRITICAL = 0x04 + +LevelValueEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LevelValueEnum, new_mt) + +return LevelValueEnum + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua new file mode 100644 index 0000000000..6efd90901a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/MeasurementUnitEnum.lua @@ -0,0 +1,51 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local MeasurementUnitEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.PPM] = "PPM", + [self.PPB] = "PPB", + [self.PPT] = "PPT", + [self.MGM3] = "MGM3", + [self.UGM3] = "UGM3", + [self.NGM3] = "NGM3", + [self.PM3] = "PM3", + [self.BQM3] = "BQM3", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.PPM = 0x00 +new_mt.__index.PPB = 0x01 +new_mt.__index.PPT = 0x02 +new_mt.__index.MGM3 = 0x03 +new_mt.__index.UGM3 = 0x04 +new_mt.__index.NGM3 = 0x05 +new_mt.__index.PM3 = 0x06 +new_mt.__index.BQM3 = 0x07 + +MeasurementUnitEnum.PPM = 0x00 +MeasurementUnitEnum.PPB = 0x01 +MeasurementUnitEnum.PPT = 0x02 +MeasurementUnitEnum.MGM3 = 0x03 +MeasurementUnitEnum.UGM3 = 0x04 +MeasurementUnitEnum.NGM3 = 0x05 +MeasurementUnitEnum.PM3 = 0x06 +MeasurementUnitEnum.BQM3 = 0x07 + +MeasurementUnitEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(MeasurementUnitEnum, new_mt) + +return MeasurementUnitEnum + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/init.lua new file mode 100644 index 0000000000..c339f17414 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ConcentrationMeasurement/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.ConcentrationMeasurement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ConcentrationMeasurementTypes = {} + +setmetatable(ConcentrationMeasurementTypes, types_mt) + +return ConcentrationMeasurementTypes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua new file mode 100644 index 0000000000..0f564673a9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua @@ -0,0 +1,56 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ElectricalEnergyMeasurementServerAttributes = require "embedded_clusters.ElectricalEnergyMeasurement.server.attributes" +local ElectricalEnergyMeasurementTypes = require "embedded_clusters.ElectricalEnergyMeasurement.types" +local ElectricalEnergyMeasurement = {} + +ElectricalEnergyMeasurement.ID = 0x0091 +ElectricalEnergyMeasurement.NAME = "ElectricalEnergyMeasurement" +ElectricalEnergyMeasurement.server = {} +ElectricalEnergyMeasurement.client = {} +ElectricalEnergyMeasurement.server.attributes = ElectricalEnergyMeasurementServerAttributes:set_parent_cluster(ElectricalEnergyMeasurement) +ElectricalEnergyMeasurement.types = ElectricalEnergyMeasurementTypes + +function ElectricalEnergyMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0001] = "CumulativeEnergyImported", + [0x0003] = "PeriodicEnergyImported", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +ElectricalEnergyMeasurement.attribute_direction_map = { + ["CumulativeEnergyImported"] = "server", + ["PeriodicEnergyImported"] = "server", +} + +ElectricalEnergyMeasurement.FeatureMap = ElectricalEnergyMeasurement.types.Feature + +function ElectricalEnergyMeasurement.are_features_supported(feature, feature_map) + if (ElectricalEnergyMeasurement.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ElectricalEnergyMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ElectricalEnergyMeasurement.NAME)) + end + return ElectricalEnergyMeasurement[direction].attributes[key] +end +ElectricalEnergyMeasurement.attributes = {} +setmetatable(ElectricalEnergyMeasurement.attributes, attribute_helper_mt) + +setmetatable(ElectricalEnergyMeasurement, {__index = cluster_base}) + +return ElectricalEnergyMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua new file mode 100644 index 0000000000..2d41790440 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CumulativeEnergyImported = { + ID = 0x0001, + NAME = "CumulativeEnergyImported", + base_type = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function CumulativeEnergyImported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CumulativeEnergyImported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyImported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CumulativeEnergyImported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CumulativeEnergyImported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CumulativeEnergyImported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CumulativeEnergyImported, {__call = CumulativeEnergyImported.new_value, __index = CumulativeEnergyImported.base_type}) +return CumulativeEnergyImported + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua new file mode 100644 index 0000000000..5daccf48ab --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PeriodicEnergyImported = { + ID = 0x0003, + NAME = "PeriodicEnergyImported", + base_type = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", +} + +function PeriodicEnergyImported:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function PeriodicEnergyImported:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyImported:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PeriodicEnergyImported:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PeriodicEnergyImported:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PeriodicEnergyImported:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(PeriodicEnergyImported, {__call = PeriodicEnergyImported.new_value, __index = PeriodicEnergyImported.base_type}) +return PeriodicEnergyImported + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..57bc0d1f72 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ElectricalEnergyMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ElectricalEnergyMeasurementServerAttributes = {} + +function ElectricalEnergyMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ElectricalEnergyMeasurementServerAttributes, attr_mt) + +return ElectricalEnergyMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua new file mode 100644 index 0000000000..a4c58a3646 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua @@ -0,0 +1,101 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local EnergyMeasurementStruct = {} +local new_mt = StructureABC.new_mt({NAME = "EnergyMeasurementStruct", ID = data_types.name_to_id_map["Structure"]}) + +EnergyMeasurementStruct.field_defs = { + { + name = "energy", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "start_timestamp", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "end_timestamp", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "start_systime", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "end_systime", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, +} + +EnergyMeasurementStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +EnergyMeasurementStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = EnergyMeasurementStruct.init +new_mt.__index.serialize = EnergyMeasurementStruct.serialize + +EnergyMeasurementStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(EnergyMeasurementStruct, new_mt) + +return EnergyMeasurementStruct + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua new file mode 100644 index 0000000000..e3db76f49d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua @@ -0,0 +1,31 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.IMPORTED_ENERGY = 0x0001 +Feature.EXPORTED_ENERGY = 0x0002 +Feature.CUMULATIVE_ENERGY = 0x0004 +Feature.PERIODIC_ENERGY = 0x0008 + +function Feature.bits_are_valid(feature) + local max = + Feature.IMPORTED_ENERGY | + Feature.EXPORTED_ENERGY | + Feature.CUMULATIVE_ENERGY | + Feature.PERIODIC_ENERGY + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua new file mode 100644 index 0000000000..ec29b53e05 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.ElectricalEnergyMeasurement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ElectricalEnergyMeasurementTypes = {} + +setmetatable(ElectricalEnergyMeasurementTypes, types_mt) + +return ElectricalEnergyMeasurementTypes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/init.lua new file mode 100644 index 0000000000..c9061e3cc4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/init.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ElectricalPowerMeasurementServerAttributes = require "embedded_clusters.ElectricalPowerMeasurement.server.attributes" + +local ElectricalPowerMeasurement = {} + +ElectricalPowerMeasurement.ID = 0x0090 +ElectricalPowerMeasurement.NAME = "ElectricalPowerMeasurement" +ElectricalPowerMeasurement.server = {} +ElectricalPowerMeasurement.client = {} +ElectricalPowerMeasurement.server.attributes = ElectricalPowerMeasurementServerAttributes:set_parent_cluster(ElectricalPowerMeasurement) + +function ElectricalPowerMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0008] = "ActivePower", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +ElectricalPowerMeasurement.attribute_direction_map = { + ["ActivePower"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ElectricalPowerMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ElectricalPowerMeasurement.NAME)) + end + return ElectricalPowerMeasurement[direction].attributes[key] +end +ElectricalPowerMeasurement.attributes = {} +setmetatable(ElectricalPowerMeasurement.attributes, attribute_helper_mt) + +setmetatable(ElectricalPowerMeasurement, {__index = cluster_base}) + +return ElectricalPowerMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua new file mode 100644 index 0000000000..f1696509f5 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ActivePower = { + ID = 0x0008, + NAME = "ActivePower", + base_type = require "st.matter.data_types.Int64", +} + +function ActivePower:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ActivePower:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActivePower:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActivePower:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ActivePower:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ActivePower:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ActivePower, {__call = ActivePower.new_value, __index = ActivePower.base_type}) +return ActivePower diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..6de69b94ff --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.ElectricalPowerMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ElectricalPowerMeasurementServerAttributes = {} + +function ElectricalPowerMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ElectricalPowerMeasurementServerAttributes, attr_mt) + +return ElectricalPowerMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..cdfd1d597e --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local FormaldehydeConcentrationMeasurementServerAttributes = require "embedded_clusters.FormaldehydeConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local FormaldehydeConcentrationMeasurement = {} + +FormaldehydeConcentrationMeasurement.ID = 0x042B +FormaldehydeConcentrationMeasurement.NAME = "FormaldehydeConcentrationMeasurement" +FormaldehydeConcentrationMeasurement.server = {} +FormaldehydeConcentrationMeasurement.client = {} +FormaldehydeConcentrationMeasurement.server.attributes = FormaldehydeConcentrationMeasurementServerAttributes:set_parent_cluster(FormaldehydeConcentrationMeasurement) +FormaldehydeConcentrationMeasurement.types = ConcentrationMeasurement.types + +function FormaldehydeConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function FormaldehydeConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +FormaldehydeConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +FormaldehydeConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function FormaldehydeConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = FormaldehydeConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, FormaldehydeConcentrationMeasurement.NAME)) + end + return FormaldehydeConcentrationMeasurement[direction].attributes[key] +end +FormaldehydeConcentrationMeasurement.attributes = {} +setmetatable(FormaldehydeConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(FormaldehydeConcentrationMeasurement, {__index = cluster_base}) + +return FormaldehydeConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..3dee13abe3 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/FormaldehydeConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.FormaldehydeConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local FormaldehydeConcentrationMeasurementServerAttributes = {} + +function FormaldehydeConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(FormaldehydeConcentrationMeasurementServerAttributes, attr_mt) + +return FormaldehydeConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/init.lua new file mode 100644 index 0000000000..7a36d3aa92 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/init.lua @@ -0,0 +1,105 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local HepaFilterMonitoringServerAttributes = require "embedded_clusters.HepaFilterMonitoring.server.attributes" +local HepaFilterMonitoringServerCommands = require "embedded_clusters.HepaFilterMonitoring.server.commands" +local HepaFilterMonitoringTypes = require "embedded_clusters.HepaFilterMonitoring.types" + +local HepaFilterMonitoring = {} + +HepaFilterMonitoring.ID = 0x0071 +HepaFilterMonitoring.NAME = "HepaFilterMonitoring" +HepaFilterMonitoring.server = {} +HepaFilterMonitoring.client = {} +HepaFilterMonitoring.server.attributes = HepaFilterMonitoringServerAttributes:set_parent_cluster(HepaFilterMonitoring) +HepaFilterMonitoring.server.commands = HepaFilterMonitoringServerCommands:set_parent_cluster(HepaFilterMonitoring) +HepaFilterMonitoring.types = HepaFilterMonitoringTypes + +function HepaFilterMonitoring:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Condition", + [0x0001] = "DegradationDirection", + [0x0002] = "ChangeIndication", + [0x0003] = "InPlaceIndicator", + [0x0004] = "LastChangedTime", + [0x0005] = "ReplacementProductList", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function HepaFilterMonitoring:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ResetCondition", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +HepaFilterMonitoring.attribute_direction_map = { + ["Condition"] = "server", + ["DegradationDirection"] = "server", + ["ChangeIndication"] = "server", + ["InPlaceIndicator"] = "server", + ["LastChangedTime"] = "server", + ["ReplacementProductList"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +HepaFilterMonitoring.command_direction_map = { + ["ResetCondition"] = "server", +} + +HepaFilterMonitoring.FeatureMap = HepaFilterMonitoring.types.Feature + +function HepaFilterMonitoring.are_features_supported(feature, feature_map) + if (HepaFilterMonitoring.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = HepaFilterMonitoring.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, HepaFilterMonitoring.NAME)) + end + return HepaFilterMonitoring[direction].attributes[key] +end +HepaFilterMonitoring.attributes = {} +setmetatable(HepaFilterMonitoring.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = HepaFilterMonitoring.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, HepaFilterMonitoring.NAME)) + end + return HepaFilterMonitoring[direction].commands[key] +end +HepaFilterMonitoring.commands = {} +setmetatable(HepaFilterMonitoring.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return HepaFilterMonitoring.server.events[key] +end +HepaFilterMonitoring.events = {} +setmetatable(HepaFilterMonitoring.events, event_helper_mt) + +setmetatable(HepaFilterMonitoring, {__index = cluster_base}) + +return HepaFilterMonitoring + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/ChangeIndication.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/ChangeIndication.lua new file mode 100644 index 0000000000..9dca020204 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/ChangeIndication.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeIndication = { + ID = 0x0002, + NAME = "ChangeIndication", + base_type = require "embedded_clusters.HepaFilterMonitoring.types.ChangeIndicationEnum", +} + +function ChangeIndication:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function ChangeIndication:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChangeIndication:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil --event_id + ) +end + +function ChangeIndication:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeIndication:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function ChangeIndication:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeIndication, {__call = ChangeIndication.new_value, __index = ChangeIndication.base_type}) +return ChangeIndication + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/Condition.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/Condition.lua new file mode 100644 index 0000000000..76a4fd9760 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/Condition.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Condition = { + ID = 0x0000, + NAME = "Condition", + base_type = require "st.matter.data_types.Uint8", +} + +function Condition:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Condition:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Condition:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Condition:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function Condition:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Condition, {__call = Condition.new_value, __index = Condition.base_type}) +return Condition + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/init.lua new file mode 100644 index 0000000000..2590980846 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.HepaFilterMonitoring.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local HepaFilterMonitoringServerAttributes = {} + +function HepaFilterMonitoringServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(HepaFilterMonitoringServerAttributes, attr_mt) + +return HepaFilterMonitoringServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/ResetCondition.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/ResetCondition.lua new file mode 100644 index 0000000000..1ac942f780 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/ResetCondition.lua @@ -0,0 +1,94 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ResetCondition = {} + +ResetCondition.NAME = "ResetCondition" +ResetCondition.ID = 0x0000 +ResetCondition.field_defs = { +} + +function ResetCondition:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function ResetCondition:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ResetCondition, + __tostring = ResetCondition.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ResetCondition:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ResetCondition:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ResetCondition:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ResetCondition, {__call = ResetCondition.init}) + +return ResetCondition + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/init.lua new file mode 100644 index 0000000000..77ae141f77 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/server/commands/init.lua @@ -0,0 +1,26 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("embedded_clusters.HepaFilterMonitoring.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local HepaFilterMonitoringServerCommands = {} + +function HepaFilterMonitoringServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(HepaFilterMonitoringServerCommands, command_mt) + +return HepaFilterMonitoringServerCommands + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/ChangeIndicationEnum.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/ChangeIndicationEnum.lua new file mode 100644 index 0000000000..6b5ab62494 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/ChangeIndicationEnum.lua @@ -0,0 +1,36 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ChangeIndicationEnum = {} +-- Note: the name here is intentionally set to Uint8 to maintain backwards compatibility +-- with how types were handled in api < 10. +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.OK] = "OK", + [self.WARNING] = "WARNING", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.OK = 0x00 +new_mt.__index.WARNING = 0x01 +new_mt.__index.CRITICAL = 0x02 + +ChangeIndicationEnum.OK = 0x00 +ChangeIndicationEnum.WARNING = 0x01 +ChangeIndicationEnum.CRITICAL = 0x02 + +ChangeIndicationEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ChangeIndicationEnum, new_mt) + +return ChangeIndicationEnum + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/Feature.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/Feature.lua new file mode 100644 index 0000000000..906e769676 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/Feature.lua @@ -0,0 +1,101 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.CONDITION = 0x0001 +Feature.WARNING = 0x0002 +Feature.REPLACEMENT_PRODUCT_LIST = 0x0004 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + CONDITION = 0x0001, + WARNING = 0x0002, + REPLACEMENT_PRODUCT_LIST = 0x0004, +} + +Feature.is_condition_set = function(self) + return (self.value & self.CONDITION) ~= 0 +end + +Feature.set_condition = function(self) + if self.value ~= nil then + self.value = self.value | self.CONDITION + else + self.value = self.CONDITION + end +end + +Feature.unset_condition = function(self) + self.value = self.value & (~self.CONDITION & self.BASE_MASK) +end + +Feature.is_warning_set = function(self) + return (self.value & self.WARNING) ~= 0 +end + +Feature.set_warning = function(self) + if self.value ~= nil then + self.value = self.value | self.WARNING + else + self.value = self.WARNING + end +end + +Feature.unset_warning = function(self) + self.value = self.value & (~self.WARNING & self.BASE_MASK) +end + +Feature.is_replacement_product_list_set = function(self) + return (self.value & self.REPLACEMENT_PRODUCT_LIST) ~= 0 +end + +Feature.set_replacement_product_list = function(self) + if self.value ~= nil then + self.value = self.value | self.REPLACEMENT_PRODUCT_LIST + else + self.value = self.REPLACEMENT_PRODUCT_LIST + end +end + +Feature.unset_replacement_product_list = function(self) + self.value = self.value & (~self.REPLACEMENT_PRODUCT_LIST & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.CONDITION | + Feature.WARNING | + Feature.REPLACEMENT_PRODUCT_LIST + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_condition_set = Feature.is_condition_set, + set_condition = Feature.set_condition, + unset_condition = Feature.unset_condition, + is_warning_set = Feature.is_warning_set, + set_warning = Feature.set_warning, + unset_warning = Feature.unset_warning, + is_replacement_product_list_set = Feature.is_replacement_product_list_set, + set_replacement_product_list = Feature.set_replacement_product_list, + unset_replacement_product_list = Feature.unset_replacement_product_list, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/init.lua new file mode 100644 index 0000000000..6c36af1aec --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/HepaFilterMonitoring/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.HepaFilterMonitoring.types." .. key) + end + return types_mt.__types_cache[key] +end + +local HepaFilterMonitoringTypes = {} + +setmetatable(HepaFilterMonitoringTypes, types_mt) + +return HepaFilterMonitoringTypes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..eae9be65f0 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local NitrogenDioxideConcentrationMeasurementServerAttributes = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local NitrogenDioxideConcentrationMeasurement = {} + +NitrogenDioxideConcentrationMeasurement.ID = 0x0413 +NitrogenDioxideConcentrationMeasurement.NAME = "NitrogenDioxideConcentrationMeasurement" +NitrogenDioxideConcentrationMeasurement.server = {} +NitrogenDioxideConcentrationMeasurement.client = {} +NitrogenDioxideConcentrationMeasurement.server.attributes = NitrogenDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(NitrogenDioxideConcentrationMeasurement) +NitrogenDioxideConcentrationMeasurement.types = ConcentrationMeasurement.types + +function NitrogenDioxideConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function NitrogenDioxideConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +NitrogenDioxideConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +NitrogenDioxideConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function NitrogenDioxideConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = NitrogenDioxideConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, NitrogenDioxideConcentrationMeasurement.NAME)) + end + return NitrogenDioxideConcentrationMeasurement[direction].attributes[key] +end +NitrogenDioxideConcentrationMeasurement.attributes = {} +setmetatable(NitrogenDioxideConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(NitrogenDioxideConcentrationMeasurement, {__index = cluster_base}) + +return NitrogenDioxideConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..c82517d362 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/NitrogenDioxideConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.NitrogenDioxideConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local NitrogenDioxideConcentrationMeasurementServerAttributes = {} + +function NitrogenDioxideConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(NitrogenDioxideConcentrationMeasurementServerAttributes, attr_mt) + +return NitrogenDioxideConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..c49ea94b39 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local OzoneConcentrationMeasurementServerAttributes = require "embedded_clusters.OzoneConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local OzoneConcentrationMeasurement = {} + +OzoneConcentrationMeasurement.ID = 0x0415 +OzoneConcentrationMeasurement.NAME = "OzoneConcentrationMeasurement" +OzoneConcentrationMeasurement.server = {} +OzoneConcentrationMeasurement.client = {} +OzoneConcentrationMeasurement.server.attributes = OzoneConcentrationMeasurementServerAttributes:set_parent_cluster(OzoneConcentrationMeasurement) +OzoneConcentrationMeasurement.types = ConcentrationMeasurement.types + +function OzoneConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function OzoneConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +OzoneConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +OzoneConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function OzoneConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = OzoneConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, OzoneConcentrationMeasurement.NAME)) + end + return OzoneConcentrationMeasurement[direction].attributes[key] +end +OzoneConcentrationMeasurement.attributes = {} +setmetatable(OzoneConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(OzoneConcentrationMeasurement, {__index = cluster_base}) + +return OzoneConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..918b680495 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/OzoneConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.OzoneConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local OzoneConcentrationMeasurementServerAttributes = {} + +function OzoneConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(OzoneConcentrationMeasurementServerAttributes, attr_mt) + +return OzoneConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..3b333b5417 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local Pm10ConcentrationMeasurementServerAttributes = require "embedded_clusters.Pm10ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local Pm10ConcentrationMeasurement = {} + +Pm10ConcentrationMeasurement.ID = 0x042D +Pm10ConcentrationMeasurement.NAME = "Pm10ConcentrationMeasurement" +Pm10ConcentrationMeasurement.server = {} +Pm10ConcentrationMeasurement.client = {} +Pm10ConcentrationMeasurement.server.attributes = Pm10ConcentrationMeasurementServerAttributes:set_parent_cluster(Pm10ConcentrationMeasurement) +Pm10ConcentrationMeasurement.types = ConcentrationMeasurement.types + +function Pm10ConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function Pm10ConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +Pm10ConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +Pm10ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function Pm10ConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Pm10ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Pm10ConcentrationMeasurement.NAME)) + end + return Pm10ConcentrationMeasurement[direction].attributes[key] +end +Pm10ConcentrationMeasurement.attributes = {} +setmetatable(Pm10ConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(Pm10ConcentrationMeasurement, {__index = cluster_base}) + +return Pm10ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..3b1e6617a4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm10ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Pm10ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local Pm10ConcentrationMeasurementServerAttributes = {} + +function Pm10ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(Pm10ConcentrationMeasurementServerAttributes, attr_mt) + +return Pm10ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..b2e6656a9a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local Pm1ConcentrationMeasurementServerAttributes = require "embedded_clusters.Pm1ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local Pm1ConcentrationMeasurement = {} + +Pm1ConcentrationMeasurement.ID = 0x042C +Pm1ConcentrationMeasurement.NAME = "Pm1ConcentrationMeasurement" +Pm1ConcentrationMeasurement.server = {} +Pm1ConcentrationMeasurement.client = {} +Pm1ConcentrationMeasurement.server.attributes = Pm1ConcentrationMeasurementServerAttributes:set_parent_cluster(Pm1ConcentrationMeasurement) +Pm1ConcentrationMeasurement.types = ConcentrationMeasurement.types + +function Pm1ConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function Pm1ConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +Pm1ConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +Pm1ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function Pm1ConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Pm1ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Pm1ConcentrationMeasurement.NAME)) + end + return Pm1ConcentrationMeasurement[direction].attributes[key] +end +Pm1ConcentrationMeasurement.attributes = {} +setmetatable(Pm1ConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(Pm1ConcentrationMeasurement, {__index = cluster_base}) + +return Pm1ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..2635da32a6 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm1ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Pm1ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local Pm1ConcentrationMeasurementServerAttributes = {} + +function Pm1ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(Pm1ConcentrationMeasurementServerAttributes, attr_mt) + +return Pm1ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..e6e6144f94 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local Pm25ConcentrationMeasurementServerAttributes = require "embedded_clusters.Pm25ConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local Pm25ConcentrationMeasurement = {} + +Pm25ConcentrationMeasurement.ID = 0x042A +Pm25ConcentrationMeasurement.NAME = "Pm25ConcentrationMeasurement" +Pm25ConcentrationMeasurement.server = {} +Pm25ConcentrationMeasurement.client = {} +Pm25ConcentrationMeasurement.server.attributes = Pm25ConcentrationMeasurementServerAttributes:set_parent_cluster(Pm25ConcentrationMeasurement) +Pm25ConcentrationMeasurement.types = ConcentrationMeasurement.types + +function Pm25ConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function Pm25ConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +Pm25ConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +Pm25ConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function Pm25ConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Pm25ConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Pm25ConcentrationMeasurement.NAME)) + end + return Pm25ConcentrationMeasurement[direction].attributes[key] +end +Pm25ConcentrationMeasurement.attributes = {} +setmetatable(Pm25ConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(Pm25ConcentrationMeasurement, {__index = cluster_base}) + +return Pm25ConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..5c432da0ec --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/Pm25ConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Pm25ConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local Pm25ConcentrationMeasurementServerAttributes = {} + +function Pm25ConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(Pm25ConcentrationMeasurementServerAttributes, attr_mt) + +return Pm25ConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..3d4b21d602 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local RadonConcentrationMeasurementServerAttributes = require "embedded_clusters.RadonConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local RadonConcentrationMeasurement = {} + +RadonConcentrationMeasurement.ID = 0x042F +RadonConcentrationMeasurement.NAME = "RadonConcentrationMeasurement" +RadonConcentrationMeasurement.server = {} +RadonConcentrationMeasurement.client = {} +RadonConcentrationMeasurement.server.attributes = RadonConcentrationMeasurementServerAttributes:set_parent_cluster(RadonConcentrationMeasurement) +RadonConcentrationMeasurement.types = ConcentrationMeasurement.types + +function RadonConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function RadonConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +RadonConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +RadonConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function RadonConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = RadonConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, RadonConcentrationMeasurement.NAME)) + end + return RadonConcentrationMeasurement[direction].attributes[key] +end +RadonConcentrationMeasurement.attributes = {} +setmetatable(RadonConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(RadonConcentrationMeasurement, {__index = cluster_base}) + +return RadonConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..8aa225e510 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/RadonConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.RadonConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local RadonConcentrationMeasurementServerAttributes = {} + +function RadonConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(RadonConcentrationMeasurementServerAttributes, attr_mt) + +return RadonConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua new file mode 100644 index 0000000000..cad49a14e1 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/init.lua @@ -0,0 +1,47 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.server.attributes" +local ConcentrationMeasurement = require "embedded_clusters.ConcentrationMeasurement" + +local TotalVolatileOrganicCompoundsConcentrationMeasurement = {} + +TotalVolatileOrganicCompoundsConcentrationMeasurement.ID = 0x042E +TotalVolatileOrganicCompoundsConcentrationMeasurement.NAME = "TotalVolatileOrganicCompoundsConcentrationMeasurement" +TotalVolatileOrganicCompoundsConcentrationMeasurement.server = {} +TotalVolatileOrganicCompoundsConcentrationMeasurement.client = {} +TotalVolatileOrganicCompoundsConcentrationMeasurement.server.attributes = TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes:set_parent_cluster(TotalVolatileOrganicCompoundsConcentrationMeasurement) +TotalVolatileOrganicCompoundsConcentrationMeasurement.types = ConcentrationMeasurement.types + +function TotalVolatileOrganicCompoundsConcentrationMeasurement:get_attribute_by_id(attr_id) + return ConcentrationMeasurement:get_attribute_by_id(attr_id) +end + +function TotalVolatileOrganicCompoundsConcentrationMeasurement:get_server_command_by_id(command_id) + return ConcentrationMeasurement:get_server_command_by_id(command_id) +end + +TotalVolatileOrganicCompoundsConcentrationMeasurement.attribute_direction_map = ConcentrationMeasurement.attribute_direction_map + +TotalVolatileOrganicCompoundsConcentrationMeasurement.FeatureMap = ConcentrationMeasurement.types.Feature + +function TotalVolatileOrganicCompoundsConcentrationMeasurement.are_features_supported(feature, feature_map) + return ConcentrationMeasurement.are_features_supported(feature, feature_map) +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = TotalVolatileOrganicCompoundsConcentrationMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, TotalVolatileOrganicCompoundsConcentrationMeasurement.NAME)) + end + return TotalVolatileOrganicCompoundsConcentrationMeasurement[direction].attributes[key] +end +TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes = {} +setmetatable(TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes, attribute_helper_mt) + +setmetatable(TotalVolatileOrganicCompoundsConcentrationMeasurement, {__index = cluster_base}) + +return TotalVolatileOrganicCompoundsConcentrationMeasurement + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua new file mode 100644 index 0000000000..cc8f7b47eb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/LevelValue.lua @@ -0,0 +1,44 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ConcentrationMeasurementServerAttributesLevelValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.LevelValue" + +local LevelValue = { + ID = 0x000A, + NAME = "LevelValue", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.LevelValueEnum", +} + +function LevelValue:new_value(...) + ConcentrationMeasurementServerAttributesLevelValue:new_value(...) +end + +function LevelValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:read(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesLevelValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function LevelValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LevelValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesLevelValue:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function LevelValue:deserialize(tlv_buf) + return ConcentrationMeasurementServerAttributesLevelValue:deserialize(tlv_buf) +end + +setmetatable(LevelValue, {__call = LevelValue.new_value, __index = LevelValue.base_type}) +return LevelValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua new file mode 100644 index 0000000000..ca0dafb0dc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasuredValue.lua @@ -0,0 +1,59 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasuredValue = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasuredValue" + + +local MeasuredValue = { + ID = 0x0000, + NAME = "MeasuredValue", + base_type = require "st.matter.data_types.SinglePrecisionFloat", +} + +function MeasuredValue:new_value(...) + return ConcentrationMeasurementServerAttributesMeasuredValue:new_value(...) +end + +function MeasuredValue:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:read(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasuredValue:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(MeasuredValue, {__call = MeasuredValue.new_value, __index = MeasuredValue.base_type}) +return MeasuredValue + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua new file mode 100644 index 0000000000..9597906d3a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/MeasurementUnit.lua @@ -0,0 +1,48 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local TLVParser = require "st.matter.TLV.TLVParser" +local ConcentrationMeasurementServerAttributesMeasurementUnit = require "embedded_clusters.ConcentrationMeasurement.server.attributes.MeasurementUnit" + + +local MeasurementUnit = { + ID = 0x0008, + NAME = "MeasurementUnit", + base_type = require "embedded_clusters.ConcentrationMeasurement.types.MeasurementUnitEnum", +} + +function MeasurementUnit:new_value(...) + return ConcentrationMeasurementServerAttributesMeasurementUnit:new_value(...) +end + +function MeasurementUnit:read(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:read(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:subscribe(device, endpoint_id) + return ConcentrationMeasurementServerAttributesMeasurementUnit:subscribe(device, endpoint_id, self._cluster.ID) +end + +function MeasurementUnit:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MeasurementUnit:build_test_report_data( + device, + endpoint_id, + value, + status +) + return ConcentrationMeasurementServerAttributesMeasurementUnit:build_test_report_data(device, endpoint_id, value, status, self._cluster.ID) +end + +function MeasurementUnit:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MeasurementUnit, {__call = MeasurementUnit.new_value, __index = MeasurementUnit.base_type}) +return MeasurementUnit + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..b67cbcd7b6 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/TotalVolatileOrganicCompoundsConcentrationMeasurement/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes = {} + +function TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes, attr_mt) + +return TotalVolatileOrganicCompoundsConcentrationMeasurementServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/init.lua new file mode 100644 index 0000000000..3a1c2f1bfb --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/init.lua @@ -0,0 +1,95 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local WaterHeaterModeServerAttributes = require "embedded_clusters.WaterHeaterMode.server.attributes" +local WaterHeaterModeServerCommands = require "embedded_clusters.WaterHeaterMode.server.commands" +local WaterHeaterModeTypes = require "embedded_clusters.WaterHeaterMode.types" + +local WaterHeaterMode = {} + +WaterHeaterMode.ID = 0x009E +WaterHeaterMode.NAME = "WaterHeaterMode" +WaterHeaterMode.server = {} +WaterHeaterMode.client = {} +WaterHeaterMode.server.attributes = WaterHeaterModeServerAttributes:set_parent_cluster(WaterHeaterMode) +WaterHeaterMode.server.commands = WaterHeaterModeServerCommands:set_parent_cluster(WaterHeaterMode) +WaterHeaterMode.types = WaterHeaterModeTypes + +function WaterHeaterMode:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SupportedModes", + [0x0001] = "CurrentMode", + [0x0002] = "StartUpMode", + [0x0003] = "OnMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function WaterHeaterMode:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +WaterHeaterMode.attribute_direction_map = { + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["StartUpMode"] = "server", + ["OnMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +WaterHeaterMode.command_direction_map = { + ["ChangeToMode"] = "server", + ["ChangeToModeResponse"] = "client", +} + +WaterHeaterMode.FeatureMap = WaterHeaterMode.types.Feature + +function WaterHeaterMode.are_features_supported(feature, feature_map) + if (WaterHeaterMode.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = WaterHeaterMode.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, WaterHeaterMode.NAME)) + end + return WaterHeaterMode[direction].attributes[key] +end +WaterHeaterMode.attributes = {} +setmetatable(WaterHeaterMode.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = WaterHeaterMode.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, WaterHeaterMode.NAME)) + end + return WaterHeaterMode[direction].commands[key] +end +WaterHeaterMode.commands = {} +setmetatable(WaterHeaterMode.commands, command_helper_mt) + +setmetatable(WaterHeaterMode, {__index = cluster_base}) + +return WaterHeaterMode + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..165b906df5 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/CurrentMode.lua @@ -0,0 +1,71 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0001, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..903a374d43 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/SupportedModes.lua @@ -0,0 +1,78 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0000, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.WaterHeaterMode.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/init.lua new file mode 100644 index 0000000000..fb7bed9828 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.WaterHeaterMode.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local WaterHeaterModeServerAttributes = {} + +function WaterHeaterModeServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(WaterHeaterModeServerAttributes, attr_mt) + +return WaterHeaterModeServerAttributes + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..726352b448 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/ChangeToMode.lua @@ -0,0 +1,90 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/init.lua new file mode 100644 index 0000000000..64660ed336 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/server/commands/init.lua @@ -0,0 +1,26 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local command_mt = {} +command_mt.__command_cache = {} +command_mt.__index = function(self, key) + if command_mt.__command_cache[key] == nil then + local req_loc = string.format("embedded_clusters.WaterHeaterMode.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) + end + return command_mt.__command_cache[key] +end + +local WaterHeaterModeServerCommands = {} + +function WaterHeaterModeServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(WaterHeaterModeServerCommands, command_mt) + +return WaterHeaterModeServerCommands + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/Feature.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/Feature.lua new file mode 100644 index 0000000000..c7e987399d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/Feature.lua @@ -0,0 +1,57 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..1db8c5b634 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeOptionStruct.lua @@ -0,0 +1,88 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "mode_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.WaterHeaterMode.types.ModeTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTag.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTag.lua new file mode 100644 index 0000000000..7d37d4374d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTag.lua @@ -0,0 +1,34 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ModeTag = {} +local new_mt = UintABC.new_mt({NAME = "ModeTag", ID = data_types.name_to_id_map["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.NORMAL] = "NORMAL", + [self.HEAVY] = "HEAVY", + [self.LIGHT] = "LIGHT", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.NORMAL = 0x4000 +new_mt.__index.HEAVY = 0x4001 +new_mt.__index.LIGHT = 0x4002 + +ModeTag.NORMAL = 0x4000 +ModeTag.HEAVY = 0x4001 +ModeTag.LIGHT = 0x4002 + +ModeTag.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ModeTag, new_mt) + +return ModeTag + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTagStruct.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTagStruct.lua new file mode 100644 index 0000000000..17dfd269d6 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/ModeTagStruct.lua @@ -0,0 +1,81 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct + diff --git a/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/init.lua b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/init.lua new file mode 100644 index 0000000000..bae583dfb4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/embedded_clusters/WaterHeaterMode/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.WaterHeaterMode.types." .. key) + end + return types_mt.__types_cache[key] +end + +local WaterHeaterModeTypes = {} + +setmetatable(WaterHeaterModeTypes, types_mt) + +return WaterHeaterModeTypes + diff --git a/drivers/SmartThings/matter-thermostat/src/init.lua b/drivers/SmartThings/matter-thermostat/src/init.lua index eeba65e635..d438ba0952 100644 --- a/drivers/SmartThings/matter-thermostat/src/init.lua +++ b/drivers/SmartThings/matter-thermostat/src/init.lua @@ -1,465 +1,515 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" local log = require "log" +local version = require "version" +local MatterDriver = require "st.matter.driver" +local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local im = require "st.matter.interaction_model" - -local MatterDriver = require "st.matter.driver" -local utils = require "st.utils" - -local THERMOSTAT_MODE_MAP = { - [clusters.Thermostat.types.ThermostatSystemMode.OFF] = capabilities.thermostatMode.thermostatMode.off, - [clusters.Thermostat.types.ThermostatSystemMode.AUTO] = capabilities.thermostatMode.thermostatMode.auto, - [clusters.Thermostat.types.ThermostatSystemMode.COOL] = capabilities.thermostatMode.thermostatMode.cool, - [clusters.Thermostat.types.ThermostatSystemMode.HEAT] = capabilities.thermostatMode.thermostatMode.heat, - [clusters.Thermostat.types.ThermostatSystemMode.EMERGENCY_HEATING] = capabilities.thermostatMode.thermostatMode.emergency_heat, - [clusters.Thermostat.types.ThermostatSystemMode.FAN_ONLY] = capabilities.thermostatMode.thermostatMode.fanonly -} - -local THERMOSTAT_OPERATING_MODE_MAP = { - [0] = capabilities.thermostatOperatingState.thermostatOperatingState.heating, - [1] = capabilities.thermostatOperatingState.thermostatOperatingState.cooling, - [2] = capabilities.thermostatOperatingState.thermostatOperatingState.fan_only, -} - -local setpoint_limit_device_field = { - MIN_HEAT = "MIN_HEAT", - MAX_HEAT = "MAX_HEAT", - MIN_COOL = "MIN_COOL", - MAX_COOL = "MAX_COOL", - MIN_DEADBAND = "MIN_DEADBAND", -} - -local subscribed_attributes = { - [capabilities.temperatureMeasurement.ID] = { - clusters.Thermostat.attributes.LocalTemperature, - clusters.TemperatureMeasurement.attributes.MeasuredValue - }, - [capabilities.relativeHumidityMeasurement.ID] = { - clusters.RelativeHumidityMeasurement.attributes.MeasuredValue - }, - [capabilities.thermostatMode.ID] = { - clusters.Thermostat.attributes.SystemMode, - clusters.Thermostat.attributes.ControlSequenceOfOperation - }, - [capabilities.thermostatOperatingState.ID] = { - clusters.Thermostat.attributes.ThermostatRunningState - }, - [capabilities.thermostatFanMode.ID] = { - clusters.FanControl.attributes.FanModeSequence, - clusters.FanControl.attributes.FanMode - }, - [capabilities.thermostatCoolingSetpoint.ID] = { - clusters.Thermostat.attributes.OccupiedCoolingSetpoint - }, - [capabilities.thermostatHeatingSetpoint.ID] = { - clusters.Thermostat.attributes.OccupiedHeatingSetpoint - }, - [capabilities.battery.ID] = { - clusters.PowerSource.attributes.BatPercentRemaining - } -} - -local function find_default_endpoint(device, cluster) - local res = device.MATTER_DEFAULT_ENDPOINT - local eps = device:get_endpoints(cluster) - table.sort(eps) - for _, v in ipairs(eps) do - if v ~= 0 then --0 is the matter RootNode endpoint - return v - end - end - device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return res +local attribute_handlers = require "thermostat_handlers.attribute_handlers" +local capability_handlers = require "thermostat_handlers.capability_handlers" +local fields = require "thermostat_utils.fields" +local thermostat_utils = require "thermostat_utils.utils" +local embedded_cluster_utils = require "thermostat_utils.embedded_cluster_utils" + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" end -local function component_to_endpoint(device, component_name) - -- Use the find_default_endpoint function to return the first endpoint that - -- supports a given cluster. - return find_default_endpoint(device, clusters.Thermostat.ID) +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" end -local function device_init(driver, device) - device:subscribe() - device:set_component_to_endpoint_fn(component_to_endpoint) +if version.api < 13 then + clusters.WaterHeaterMode = require "embedded_clusters.WaterHeaterMode" end -local function info_changed(driver, device, event, args) - --Note this is needed because device:subscribe() does not recalculate - -- the subscribed attributes each time it is run, that only happens at init. - -- This will change in the 0.48.x release of the lua libs. - for cap_id, attributes in pairs(subscribed_attributes) do - if device:supports_capability_by_id(cap_id) then - for _, attr in ipairs(attributes) do - device:add_subscribed_attribute(attr) - end - end - end - device:subscribe() -end - -local function do_configure(driver, device) - local heat_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) - local cool_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) - local auto_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.AUTOMODE}) - local thermo_eps = device:get_endpoints(clusters.Thermostat.ID) - local fan_eps = device:get_endpoints(clusters.FanControl.ID) - local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) - local battery_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) - local profile_name = "thermostat" - --Note: we have not encountered thermostats with multiple endpoints that support the Thermostat cluster - if #thermo_eps == 1 then - if #humidity_eps > 0 and #fan_eps > 0 then - profile_name = profile_name .. "-humidity" .. "-fan" - elseif #humidity_eps > 0 then - profile_name = profile_name .. "-humidity" - elseif #fan_eps > 0 then - profile_name = profile_name .. "-fan" - end - - if #heat_eps == 0 and #cool_eps == 0 then - log.warn_with({hub_logs=true}, "Thermostat does not support heating or cooling. No matching profile") - return - elseif #heat_eps > 0 and #cool_eps == 0 then - profile_name = profile_name .. "-heating-only" - elseif #cool_eps > 0 and #heat_eps == 0 then - profile_name = profile_name .. "-cooling-only" - end - - -- TODO remove this in favor of reading Thermostat clusters AttributeList attribute - -- to determine support for ThermostatRunningState - -- Add nobattery profiles if updated - profile_name = profile_name .. "-nostate" +local ThermostatLifecycleHandlers = {} - if #battery_eps == 0 then - profile_name = profile_name .. "-nobattery" - end +function ThermostatLifecycleHandlers.device_added(driver, device) + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(clusters.Thermostat.attributes.ControlSequenceOfOperation:read(device)) + req:merge(clusters.FanControl.attributes.FanModeSequence:read(device)) + req:merge(clusters.FanControl.attributes.WindSupport:read(device)) + req:merge(clusters.FanControl.attributes.RockSupport:read(device)) - log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({profile = profile_name}) + local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) + if #thermostat_eps > 0 then + req:merge(clusters.Thermostat.attributes.AttributeList:read(device)) else - log.warn_with({hub_logs=true}, "Device does not support thermostat cluster") - end - - --Query setpoint limits if needed - local setpoint_limit_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) - if #heat_eps ~= 0 and device:get_field(setpoint_limit_device_field.MIN_HEAT) == nil then - setpoint_limit_read:merge(clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:read()) - end - if #heat_eps ~= 0 and device:get_field(setpoint_limit_device_field.MAX_HEAT) == nil then - setpoint_limit_read:merge(clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:read()) - end - if #cool_eps ~= 0 and device:get_field(setpoint_limit_device_field.MIN_COOL) == nil then - setpoint_limit_read:merge(clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:read()) - end - if #cool_eps ~= 0 and device:get_field(setpoint_limit_device_field.MAX_COOL) == nil then - setpoint_limit_read:merge(clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:read()) - end - if #auto_eps ~= 0 and device:get_field(setpoint_limit_device_field.MIN_DEADBAND) == nil then - setpoint_limit_read:merge(clusters.Thermostat.attributes.MinSetpointDeadBand:read()) - end - if #setpoint_limit_read.info_blocks ~= 0 then - device:send(setpoint_limit_read) - end -end - -local function device_added(driver, device) - device:send(clusters.Thermostat.attributes.ControlSequenceOfOperation:read(device)) - device:send(clusters.FanControl.attributes.FanModeSequence:read(device)) -end - -local function temp_event_handler(attribute) - return function(driver, device, ib, response) - local temp = ib.data.value / 100.0 - local unit = "C" - device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = temp, unit = unit})) + device:set_field(fields.profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT, false) end -end - -local function humidity_attr_handler(driver, device, ib, response) - local humidity = math.floor(ib.data.value / 100.0) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) -end - -local function system_mode_handler(driver, device, ib, response) - if THERMOSTAT_MODE_MAP[ib.data.value] then - device:emit_event_for_endpoint(ib.endpoint_id, THERMOSTAT_MODE_MAP[ib.data.value]()) - local supported_modes = device:get_latest_state(device:endpoint_to_component(ib.endpoint_id), capabilities.thermostatMode.ID, capabilities.thermostatMode.supportedThermostatModes.NAME) or {} - -- TODO: remove -- this has been fixed upstream - local sm = utils.deep_copy(supported_modes) - -- if we get a mode report from the thermostat that isn't in the supported modes, then we need to update the supported modes - for _, mode in ipairs(supported_modes) do - if mode == THERMOSTAT_MODE_MAP[ib.data.value].NAME then - return - end - end - -- if we get here, then the reported mode was not in our mode map - table.insert(sm, THERMOSTAT_MODE_MAP[ib.data.value].NAME) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatMode.supportedThermostatModes(sm)) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps > 0 then + req:merge(clusters.PowerSource.attributes.AttributeList:read(device)) + else + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY) end -end - -local function running_state_handler(driver, device, ib, response) - for mode, operating_state in pairs(THERMOSTAT_OPERATING_MODE_MAP) do - if ((ib.data.value >> mode) & 1) > 0 then - device:emit_event_for_endpoint(ib.endpoint_id, operating_state()) - return - end + device:send(req) + local heat_pump_eps = thermostat_utils.get_endpoints_by_device_type(device, fields.HEAT_PUMP_DEVICE_TYPE_ID) or {} + if #heat_pump_eps > 0 then + local thermostat_eps = thermostat_utils.get_endpoints_by_device_type(device, fields.THERMOSTAT_DEVICE_TYPE_ID) or {} + local component_to_endpoint_map = { + ["thermostatOne"] = thermostat_eps[1], + ["thermostatTwo"] = thermostat_eps[2], + } + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_to_endpoint_map, {persist = true}) end - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatOperatingState.thermostatOperatingState.idle()) end -local function sequence_of_operation_handler(driver, device, ib, response) - -- the values reported here are kind of limited in terms of our mapping, i.e. there's no way to know about whether - -- or not the device supports emergency heat or fan only - local supported_modes = {capabilities.thermostatMode.thermostatMode.off.NAME} - if ib.data.value <= clusters.Thermostat.attributes.ControlSequenceOfOperation.COOLING_WITH_REHEAT then - table.insert(supported_modes, capabilities.thermostatMode.thermostatMode.cool.NAME) - elseif ib.data.value <= clusters.Thermostat.attributes.ControlSequenceOfOperation.HEATING_WITH_REHEAT then - table.insert(supported_modes, capabilities.thermostatMode.thermostatMode.heat.NAME) - elseif ib.data.value <= clusters.Thermostat.attributes.ControlSequenceOfOperation.COOLING_AND_HEATING_WITH_REHEAT then - table.insert(supported_modes, capabilities.thermostatMode.thermostatMode.cool.NAME) - table.insert(supported_modes, capabilities.thermostatMode.thermostatMode.heat.NAME) - table.insert(supported_modes, capabilities.thermostatMode.thermostatMode.auto.NAME) -- auto isn't _guaranteed_ by the spec - end - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatMode.supportedThermostatModes(supported_modes)) +function ThermostatLifecycleHandlers.do_configure(driver, device) + local device_cfg = require "thermostat_utils.device_configuration" + device_cfg.match_profile(device) end -local function fan_mode_handler(driver, device, ib, response) - if ib.data.value == clusters.FanControl.attributes.FanMode.AUTO or - ib.data.value == clusters.FanControl.attributes.FanMode.SMART then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatFanMode.thermostatFanMode.auto()) - elseif ib.data.value ~= clusters.FanControl.attributes.FanMode.OFF then -- we don't have an "off" value - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatFanMode.thermostatFanMode.on()) - end +function ThermostatLifecycleHandlers.driver_switched(driver, device) + local device_cfg = require "thermostat_utils.device_configuration" + device_cfg.match_profile(device) end -local function fan_mode_sequence_handler(driver, device, ib, response) - -- Our thermostat fan mode control is probably not granular enough to handle the supported modes here well - -- definitely meant for actual fans and not HVAC fans - if ib.data.value >= clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO and - ib.data.value <= clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatFanMode.supportedThermostatFanModes( - {capabilities.thermostatFanMode.thermostatFanMode.auto.NAME, capabilities.thermostatFanMode.thermostatFanMode.on.NAME})) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatFanMode.supportedThermostatFanModes( - {capabilities.thermostatFanMode.thermostatFanMode.on.NAME})) +function ThermostatLifecycleHandlers.device_init(driver, device) + if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) and (version.api < 15 or version.rpc < 9) then + -- assume that device is using a modular profile on 0.57 FW, override supports_capability_by_id + -- library function to utilize optional capabilities + device:extend_device("supports_capability_by_id", thermostat_utils.supports_capability_by_id_modular) end -end - -local function set_thermostat_mode(driver, device, cmd) - local mode_id = nil - for value, mode in pairs(THERMOSTAT_MODE_MAP) do - if mode.NAME == cmd.args.mode then - mode_id = value - break + device:subscribe() + device:set_component_to_endpoint_fn(thermostat_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(thermostat_utils.endpoint_to_component) + thermostat_utils.handle_thermostat_operating_state_info(device) + if not device:get_field(fields.setpoint_limit_device_field.MIN_SETPOINT_DEADBAND_CHECKED) then + local auto_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.AUTOMODE}) + --Query min setpoint deadband if needed + if #auto_eps ~= 0 and device:get_field(fields.setpoint_limit_device_field.MIN_DEADBAND) == nil then + device:send(clusters.Thermostat.attributes.MinSetpointDeadBand:read()) end end - if mode_id then - device:send(clusters.Thermostat.attributes.SystemMode:write(device, device:component_to_endpoint(cmd.component), mode_id)) - end -end - -local thermostat_mode_setter = function(mode_name) - return function(driver, device, cmd) - return set_thermostat_mode(driver, device, {component = cmd.component, args = {mode = mode_name}}) - end -end - -local function set_thermostat_fan_mode(driver, device, cmd) - local fan_mode_id = nil - if cmd.args.mode == capabilities.thermostatFanMode.thermostatFanMode.auto.NAME then - fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO - elseif cmd.args.mode == capabilities.thermostatFanMode.thermostatFanMode.on.NAME then - fan_mode_id = clusters.FanControl.attributes.FanMode.ON - end - if fan_mode_id then - device:send(clusters.FanControl.attributes.FanMode:write(device, device:component_to_endpoint(cmd.component), fan_mode_id)) - end -end -local function thermostat_fan_mode_setter(mode_name) - return function(driver, device, cmd) - return set_thermostat_fan_mode(driver, device, {component = cmd.component, args = {mode = mode_name}}) + -- device energy reporting must be handled cumulatively, periodically, or by both simulatanously. + -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. + if #embedded_cluster_utils.get_endpoints( + device, + clusters.ElectricalEnergyMeasurement.ID, + {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY} + ) == 0 then device:set_field(fields.CUMULATIVE_REPORTS_NOT_SUPPORTED, true, {persist = false}) end end -local function set_setpoint(setpoint) - return function(driver, device, cmd) - local value = cmd.args.setpoint - if (value >= 40) then -- assume this is a fahrenheit value - value = utils.f_to_c(value) - end - - -- Gather cached setpoint values when considering setpoint limits - -- Note: cached values should always exist, but defaults are chosen just in case to prevent - -- nil operation errors, and deadband logic from triggering. - local cached_cooling_val, cooling_setpoint = device:get_latest_state( - cmd.component, capabilities.thermostatCoolingSetpoint.ID, - capabilities.thermostatCoolingSetpoint.coolingSetpoint.NAME, - 100, { value = 100, unit = "C" } - ) - if cooling_setpoint and cooling_setpoint.unit == "F" then - cached_cooling_val = utils.f_to_c(cached_cooling_val) - end - local cached_heating_val, heating_setpoint = device:get_latest_state( - cmd.component, capabilities.thermostatHeatingSetpoint.ID, - capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME, - 0, { value = 0, unit = "C" } - ) - if heating_setpoint and heating_setpoint.unit == "F" then - cached_heating_val = utils.f_to_c(cached_heating_val) - end - local is_auto_capable = #device:get_endpoints( - clusters.Thermostat.ID, - {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.AUTOMODE} - ) > 0 - - --Check setpoint limits for the device - local setpoint_type = string.match(setpoint.NAME, "Heat") or "Cool" - local deadband = device:get_field(setpoint_limit_device_field.MIN_DEADBAND) or 2.5 --spec default - if setpoint_type == "Heat" then - local min = device:get_field(setpoint_limit_device_field.MIN_HEAT) or 0 - local max = device:get_field(setpoint_limit_device_field.MAX_HEAT) or 100 - if value < min or value > max then - log.warn(string.format( - "Invalid setpoint (%s) outside the min (%s) and the max (%s)", - value, min, max - )) - device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint(heating_setpoint)) - return - end - if is_auto_capable and value > (cached_cooling_val - deadband) then - log.warn(string.format( - "Invalid setpoint (%s) is greater than the cooling setpoint (%s) with the deadband (%s)", - value, cooling_setpoint, deadband - )) - device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint(heating_setpoint)) - return - end - else - local min = device:get_field(setpoint_limit_device_field.MIN_COOL) or 0 - local max = device:get_field(setpoint_limit_device_field.MAX_COOL) or 100 - if value < min or value > max then - log.warn(string.format( - "Invalid setpoint (%s) outside the min (%s) and the max (%s)", - value, min, max - )) - device:emit_event(capabilities.thermostatCoolingSetpoint.coolingSetpoint(cooling_setpoint)) - return - end - if is_auto_capable and value < (cached_heating_val + deadband) then - log.warn(string.format( - "Invalid setpoint (%s) is less than the heating setpoint (%s) with the deadband (%s)", - value, heating_setpoint, deadband - )) - device:emit_event(capabilities.thermostatCoolingSetpoint.coolingSetpoint(cooling_setpoint)) - return - end - end - device:send(setpoint:write(device, device:component_to_endpoint(cmd.component), utils.round(value * 100.0))) +function ThermostatLifecycleHandlers.info_changed(driver, device, event, args) + if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then + -- This indicates the device should be using a modular profile, so + -- re-up subscription with new capabilities using the modular supports_capability override + device:extend_device("supports_capability_by_id", thermostat_utils.supports_capability_by_id_modular) end -end -local function setpoint_limit_handler(limit_field) - return function(driver, device, ib, response) - local val = ib.data.value / 100.0 - log.info("Setting " .. limit_field .. " to " .. string.format("%s", val)) - device:set_field(limit_field, val, { persist = true }) + if not thermostat_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then + thermostat_utils.handle_thermostat_operating_state_info(device) + device:subscribe() end end -local function min_deadband_limit_handler(driver, device, ib, response) - local val = ib.data.value / 10.0 - log.info("Setting " .. setpoint_limit_device_field.MIN_DEADBAND .. " to " .. string.format("%s", val)) - device:set_field(setpoint_limit_device_field.MIN_DEADBAND, val, { persist = true }) -end - -local function battery_percent_remaining_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) - end +function ThermostatLifecycleHandlers.device_removed(driver, device) + device.log.info("device removed") end local matter_driver_template = { lifecycle_handlers = { - init = device_init, - added = device_added, - doConfigure = do_configure, - infoChanged = info_changed, + added = ThermostatLifecycleHandlers.device_added, + doConfigure = ThermostatLifecycleHandlers.do_configure, + driverSwitched = ThermostatLifecycleHandlers.driver_switched, + infoChanged = ThermostatLifecycleHandlers.info_changed, + init = ThermostatLifecycleHandlers.device_init, + removed = ThermostatLifecycleHandlers.device_removed, }, matter_handlers = { attr = { - [clusters.Thermostat.ID] = { - [clusters.Thermostat.attributes.LocalTemperature.ID] = temp_event_handler(capabilities.temperatureMeasurement.temperature), - [clusters.Thermostat.attributes.OccupiedCoolingSetpoint.ID] = temp_event_handler(capabilities.thermostatCoolingSetpoint.coolingSetpoint), - [clusters.Thermostat.attributes.OccupiedHeatingSetpoint.ID] = temp_event_handler(capabilities.thermostatHeatingSetpoint.heatingSetpoint), - [clusters.Thermostat.attributes.SystemMode.ID] = system_mode_handler, - [clusters.Thermostat.attributes.ThermostatRunningState.ID] = running_state_handler, - [clusters.Thermostat.attributes.ControlSequenceOfOperation.ID] = sequence_of_operation_handler, - [clusters.Thermostat.attributes.AbsMinHeatSetpointLimit.ID] = setpoint_limit_handler(setpoint_limit_device_field.MIN_HEAT), - [clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit.ID] = setpoint_limit_handler(setpoint_limit_device_field.MAX_HEAT), - [clusters.Thermostat.attributes.AbsMinCoolSetpointLimit.ID] = setpoint_limit_handler(setpoint_limit_device_field.MIN_COOL), - [clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit.ID] = setpoint_limit_handler(setpoint_limit_device_field.MAX_COOL), - [clusters.Thermostat.attributes.MinSetpointDeadBand.ID] = min_deadband_limit_handler, + [clusters.ActivatedCarbonFilterMonitoring.ID] = { + [clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.ID] = attribute_handlers.activated_carbon_filter_change_indication_handler, + [clusters.ActivatedCarbonFilterMonitoring.attributes.Condition.ID] = attribute_handlers.activated_carbon_filter_condition_handler, + }, + [clusters.AirQuality.ID] = { + [clusters.AirQuality.attributes.AirQuality.ID] = attribute_handlers.air_quality_handler, + }, + [clusters.ElectricalEnergyMeasurement.ID] = { + [clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported.ID] = attribute_handlers.energy_imported_factory(true), + [clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported.ID] = attribute_handlers.energy_imported_factory(false), + }, + [clusters.ElectricalPowerMeasurement.ID] = { + [clusters.ElectricalPowerMeasurement.attributes.ActivePower.ID] = attribute_handlers.active_power_handler }, [clusters.FanControl.ID] = { - [clusters.FanControl.attributes.FanModeSequence.ID] = fan_mode_sequence_handler, - [clusters.FanControl.attributes.FanMode.ID] = fan_mode_handler + [clusters.FanControl.attributes.FanMode.ID] = attribute_handlers.fan_mode_handler, + [clusters.FanControl.attributes.FanModeSequence.ID] = attribute_handlers.fan_mode_sequence_handler, + [clusters.FanControl.attributes.PercentCurrent.ID] = attribute_handlers.percent_current_handler, + [clusters.FanControl.attributes.RockSetting.ID] = attribute_handlers.rock_setting_handler, + [clusters.FanControl.attributes.RockSupport.ID] = attribute_handlers.rock_support_handler, + [clusters.FanControl.attributes.WindSetting.ID] = attribute_handlers.wind_setting_handler, + [clusters.FanControl.attributes.WindSupport.ID] = attribute_handlers.wind_support_handler, }, - [clusters.TemperatureMeasurement.ID] = { - [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = temp_event_handler(capabilities.temperatureMeasurement.temperature), + [clusters.HepaFilterMonitoring.ID] = { + [clusters.HepaFilterMonitoring.attributes.ChangeIndication.ID] = attribute_handlers.hepa_filter_change_indication_handler, + [clusters.HepaFilterMonitoring.attributes.Condition.ID] = attribute_handlers.hepa_filter_condition_handler, }, - [clusters.RelativeHumidityMeasurement.ID] = { - [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = humidity_attr_handler + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = attribute_handlers.on_off_handler, }, [clusters.PowerSource.ID] = { - [clusters.PowerSource.attributes.BatPercentRemaining.ID] = battery_percent_remaining_attr_handler - } - } + [clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler, + [clusters.PowerSource.attributes.BatChargeLevel.ID] = attribute_handlers.bat_charge_level_handler, + [clusters.PowerSource.attributes.BatPercentRemaining.ID] = attribute_handlers.bat_percent_remaining_handler, + }, + [clusters.RelativeHumidityMeasurement.ID] = { + [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.relative_humidity_measured_value_handler + }, + [clusters.TemperatureMeasurement.ID] = { + [clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.setpoint_limit_device_field.MAX_TEMP), + [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.temperature_handler_factory(capabilities.temperatureMeasurement.temperature), + [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.setpoint_limit_device_field.MIN_TEMP), + }, + [clusters.Thermostat.ID] = { + [clusters.Thermostat.attributes.AttributeList.ID] = attribute_handlers.thermostat_attribute_list_handler, + [clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit.ID] = attribute_handlers.abs_cool_setpoint_limit_factory(fields.setpoint_limit_device_field.MAX_COOL), + [clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit.ID] = attribute_handlers.abs_heat_setpoint_limit_factory(fields.setpoint_limit_device_field.MAX_HEAT), + [clusters.Thermostat.attributes.AbsMinCoolSetpointLimit.ID] = attribute_handlers.abs_cool_setpoint_limit_factory(fields.setpoint_limit_device_field.MIN_COOL), + [clusters.Thermostat.attributes.AbsMinHeatSetpointLimit.ID] = attribute_handlers.abs_heat_setpoint_limit_factory(fields.setpoint_limit_device_field.MIN_HEAT), + [clusters.Thermostat.attributes.ControlSequenceOfOperation.ID] = attribute_handlers.control_sequence_of_operation_handler, + [clusters.Thermostat.attributes.LocalTemperature.ID] = attribute_handlers.temperature_handler_factory(capabilities.temperatureMeasurement.temperature), + [clusters.Thermostat.attributes.MinSetpointDeadBand.ID] = attribute_handlers.min_setpoint_deadband_handler, + [clusters.Thermostat.attributes.OccupiedCoolingSetpoint.ID] = attribute_handlers.temperature_handler_factory(capabilities.thermostatCoolingSetpoint.coolingSetpoint), + [clusters.Thermostat.attributes.OccupiedHeatingSetpoint.ID] = attribute_handlers.temperature_handler_factory(capabilities.thermostatHeatingSetpoint.heatingSetpoint), + [clusters.Thermostat.attributes.SystemMode.ID] = attribute_handlers.system_mode_handler, + [clusters.Thermostat.attributes.ThermostatRunningState.ID] = attribute_handlers.thermostat_running_state_handler, + }, + [clusters.WaterHeaterMode.ID] = { + [clusters.WaterHeaterMode.attributes.CurrentMode.ID] = attribute_handlers.water_heater_current_mode_handler, + [clusters.WaterHeaterMode.attributes.SupportedModes.ID] = attribute_handlers.water_heater_supported_modes_handler + }, + -- CONCENTRATION MEASUREMENT CLUSTERS -- + [clusters.CarbonDioxideConcentrationMeasurement.ID] = { + [clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern), + [clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.carbonDioxideMeasurement.NAME, capabilities.carbonDioxideMeasurement.carbonDioxide, fields.units.PPM), + [clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.carbonDioxideMeasurement.NAME), + }, + [clusters.CarbonMonoxideConcentrationMeasurement.ID] = { + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.carbonMonoxideHealthConcern.carbonMonoxideHealthConcern), + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.carbonMonoxideMeasurement.NAME, capabilities.carbonMonoxideMeasurement.carbonMonoxideLevel, fields.units.PPM), + [clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.carbonMonoxideMeasurement.NAME), + }, + [clusters.FormaldehydeConcentrationMeasurement.ID] = { + [clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.formaldehydeHealthConcern.formaldehydeHealthConcern), + [clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.formaldehydeMeasurement.NAME, capabilities.formaldehydeMeasurement.formaldehydeLevel, fields.units.PPM), + [clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.formaldehydeMeasurement.NAME), + }, + [clusters.NitrogenDioxideConcentrationMeasurement.ID] = { + [clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.nitrogenDioxideHealthConcern.nitrogenDioxideHealthConcern), + [clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.nitrogenDioxideMeasurement.NAME, capabilities.nitrogenDioxideMeasurement.nitrogenDioxide, fields.units.PPM), + [clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.nitrogenDioxideMeasurement.NAME), + }, + [clusters.OzoneConcentrationMeasurement.ID] = { + [clusters.OzoneConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.ozoneHealthConcern.ozoneHealthConcern), + [clusters.OzoneConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.ozoneMeasurement.NAME, capabilities.ozoneMeasurement.ozone, fields.units.PPM), + [clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.ozoneMeasurement.NAME), + }, + [clusters.Pm1ConcentrationMeasurement.ID] = { + [clusters.Pm1ConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern), + [clusters.Pm1ConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.veryFineDustSensor.NAME, capabilities.veryFineDustSensor.veryFineDustLevel, fields.units.UGM3), + [clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.veryFineDustSensor.NAME), + }, + [clusters.Pm10ConcentrationMeasurement.ID] = { + [clusters.Pm10ConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.dustHealthConcern.dustHealthConcern), + [clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.dustSensor.NAME, capabilities.dustSensor.dustLevel, fields.units.UGM3), + [clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.dustSensor.NAME), + }, + [clusters.Pm25ConcentrationMeasurement.ID] = { + [clusters.Pm25ConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.fineDustHealthConcern.fineDustHealthConcern), + [clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.fineDustSensor.NAME, capabilities.fineDustSensor.fineDustLevel, fields.units.UGM3), + [clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.fineDustSensor.NAME), + }, + [clusters.RadonConcentrationMeasurement.ID] = { + [clusters.RadonConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.radonHealthConcern.radonHealthConcern), + [clusters.RadonConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.radonMeasurement.NAME, capabilities.radonMeasurement.radonLevel, fields.units.PCIL), + [clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.radonMeasurement.NAME), + }, + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID] = { + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue.ID] = attribute_handlers.concentration_level_value_factory(capabilities.tvocHealthConcern.tvocHealthConcern), + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.concentration_measured_value_factory(capabilities.tvocMeasurement.NAME, capabilities.tvocMeasurement.tvocLevel, fields.units.PPB), + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit.ID] = attribute_handlers.concentration_measurement_unit_factory(capabilities.tvocMeasurement.NAME), + }, + }, }, - subscribed_attributes = subscribed_attributes, capability_handlers = { - [capabilities.thermostatMode.ID] = { - [capabilities.thermostatMode.commands.setThermostatMode.NAME] = set_thermostat_mode, - [capabilities.thermostatMode.commands.auto.NAME] = thermostat_mode_setter(capabilities.thermostatMode.thermostatMode.auto.NAME), - [capabilities.thermostatMode.commands.off.NAME] = thermostat_mode_setter(capabilities.thermostatMode.thermostatMode.off.NAME), - [capabilities.thermostatMode.commands.cool.NAME] = thermostat_mode_setter(capabilities.thermostatMode.thermostatMode.cool.NAME), - [capabilities.thermostatMode.commands.heat.NAME] = thermostat_mode_setter(capabilities.thermostatMode.thermostatMode.heat.NAME), - [capabilities.thermostatMode.commands.emergencyHeat.NAME] = thermostat_mode_setter(capabilities.thermostatMode.thermostatMode.emergency_heat.NAME) + [capabilities.airConditionerFanMode.ID] = { + [capabilities.airConditionerFanMode.commands.setFanMode.NAME] = capability_handlers.fan_mode_command_factory(capabilities.airConditionerFanMode) + }, + [capabilities.airPurifierFanMode.ID] = { + [capabilities.airPurifierFanMode.commands.setAirPurifierFanMode.NAME] = capability_handlers.fan_mode_command_factory(capabilities.airPurifierFanMode) + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = capability_handlers.fan_mode_command_factory(capabilities.fanMode) + }, + [capabilities.fanOscillationMode.ID] = { + [capabilities.fanOscillationMode.commands.setFanOscillationMode.NAME] = capability_handlers.handle_set_fan_oscillation_mode, + }, + [capabilities.fanSpeedPercent.ID] = { + [capabilities.fanSpeedPercent.commands.setPercent.NAME] = capability_handlers.handle_fan_speed_set_percent, + }, + [capabilities.filterState.ID] = { + [capabilities.filterState.commands.resetFilter.NAME] = capability_handlers.handle_filter_state_reset_filter, + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = capability_handlers.handle_set_mode, + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.off.NAME] = capability_handlers.handle_switch_off, + [capabilities.switch.commands.on.NAME] = capability_handlers.handle_switch_on, + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + [capabilities.thermostatCoolingSetpoint.commands.setCoolingSetpoint.NAME] = capability_handlers.thermostat_set_setpoint_factory(clusters.Thermostat.attributes.OccupiedCoolingSetpoint) + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = capability_handlers.thermostat_set_setpoint_factory(clusters.Thermostat.attributes.OccupiedHeatingSetpoint) }, [capabilities.thermostatFanMode.ID] = { - [capabilities.thermostatFanMode.commands.setThermostatFanMode.NAME] = set_thermostat_fan_mode, - [capabilities.thermostatFanMode.commands.fanAuto.NAME] = thermostat_fan_mode_setter(capabilities.thermostatFanMode.thermostatFanMode.auto.NAME), - [capabilities.thermostatFanMode.commands.fanOn.NAME] = thermostat_fan_mode_setter(capabilities.thermostatFanMode.thermostatFanMode.on.NAME) + [capabilities.thermostatFanMode.commands.fanAuto.NAME] = capability_handlers.thermostat_fan_mode_command_factory(capabilities.thermostatFanMode.thermostatFanMode.auto.NAME), + [capabilities.thermostatFanMode.commands.fanOn.NAME] = capability_handlers.thermostat_fan_mode_command_factory(capabilities.thermostatFanMode.thermostatFanMode.on.NAME), + [capabilities.thermostatFanMode.commands.setThermostatFanMode.NAME] = capability_handlers.fan_mode_command_factory(capabilities.thermostatFanMode), + }, + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.auto.NAME] = capability_handlers.thermostat_mode_command_factory(capabilities.thermostatMode.thermostatMode.auto.NAME), + [capabilities.thermostatMode.commands.cool.NAME] = capability_handlers.thermostat_mode_command_factory(capabilities.thermostatMode.thermostatMode.cool.NAME), + [capabilities.thermostatMode.commands.emergencyHeat.NAME] = capability_handlers.thermostat_mode_command_factory(capabilities.thermostatMode.thermostatMode.emergency_heat.NAME), + [capabilities.thermostatMode.commands.heat.NAME] = capability_handlers.thermostat_mode_command_factory(capabilities.thermostatMode.thermostatMode.heat.NAME), + [capabilities.thermostatMode.commands.off.NAME] = capability_handlers.thermostat_mode_command_factory(capabilities.thermostatMode.thermostatMode.off.NAME), + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = capability_handlers.handle_set_thermostat_mode, + }, + [capabilities.windMode.ID] = { + [capabilities.windMode.commands.setWindMode.NAME] = capability_handlers.handle_set_wind_mode, + }, + }, + subscribed_attributes = { + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.airPurifierFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining + }, + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel + }, + [capabilities.energyMeter.ID] = { + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported + }, + [capabilities.fanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanOscillationMode.ID] = { + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.filterState.ID] = { + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition + }, + [capabilities.filterStatus.ID] = { + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication + }, + [capabilities.mode.ID] = { + clusters.WaterHeaterMode.attributes.CurrentMode, + clusters.WaterHeaterMode.attributes.SupportedModes + }, + [capabilities.powerConsumptionReport.ID] = { + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported + }, + [capabilities.powerMeter.ID] = { + clusters.ElectricalPowerMeasurement.attributes.ActivePower + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue }, [capabilities.thermostatCoolingSetpoint.ID] = { - [capabilities.thermostatCoolingSetpoint.commands.setCoolingSetpoint.NAME] = set_setpoint(clusters.Thermostat.attributes.OccupiedCoolingSetpoint) + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode }, [capabilities.thermostatHeatingSetpoint.ID] = { - [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_setpoint(clusters.Thermostat.attributes.OccupiedHeatingSetpoint) - } + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + -- AIR QUALITY SENSOR DEVICE TYPE SPECIFIC CAPABILITIES -- + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.atmosphericPressureMeasurement.ID] = { + clusters.PressureMeasurement.attributes.MeasuredValue + }, + [capabilities.carbonDioxideHealthConcern.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.carbonDioxideMeasurement.ID] = { + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.carbonMonoxideHealthConcern.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.carbonMonoxideMeasurement.ID] = { + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.nitrogenDioxideMeasurement.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasuredValue, + clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.ozoneHealthConcern.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.ozoneMeasurement.ID] = { + clusters.OzoneConcentrationMeasurement.attributes.MeasuredValue, + clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.radonHealthConcern.ID] = { + clusters.RadonConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.radonMeasurement.ID] = { + clusters.RadonConcentrationMeasurement.attributes.MeasuredValue, + clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.veryFineDustHealthConcern.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.veryFineDustSensor.ID] = { + clusters.Pm1ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit, + }, }, supported_capabilities = { - capabilities.thermostatMode, - capabilities.thermostatHeatingSetpoint, + capabilities.airConditionerFanMode, + capabilities.airQualityHealthConcern, + capabilities.airPurifierFanMode, + capabilities.battery, + capabilities.batteryLevel, + capabilities.carbonDioxideHealthConcern, + capabilities.carbonDioxideMeasurement, + capabilities.carbonMonoxideHealthConcern, + capabilities.carbonMonoxideMeasurement, + capabilities.dustHealthConcern, + capabilities.dustSensor, + capabilities.energyMeter, + capabilities.fanMode, + capabilities.fanOscillationMode, + capabilities.fanSpeedPercent, + capabilities.filterState, + capabilities.filterStatus, + capabilities.fineDustHealthConcern, + capabilities.fineDustSensor, + capabilities.formaldehydeHealthConcern, + capabilities.formaldehydeMeasurement, + capabilities.mode, + capabilities.nitrogenDioxideHealthConcern, + capabilities.nitrogenDioxideMeasurement, + capabilities.ozoneHealthConcern, + capabilities.ozoneMeasurement, + capabilities.powerConsumptionReport, + capabilities.powerMeter, + capabilities.radonHealthConcern, + capabilities.radonMeasurement, capabilities.thermostatCoolingSetpoint, capabilities.thermostatFanMode, + capabilities.thermostatHeatingSetpoint, + capabilities.thermostatMode, capabilities.thermostatOperatingState, - capabilities.battery, + capabilities.tvocHealthConcern, + capabilities.tvocMeasurement, + capabilities.veryFineDustHealthConcern, + capabilities.veryFineDustSensor, + capabilities.windMode, }, } diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua new file mode 100644 index 0000000000..0a97b12181 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua @@ -0,0 +1,921 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +test.set_rpc_version(0) +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local SinglePrecisionFloat = require "st.matter.data_types.SinglePrecisionFloat" +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" +end + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER"}, + } + } + } +}) + +local mock_device_rock = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-rock-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER"}, + } + } + } +}) + +local mock_device_ap_aqs = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.RadonConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + } + } +}) + +local mock_device_ap_thermo_aqs = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1} -- Temperature Sensor + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + }, + } +}) + +local mock_device_ap_thermo_aqs_preconfigured = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1} -- Temperature Sensor + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + }, + } +}) + +local cluster_subscribe_list = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition, +} + +local cluster_subscribe_list_rock = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting, + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition, +} + +local cluster_subscribe_list_configured = { + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + }, + [capabilities.airPurifierFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting, + }, + [capabilities.filterState.ID] = { + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition + }, + [capabilities.filterStatus.ID] = { + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue + } +} + +local function test_init() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + + subscribe_request = cluster_subscribe_list_rock[1]:subscribe(mock_device_rock) + for i, cluster in ipairs(cluster_subscribe_list_rock) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_rock)) + end + end + test.socket.matter:__expect_send({mock_device_rock.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_rock) +end +test.set_test_init_function(test_init) + +local function test_init_ap_aqs() + local subscribe_request_ap_aqs = cluster_subscribe_list[1]:subscribe(mock_device_ap_aqs) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request_ap_aqs:merge(cluster:subscribe(mock_device_ap_aqs)) + end + end + test.socket.matter:__expect_send({mock_device_ap_aqs.id, subscribe_request_ap_aqs}) + test.mock_device.add_test_device(mock_device_ap_aqs) +end + +local function test_init_ap_thermo_aqs_preconfigured() + local subscribe_request = nil + for _, attributes in pairs(cluster_subscribe_list_configured) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs_preconfigured.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_ap_thermo_aqs_preconfigured) +end + +local function test_init_ap_thermo_aqs() + local subscribe_request_ap_aqs = cluster_subscribe_list[1]:subscribe(mock_device_ap_thermo_aqs) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request_ap_aqs:merge(cluster:subscribe(mock_device_ap_thermo_aqs)) + end + end + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs.id, subscribe_request_ap_aqs}) + test.mock_device.add_test_device(mock_device_ap_thermo_aqs) +end + +test.register_coroutine_test( + "Test profile change on init for AP and AQS combined device type", + function() + mock_device_ap_aqs:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_ap_aqs:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_aqs.id, "doConfigure" }) + mock_device_ap_aqs:expect_metadata_update({ profile = "air-purifier-hepa-ac-aqs-co2-tvoc-meas-co2-radon-level" }) + mock_device_ap_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_ap_aqs, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change on init for AP and Thermo and AQS combined device type", + function() + mock_device_ap_thermo_aqs:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_ap_thermo_aqs:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_thermo_aqs.id, "doConfigure" }) + mock_device_ap_thermo_aqs:expect_metadata_update({ profile = "air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level" }) + mock_device_ap_thermo_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + print(mock_device_ap_thermo_aqs.profile) + end, + { + test_init = test_init_ap_thermo_aqs, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Molecular weight conversion should be handled appropriately in unit_conversion", + function () + test.socket.matter:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit:build_test_report_data( + mock_device_ap_thermo_aqs_preconfigured, 1, clusters.FormaldehydeConcentrationMeasurement.types.MeasurementUnitEnum.MGM3 + ) + }) + test.socket.matter:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue:build_test_report_data( + mock_device_ap_thermo_aqs_preconfigured, 1, SinglePrecisionFloat(0, 4, .11187500) + ) + }) + test.socket.capability:__expect_send( + mock_device_ap_thermo_aqs_preconfigured:generate_test_message("main", capabilities.formaldehydeMeasurement.formaldehydeLevel({value = 14, unit = "ppm"})) + ) + end, + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 17 + } +) + +test.register_message_test( + "setAirPurifierFanMode command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "low" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "sleep" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "auto" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.AUTO) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanModeSequence send the appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.supportedAirPurifierFanModes({ + capabilities.airPurifierFanMode.airPurifierFanMode.off.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.low.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.medium.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.high.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.supportedAirPurifierFanModes({ + capabilities.airPurifierFanMode.airPurifierFanMode.off.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.low.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.high.NAME + }, {visibility={displayed=false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan speed commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentCurrent:build_test_report_data(mock_device, 1, 10) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(10)) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanSpeedPercent", component = "main", command = "setPercent", args = { 50 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan mode handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.OFF) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.off()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.low()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.HIGH) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.high()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test filter status for HEPA and Activated Carbon filters", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.HepaFilterMonitoring.attributes.ChangeIndication.OK) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.HepaFilterMonitoring.attributes.ChangeIndication.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.replace()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.OK) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.replace()) + }, + }, + { + min_api_version = 17 + } +) + +local supportedFanWind = { + capabilities.windMode.windMode.noWind.NAME, + capabilities.windMode.windMode.sleepWind.NAME, + capabilities.windMode.windMode.naturalWind.NAME +} +test.register_message_test( + "Test wind mode", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSupport:build_test_report_data(mock_device, 1, 0x03) -- NoWind, SleepWind (0x0001), and NaturalWind (0x0002) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.supportedWindModes(supportedFanWind, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:build_test_report_data(mock_device, 1, clusters.FanControl.types.WindSettingMask.SLEEP_WIND) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.windMode.sleepWind()) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "windMode", component = "main", command = "setWindMode", args = { "naturalWind" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Set percent command should clamp invalid percentage values", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentCurrent:build_test_report_data(mock_device, 1, 255) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(100)) + }, + }, + { + min_api_version = 17 + } +) + + +local supportedFanRock = { + capabilities.fanOscillationMode.fanOscillationMode.off.NAME, + capabilities.fanOscillationMode.fanOscillationMode.horizontal.NAME, + capabilities.fanOscillationMode.fanOscillationMode.vertical.NAME, + capabilities.fanOscillationMode.fanOscillationMode.swing.NAME +} +test.register_message_test( + "Test rock mode", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSupport:build_test_report_data(mock_device_rock, 1, 0x07) -- off, RockLeftRight (0x01), RockUpDown (0x02), and RockRound (0x04) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rock:generate_test_message("main", capabilities.fanOscillationMode.supportedFanOscillationModes(supportedFanRock, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSetting:build_test_report_data(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_UP_DOWN) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rock:generate_test_message("main", capabilities.fanOscillationMode.fanOscillationMode.vertical()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSetting:build_test_report_data(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_LEFT_RIGHT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rock:generate_test_message("main", capabilities.fanOscillationMode.fanOscillationMode.horizontal()) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_rock.id, + { capability = "fanOscillationMode", component = "main", command = "setFanOscillationMode", args = { "vertical" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSetting:write(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_UP_DOWN) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test set heating setpoint on thermostat endpoint", + function() + test.socket.capability:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 21 } } + }) + test.socket.matter:__expect_send({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device_ap_thermo_aqs_preconfigured, 7, 2100) + }) + end, + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test filter state reset command", + function() + test.socket.capability:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + { capability = "filterState", component = "hepaFilter", command = "resetFilter", args = { } } + }) + test.socket.matter:__expect_send({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.HepaFilterMonitoring.server.commands.ResetCondition(mock_device_ap_thermo_aqs_preconfigured, 1) + }) + test.socket.capability:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + { capability = "filterState", component = "activatedCarbonFilter", command = "resetFilter", args = { } } + }) + test.socket.matter:__expect_send({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.ActivatedCarbonFilterMonitoring.server.commands.ResetCondition(mock_device_ap_thermo_aqs_preconfigured, 1) + }) + end, + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua new file mode 100644 index 0000000000..8bb8a6c920 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua @@ -0,0 +1,923 @@ +-- Copyright © 2025 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 SinglePrecisionFloat = require "st.matter.data_types.SinglePrecisionFloat" +local t_utils = require "integration_test.utils" +local version = require "version" + +version.api = 9 + +-- include driver-side cluster definitions to test embedded clusters on lower api versions +clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" +clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" +clusters.AirQuality = require "embedded_clusters.AirQuality" +clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" +clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" +clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" +clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" +clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" +clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" +clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" +clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" +clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" +clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000 + }, + 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.FanControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER"} + } + } + } +}) + +local mock_device_rock = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-rock-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000 + }, + 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.FanControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER"} + } + } + } +}) + +local mock_device_ap_aqs = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000 + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 0} + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.CarbonDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.RadonConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1} + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + } + } +}) + +local mock_device_ap_thermo_aqs = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000 + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7} + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14} + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0} + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1} -- Temperature Sensor + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0} + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 1} + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + } + } +}) + +local mock_device_ap_thermo_aqs_preconfigured = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000 + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7} + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14} + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1} -- Temperature Sensor + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + } + } +}) + +local cluster_subscribe_list = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition +} + +local cluster_subscribe_list_rock = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting, + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition +} + +local cluster_subscribe_list_configured = { + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airPurifierFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting + }, + [capabilities.filterState.ID] = { + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition + }, + [capabilities.filterStatus.ID] = { + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue + } +} + +local function test_init() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + + subscribe_request = cluster_subscribe_list_rock[1]:subscribe(mock_device_rock) + for i, cluster in ipairs(cluster_subscribe_list_rock) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_rock)) + end + end + test.socket.matter:__expect_send({mock_device_rock.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_rock) +end +test.set_test_init_function(test_init) + +local function test_init_ap_aqs() + local subscribe_request_ap_aqs = cluster_subscribe_list[1]:subscribe(mock_device_ap_aqs) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request_ap_aqs:merge(cluster:subscribe(mock_device_ap_aqs)) + end + end + test.socket.matter:__expect_send({mock_device_ap_aqs.id, subscribe_request_ap_aqs}) + test.mock_device.add_test_device(mock_device_ap_aqs) +end + +local function test_init_ap_thermo_aqs_preconfigured() + local subscribe_request = nil + for _, attributes in pairs(cluster_subscribe_list_configured) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs_preconfigured.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_ap_thermo_aqs_preconfigured) +end + +local function test_init_ap_thermo_aqs() + local subscribe_request_ap_aqs = cluster_subscribe_list[1]:subscribe(mock_device_ap_thermo_aqs) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request_ap_aqs:merge(cluster:subscribe(mock_device_ap_thermo_aqs)) + end + end + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs.id, subscribe_request_ap_aqs}) + test.mock_device.add_test_device(mock_device_ap_thermo_aqs) +end + +test.register_coroutine_test( + "Test profile change on init for AP and AQS combined device type", + function() + mock_device_ap_aqs:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_ap_aqs:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_aqs.id, "doConfigure" }) + mock_device_ap_aqs:expect_metadata_update({ profile = "air-purifier-hepa-ac-aqs-co2-tvoc-meas-co2-radon-level" }) + mock_device_ap_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_ap_aqs, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change on init for AP and Thermo and AQS combined device type", + function() + mock_device_ap_thermo_aqs:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_ap_thermo_aqs:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_thermo_aqs.id, "doConfigure" }) + mock_device_ap_thermo_aqs:expect_metadata_update({ profile = "air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level" }) + mock_device_ap_thermo_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + print(mock_device_ap_thermo_aqs.profile) + end, + { + test_init = test_init_ap_thermo_aqs, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Molecular weight conversion should be handled appropriately in unit_conversion", + function () + test.socket.matter:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit:build_test_report_data( + mock_device_ap_thermo_aqs_preconfigured, 1, clusters.FormaldehydeConcentrationMeasurement.types.MeasurementUnitEnum.MGM3 + ) + }) + test.socket.matter:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue:build_test_report_data( + mock_device_ap_thermo_aqs_preconfigured, 1, SinglePrecisionFloat(0, 4, .11187500) + ) + }) + test.socket.capability:__expect_send( + mock_device_ap_thermo_aqs_preconfigured:generate_test_message("main", capabilities.formaldehydeMeasurement.formaldehydeLevel({value = 14, unit = "ppm"})) + ) + end, + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 17 + } +) + +test.register_message_test( + "setAirPurifierFanMode command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "low" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "sleep" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "auto" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.AUTO) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanModeSequence send the appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.supportedAirPurifierFanModes({ + capabilities.airPurifierFanMode.airPurifierFanMode.off.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.low.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.medium.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.high.NAME + }, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanModeSequence:build_test_report_data(mock_device, 1, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.supportedAirPurifierFanModes({ + capabilities.airPurifierFanMode.airPurifierFanMode.off.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.low.NAME, + capabilities.airPurifierFanMode.airPurifierFanMode.high.NAME + }, {visibility={displayed=false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan speed commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentCurrent:build_test_report_data(mock_device, 1, 10) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(10)) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanSpeedPercent", component = "main", command = "setPercent", args = { 50 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan mode handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.OFF) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.off()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.low()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.HIGH) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.high()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test filter status for HEPA and Activated Carbon filters", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.HepaFilterMonitoring.attributes.ChangeIndication.OK) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.HepaFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.HepaFilterMonitoring.attributes.ChangeIndication.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.replace()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.OK) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.normal()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication:build_test_report_data(mock_device, 1, clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.CRITICAL) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.replace()) + } + }, + { + min_api_version = 17 + } +) + +local supportedFanWind = { + capabilities.windMode.windMode.noWind.NAME, + capabilities.windMode.windMode.sleepWind.NAME, + capabilities.windMode.windMode.naturalWind.NAME +} + +test.register_message_test( + "Test wind mode", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSupport:build_test_report_data(mock_device, 1, 0x03) -- NoWind, SleepWind (0x0001), and NaturalWind (0x0002) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.supportedWindModes(supportedFanWind, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:build_test_report_data(mock_device, 1, clusters.FanControl.types.WindSettingMask.SLEEP_WIND) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.windMode.sleepWind()) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "windMode", component = "main", command = "setWindMode", args = { "naturalWind" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Set percent command should clamp invalid percentage values", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentCurrent:build_test_report_data(mock_device, 1, 255) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(100)) + } + }, + { + min_api_version = 17 + } +) + + +local supportedFanRock = { + capabilities.fanOscillationMode.fanOscillationMode.off.NAME, + capabilities.fanOscillationMode.fanOscillationMode.horizontal.NAME, + capabilities.fanOscillationMode.fanOscillationMode.vertical.NAME, + capabilities.fanOscillationMode.fanOscillationMode.swing.NAME +} + +test.register_message_test( + "Test rock mode", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSupport:build_test_report_data(mock_device_rock, 1, 0x07) -- off, RockLeftRight (0x01), RockUpDown (0x02), and RockRound (0x04) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rock:generate_test_message("main", capabilities.fanOscillationMode.supportedFanOscillationModes(supportedFanRock, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSetting:build_test_report_data(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_UP_DOWN) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rock:generate_test_message("main", capabilities.fanOscillationMode.fanOscillationMode.vertical()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSetting:build_test_report_data(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_LEFT_RIGHT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_rock:generate_test_message("main", capabilities.fanOscillationMode.fanOscillationMode.horizontal()) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_rock.id, + { capability = "fanOscillationMode", component = "main", command = "setFanOscillationMode", args = { "vertical" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_rock.id, + clusters.FanControl.attributes.RockSetting:write(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_UP_DOWN) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test set heating setpoint on thermostat endpoint", + function() + test.socket.capability:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 21 } } + }) + test.socket.matter:__expect_send({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device_ap_thermo_aqs_preconfigured, 7, 2100) + }) + end, + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test filter state reset command", + function() + test.socket.capability:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + { capability = "filterState", component = "hepaFilter", command = "resetFilter", args = { } } + }) + test.socket.matter:__expect_send({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.HepaFilterMonitoring.server.commands.ResetCondition(mock_device_ap_thermo_aqs_preconfigured, 1) + }) + test.socket.capability:__queue_receive({ + mock_device_ap_thermo_aqs_preconfigured.id, + { capability = "filterState", component = "activatedCarbonFilter", command = "resetFilter", args = { } } + }) + test.socket.matter:__expect_send({ + mock_device_ap_thermo_aqs_preconfigured.id, + clusters.ActivatedCarbonFilterMonitoring.server.commands.ResetCondition(mock_device_ap_thermo_aqs_preconfigured, 1) + }) + end, + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua new file mode 100644 index 0000000000..6aefcb5045 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua @@ -0,0 +1,401 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" +local uint32 = require "st.matter.data_types.Uint32" +local version = require "version" + +test.disable_startup_messages() + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" +end + +local mock_device_basic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + } + } +}) + +local mock_device_ap_thermo_aqs = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1} -- Temperature Sensor + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + }, + } +}) + +local cluster_subscribe_list = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition, +} + +local cluster_subscribe_list_configured = { + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.fanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + }, + [capabilities.airPurifierFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting, + }, + [capabilities.filterState.ID] = { + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition + }, + [capabilities.filterStatus.ID] = { + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue + } +} + +local function test_init_basic() + test.mock_device.add_test_device(mock_device_basic) + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "added" }) + local read_attributes = { + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.RockSupport, + } + local read_request = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + for _, clus in ipairs(read_attributes) do + read_request:merge(clus:read(mock_device_basic)) + end + test.socket.matter:__expect_send({ mock_device_basic.id, read_request }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "init" }) + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_basic) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_basic)) + end + end + test.socket.matter:__expect_send({mock_device_basic.id, subscribe_request}) +end + +local function test_init_ap_thermo_aqs_preconfigured() + test.mock_device.add_test_device(mock_device_ap_thermo_aqs) + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_thermo_aqs.id, "added" }) + local read_attributes = { + clusters.Thermostat.attributes.AttributeList, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.RockSupport, + } + local read_request = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + for _, clus in ipairs(read_attributes) do + read_request:merge(clus:read(mock_device_ap_thermo_aqs)) + end + test.socket.matter:__expect_send({ mock_device_ap_thermo_aqs.id, read_request }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_thermo_aqs.id, "init" }) + local subscribe_request = nil + for _, attributes in pairs(cluster_subscribe_list_configured) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_ap_thermo_aqs) + else + subscribe_request:merge(attribute:subscribe(mock_device_ap_thermo_aqs)) + end + end + end + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs.id, subscribe_request}) +end + +local expected_update_metadata= { + optional_component_capabilities={ + { + "main", + {}, + }, + { + "activatedCarbonFilter", + { + "filterState", + "filterStatus", + }, + }, + { + "hepaFilter", + { + "filterState", + "filterStatus", + }, + }, + }, + profile="air-purifier-modular", +} + +local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_basic) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_basic)) + end + end + +test.register_coroutine_test( + "Test profile change on init for basic Air Purifier device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "doConfigure" }) + mock_device_basic:expect_metadata_update(expected_update_metadata) + mock_device_basic:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + test.wait_for_events() + + local updated_device_profile = t_utils.get_profile_definition("air-purifier-modular.yml", + {enabled_optional_capabilities = expected_update_metadata.optional_component_capabilities} + ) + test.socket.device_lifecycle:__queue_receive(mock_device_basic:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device_basic.id, subscribe_request}) + end, + { + test_init = test_init_basic, + min_api_version = 17 + } +) + +local expected_update_metadata= { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "temperatureMeasurement", + "fanOscillationMode", + "windMode", + "thermostatMode", + "thermostatHeatingSetpoint", + "airQualityHealthConcern", + "dustSensor", + "fineDustSensor", + "formaldehydeMeasurement", + "dustHealthConcern", + "fineDustHealthConcern", + "formaldehydeHealthConcern", + "nitrogenDioxideHealthConcern", + "tvocHealthConcern", + }, + }, + { + "activatedCarbonFilter", + { + "filterState", + "filterStatus", + }, + }, + { + "hepaFilter", + { + "filterState", + "filterStatus", + }, + }, + }, + profile="air-purifier-modular", +} + +local subscribe_request = nil +for _, attributes in pairs(cluster_subscribe_list_configured) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_ap_thermo_aqs) + else + subscribe_request:merge(attribute:subscribe(mock_device_ap_thermo_aqs)) + end + end +end + +test.register_coroutine_test( + "Test profile change on init for AP and Thermo and AQS combined device type", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_thermo_aqs.id, "doConfigure" }) + mock_device_ap_thermo_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device_ap_thermo_aqs.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device_ap_thermo_aqs, 1, {uint32(0)}) + }) + mock_device_ap_thermo_aqs:expect_metadata_update(expected_update_metadata) + + test.wait_for_events() + + local updated_device_profile = t_utils.get_profile_definition("air-purifier-modular.yml", + {enabled_optional_capabilities = expected_update_metadata.optional_component_capabilities} + ) + test.socket.device_lifecycle:__queue_receive(mock_device_ap_thermo_aqs:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs.id, subscribe_request}) + end, + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua new file mode 100644 index 0000000000..91d3303f78 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua @@ -0,0 +1,135 @@ +-- Copyright © 2025 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.matter.clusters" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("fan-rock-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 15}, + }, + device_types = { + {device_type_id = 0x002B, device_type_revision = 1,} -- Fan + } + } + } +}) + +local mock_device_generic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("fan-generic.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x002B, device_type_revision = 1,} -- Fan + } + } + } +}) + +local cluster_subscribe_list = { + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting, +} + +local cluster_subscribe_list_generic = { + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.PercentCurrent, +} + +local function test_init() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + 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) + +local function test_init_generic() + local subscribe_request = cluster_subscribe_list_generic[1]:subscribe(mock_device_generic) + for i, cluster in ipairs(cluster_subscribe_list_generic) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_generic)) + end + end + test.socket.matter:__expect_send({mock_device_generic.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_generic) +end + +test.register_coroutine_test( + "Test profile change on fan with rock and wind", + function() + mock_device:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "fan-rock-wind" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change on fan with no features", + function() + mock_device_generic:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_generic:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_generic.id, "doConfigure" }) + mock_device_generic:expect_metadata_update({ profile = "fan-generic" }) + mock_device_generic:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_generic, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua new file mode 100644 index 0000000000..66c6e593ed --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua @@ -0,0 +1,770 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local version = require "version" +local im = require "st.matter.interaction_model" + +local HEAT_PUMP_EP = 10 +local THERMOSTAT_ONE_EP = 20 +local THERMOSTAT_TWO_EP = 30 + +local HEAT_PUMP_DEVICE_TYPE_ID = 0x0309 +local THERMOSTAT_DEVICE_TYPE_ID = 0x0301 + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" +end + +local device_desc = { + profile = t_utils.get_profile_definition("heat-pump-thermostat-humidity-thermostat-humidity.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = HEAT_PUMP_EP, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 13 }, -- IMPE, CUME, PERE + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = HEAT_PUMP_DEVICE_TYPE_ID, device_type_revision = 1 } -- Heat Pump + } + }, + { + endpoint_id = THERMOSTAT_ONE_EP, + clusters = { + { cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 3 }, -- HEAT & COOL + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = THERMOSTAT_DEVICE_TYPE_ID, device_type_revision = 1 } -- Thermostat + } + }, + { + endpoint_id = THERMOSTAT_TWO_EP, + clusters = { + { cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 3 }, -- HEAT & COOL + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = THERMOSTAT_DEVICE_TYPE_ID, device_type_revision = 1 } -- Thermostat + } + }, + } +} + +local test_init_common = function(device) + test.disable_startup_messages() + test.mock_device.add_test_device(device) + local cluster_subscribe_list = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + } + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = cluster_subscribe_list[1]:subscribe(device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(device)) + end + end + test.socket.device_lifecycle:__queue_receive({ device.id, "added" }) + local read_request_on_added = { + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.RockSupport, + clusters.Thermostat.attributes.AttributeList, + } + local read_request = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + for _, clus in ipairs(read_request_on_added) do + read_request:merge(clus:read(device)) + end + test.socket.matter:__expect_send({ + device.id, read_request + }) + + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + test.socket.matter:__expect_send({ device.id, subscribe_request }) +end + +local mock_device = test.mock_device.build_test_matter_device(device_desc) +local function test_init() + test_init_common(mock_device) +end + +-- Create device with Thermostat clusters having features AUTO, HEAT & COOL +device_desc.endpoints[3].clusters[1].feature_map = 35 +device_desc.endpoints[4].clusters[1].feature_map = 35 +local mock_device_with_auto = test.mock_device.build_test_matter_device(device_desc) +local test_init_auto = function() + test_init_common(mock_device_with_auto) + test.socket.matter:__expect_send({ + mock_device_with_auto.id, clusters.Thermostat.attributes.MinSetpointDeadBand:read(mock_device_with_auto) + }) +end + +-- Set feature map of ElectricalEnergyMeasurement Cluster to only PERE and IMPE +device_desc.endpoints[2].clusters[1].feature_map = 9 + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Assert component to endpoint map", + function() + local component_to_endpoint_map = mock_device:get_field("__component_to_endpoint_map") + assert(component_to_endpoint_map["thermostatOne"] == THERMOSTAT_ONE_EP, string.format("Thermostat One Endpoint must be %d", THERMOSTAT_ONE_EP)) + assert(component_to_endpoint_map["thermostatTwo"] == THERMOSTAT_TWO_EP, string.format("Thermostat Two Endpoint must be %d", THERMOSTAT_TWO_EP)) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Heating setpoint reports from component thermostat devices should emit correct events to the correct endpoint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.OccupiedHeatingSetpoint:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { maximum = 100.0, minimum = 0.0, step = 0.1 }, unit = "C" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 40.0, unit = "C" })) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.OccupiedHeatingSetpoint:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, 23*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { maximum = 100.0, minimum = 0.0, step = 0.1 }, unit = "C" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 23.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Cooling setpoint reports reports from component thermostat devices should emit correct events to the correct endpoint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.OccupiedCoolingSetpoint:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, 39*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { maximum = 100.0, minimum = 0.0, step = 0.1 }, unit = "C" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 39.0, unit = "C" })) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.OccupiedCoolingSetpoint:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, 19*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { maximum = 100.0, minimum = 0.0, step = 0.1 }, unit = "C" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 19.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Heating setpoint commands recieved from a particular component should send the appropriate commands to the correct corresponding thermostat endpoint", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "thermostatOne", command = "setHeatingSetpoint", args = { 20 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, THERMOSTAT_ONE_EP, 20*100) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "thermostatTwo", command = "setHeatingSetpoint", args = { 25 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, THERMOSTAT_TWO_EP, 25*100) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting the cooling setpoint should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "thermostatOne", command = "setCoolingSetpoint", args = { 13 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, THERMOSTAT_ONE_EP , 13*100) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "thermostatTwo", command = "setCoolingSetpoint", args = { 13 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, THERMOSTAT_TWO_EP , 13*100) + } + } + }, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Thermostat mode reports from the component endpoints should generate correct messages to the right component", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "emergency heat"}, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.thermostatMode.emergency_heat()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, 4) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.thermostatMode.heat()) + }, + }, + { + min_api_version = 17 + } +) + +local ControlSequenceOfOperation = clusters.Thermostat.attributes.ControlSequenceOfOperation +test.register_message_test( + "Thermostat control sequence reports form component thermostats should generate correct messages to the right component", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, ControlSequenceOfOperation.COOLING_AND_HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, ControlSequenceOfOperation.HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, ControlSequenceOfOperation.COOLING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, ControlSequenceOfOperation.COOLING_AND_HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, ControlSequenceOfOperation.HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, ControlSequenceOfOperation.COOLING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "cool"}, {visibility={displayed=false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Additional mode reports from component thermostat endpoints should extend the supported modes for the correct component", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, THERMOSTAT_ONE_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "emergency heat"}, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatOne", capabilities.thermostatMode.thermostatMode.emergency_heat()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, THERMOSTAT_TWO_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "emergency heat"}, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.thermostatMode.emergency_heat()) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Additional mode reports from component thermostat endpoints should extend the supported modes for their corresponding components when auto is supported", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_with_auto.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device_with_auto, THERMOSTAT_ONE_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_with_auto:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_with_auto.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device_with_auto, THERMOSTAT_ONE_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_with_auto:generate_test_message("thermostatOne", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto", "emergency heat"}, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device_with_auto:generate_test_message("thermostatOne", capabilities.thermostatMode.thermostatMode.emergency_heat()) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_with_auto.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device_with_auto, THERMOSTAT_TWO_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_with_auto:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "emergency heat", "cool", "heat", "auto"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_with_auto.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device_with_auto, THERMOSTAT_TWO_EP, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_with_auto:generate_test_message("thermostatTwo", capabilities.thermostatMode.thermostatMode.emergency_heat()) + }, + }, + { + test_init = test_init_auto, + min_api_version = 17 + } +) + +test.register_message_test( + "Appropriate powerMeter capability events must be sent in 'W' on receiving ActivePower events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(mock_device, + HEAT_PUMP_EP, + 15000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 15.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "energyMeter capability events must be sent in 'Wh' on receiving CumulativeEnergyMeasured events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyImported:build_test_report_data(mock_device, + HEAT_PUMP_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 15000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15, unit = "Wh" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The total energy consumption of the device must be reported a maximum of every 15 minutes", + function() + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(mock_device, + HEAT_PUMP_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 20000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 20Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 20, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 20, + deltaEnergy = 0.0, + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z" + })) + ) + + test.wait_for_events() + test.mock_time.advance_time(60 * 10) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(mock_device, + HEAT_PUMP_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 30000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 30Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 30, unit = "Wh" + })) + ) + end, + { + test_init = function() + test_init() + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Ensure only the cumulative energy reports are considered if the device supports both PERE and CUME features.", + function() + test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(mock_device, + HEAT_PUMP_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 20000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 20Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 20, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:18:00Z", + deltaEnergy = 0.0, + energy = 20 + })) + ) + + test.mock_time.advance_time(180) -- move time 180s, which is less than 15m (obviously). + test.wait_for_events() + + -- do not expect energyMeter event for this report. + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported:build_test_report_data(mock_device, + HEAT_PUMP_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 20000, start_timestamp = 0, end_timestamp = 800, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 20Wh + + -- do not expect a powerConsumptionReport to be emitted + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(mock_device, + HEAT_PUMP_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 50000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 50Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 50, unit = "Wh" + })) + ) + end, + { + test_init = function() + test_init() + end, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua new file mode 100644 index 0000000000..2f7085bff2 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua @@ -0,0 +1,592 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +test.set_rpc_version(0) +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" +local clusters = require "st.matter.clusters" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("room-air-conditioner.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0072, device_type_revision = 1 } -- Room Air Conditioner + } + } + } +}) + +local mock_device_configure = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("room-air-conditioner.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0072, device_type_revision = 1} -- Room Air Conditioner + } + } + } +}) + +local mock_device_nostate = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("room-air-conditioner-fan-heating-cooling-nostate.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0072, device_type_revision = 1} -- Room Air Conditioner + } + } + } +}) + +local function test_init() + local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle"}, {visibility = {displayed = false}})) + ) + 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) + +local function test_init_configure() + local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_configure) + else + subscribe_request:merge(attribute:subscribe(mock_device_configure)) + end + end + end + test.socket.capability:__expect_send( + mock_device_configure:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__expect_send({mock_device_configure.id, subscribe_request}) + + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device_configure.id, read_setpoint_deadband}) + + test.mock_device.add_test_device(mock_device_configure) +end + +local function test_init_nostate() + local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_nostate) + else + subscribe_request:merge(attribute:subscribe(mock_device_nostate)) + end + end + end + test.socket.matter:__expect_send({mock_device_nostate.id, subscribe_request}) + + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device_nostate.id, read_setpoint_deadband}) + + test.mock_device.add_test_device(mock_device_nostate) +end + +test.register_coroutine_test( + "Test profile change on init for Room AC device type", + function() + mock_device_configure:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_configure.id, "doConfigure" }) + mock_device_configure:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device_configure.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device_configure, 1, {uint32(0x29)}) + } + ) + mock_device_configure:expect_metadata_update({ profile = "room-air-conditioner" }) + end, + { + test_init = test_init_configure, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change to profile without thermostatOperatingState on init for Room AC device type", + function() + mock_device_nostate:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_nostate.id, "doConfigure" }) + mock_device_nostate:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device_nostate.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device_nostate, 1, {uint32(0)}) + } + ) + mock_device_nostate:expect_metadata_update({ profile = "room-air-conditioner-fan-heating-cooling-nostate" }) + end, + { + test_init = test_init_nostate, + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan speed commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentCurrent:build_test_report_data(mock_device, 1, 10) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(10)) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "fanSpeedPercent", component = "main", command = "setPercent", args = { 50 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) + } + } + }, + { + min_api_version = 17 + } +) + +local supportedFanWind = { + capabilities.windMode.windMode.noWind.NAME, + capabilities.windMode.windMode.sleepWind.NAME, + capabilities.windMode.windMode.naturalWind.NAME +} +test.register_message_test( + "Test wind mode", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSupport:build_test_report_data(mock_device, 1, 0x03) -- NoWind, SleepWind (0x0001), and NaturalWind (0x0002) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.supportedWindModes(supportedFanWind, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:build_test_report_data(mock_device, 1, clusters.FanControl.types.WindSettingMask.SLEEP_WIND) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windMode.windMode.sleepWind()) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "windMode", component = "main", command = "setWindMode", args = { "naturalWind" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Test fan mode handler", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.OFF) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airConditionerFanMode.fanMode("off")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airConditionerFanMode.fanMode("low")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.FanControl.attributes.FanMode:build_test_report_data(mock_device, 1, clusters.FanControl.attributes.FanMode.HIGH) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airConditionerFanMode.fanMode("high")) + } + }, + { + min_api_version = 17 + } +) + +local FanModeSequence = clusters.FanControl.attributes.FanModeSequence +test.register_message_test( + "Room AC fan mode sequence reports should generate the appropriate supported modes", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanModeSequence:build_test_report_data(mock_device, 1, FanModeSequence.OFF_ON) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airConditionerFanMode.supportedAcFanModes({"off", "high"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanModeSequence:build_test_report_data(mock_device, 1, FanModeSequence.OFF_LOW_MED_HIGH_AUTO) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airConditionerFanMode.supportedAcFanModes({"off", "low", "medium", "high", "auto"}, {visibility={displayed=false}})) + } + }, + { + min_api_version = 17 + } +) + +local ControlSequenceOfOperation = clusters.Thermostat.attributes.ControlSequenceOfOperation +test.register_message_test( + "Room AC control sequence reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, 1, ControlSequenceOfOperation.COOLING_AND_HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, 1, ControlSequenceOfOperation.HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, 1, ControlSequenceOfOperation.COOLING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool"}, {visibility={displayed=false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Additional mode reports should extend the supported modes", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, 1, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, 1, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool", "heat", "emergency heat"}, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) + } + }, + { + min_api_version = 17 + } +) + + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua new file mode 100644 index 0000000000..aac1ecd4bc --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua @@ -0,0 +1,348 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" +local uint32 = require "st.matter.data_types.Uint32" + +test.disable_startup_messages() +test.set_rpc_version(8) + +local mock_device_basic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("room-air-conditioner.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0072, device_type_revision = 1} -- Room Air Conditioner + } + } + } +}) + +local mock_device_no_state = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("room-air-conditioner.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0072, device_type_revision = 1} -- Room Air Conditioner + } + } + } +}) + +local function initialize_mock_device(generic_mock_device, generic_subscribed_attributes) + test.mock_device.add_test_device(generic_mock_device) + local subscribe_request = nil + for _, attributes in pairs(generic_subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(generic_mock_device) + else + subscribe_request:merge(attribute:subscribe(generic_mock_device)) + end + end + end + test.socket.capability:__expect_send( + generic_mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) + return subscribe_request +end + +local function read_req_on_added(device) + local attributes = { + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.RockSupport, + clusters.Thermostat.attributes.AttributeList, + } + local read_request = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + for _, clus in ipairs(attributes) do + read_request:merge(clus:read(device)) + end + test.socket.matter:__expect_send({ device.id, read_request }) +end + +local subscribe_request_basic +local function test_init_basic() + test.socket.matter:__set_channel_ordering("relaxed") + local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "added" }) + read_req_on_added(mock_device_basic) + subscribe_request_basic = initialize_mock_device(mock_device_basic, subscribed_attributes) + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device_basic.id, read_setpoint_deadband}) +end + +local subscribed_attributes_no_state = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + +local subscribe_request_no_state = nil +for _, attributes in pairs(subscribed_attributes_no_state) do + for _, attribute in ipairs(attributes) do + if subscribe_request_no_state == nil then + subscribe_request_no_state = attribute:subscribe(mock_device_no_state) + else + subscribe_request_no_state:merge(attribute:subscribe(mock_device_no_state)) + end + end +end + +local function test_init_no_state() + test.socket.matter:__set_channel_ordering("relaxed") + local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + + test.socket.device_lifecycle:__queue_receive({ mock_device_no_state.id, "added" }) + read_req_on_added(mock_device_no_state) + -- initially, device onboards WITH thermostatOperatingState, the test below will + -- check if it is removed correctly when switching to modular profile. This is done + -- to test that cases where the modular profile is different from the static profile + -- work correctly, and thermostatOperatingState is simple to remove in these + -- test cases via the device field + initialize_mock_device(mock_device_no_state, subscribed_attributes) + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device_no_state.id, read_setpoint_deadband}) +end + +-- run the profile configuration tests +local function test_room_ac_device_type_update_modular_profile(generic_mock_device, expected_metadata, subscribe_request, thermostat_attr_list_value) + test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) + generic_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + generic_mock_device.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(generic_mock_device, 1, {thermostat_attr_list_value}) + }) + generic_mock_device:expect_metadata_update(expected_metadata) + local updated_device_profile = t_utils.get_profile_definition("air-purifier-modular.yml", + {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.socket.device_lifecycle:__queue_receive(generic_mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) +end + +local expected_metadata_basic= { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "airConditionerFanMode", + "fanSpeedPercent", + "windMode", + "thermostatHeatingSetpoint", + "thermostatCoolingSetpoint", + "thermostatOperatingState" + }, + } + }, + profile="room-air-conditioner-modular", +} + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities - basic", + function() + test_room_ac_device_type_update_modular_profile(mock_device_basic, expected_metadata_basic, subscribe_request_basic, uint32(0x29)) + end, + { + test_init = test_init_basic, + min_api_version = 17 + } +) + +local expected_metadata_no_state = { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "airConditionerFanMode", + "fanSpeedPercent", + "windMode", + "thermostatHeatingSetpoint", + "thermostatCoolingSetpoint", + }, + } + }, + profile="room-air-conditioner-modular", +} + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities - no thermo state", + function() + test_room_ac_device_type_update_modular_profile(mock_device_no_state, expected_metadata_no_state, subscribe_request_no_state, uint32(0)) + end, + { + test_init = test_init_no_state, + min_api_version = 17 + } +) +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua new file mode 100644 index 0000000000..5352a65a9d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua @@ -0,0 +1,151 @@ +-- Copyright © 2025 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.matter.clusters" +local capabilities = require "st.capabilities" +local uint32 = require "st.matter.data_types.Uint32" + +test.set_rpc_version(7) + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-batteryLevel.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + { + cluster_id = clusters.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=2, -- Cool feature only. + }, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + } + } +}) + +local cluster_subscribe = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.PowerSource.attributes.BatChargeLevel, +} + +local function test_init() + local subscribe_request= cluster_subscribe[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "cooling"}, {visibility = {displayed = false}})) + ) + test.mock_device.add_test_device(mock_device) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + local read_req = clusters.Thermostat.attributes.ControlSequenceOfOperation:read() + read_req:merge(clusters.FanControl.attributes.FanModeSequence:read()) + read_req:merge(clusters.FanControl.attributes.WindSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.Thermostat.attributes.AttributeList:read()) + read_req:merge(clusters.PowerSource.attributes.AttributeList:read()) + test.socket.matter:__expect_send({mock_device.id, read_req}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test profile change when battery percent remaining attribute (attribute ID 12) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(12)}) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(28)}) + } + ) + mock_device:expect_metadata_update({ profile = "thermostat-cooling-only-nostate" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test profile change when battery level attribute (attribute ID 14) is available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(28)}) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(14)}) + } + ) + mock_device:expect_metadata_update({ profile = "thermostat-cooling-only-nostate-batteryLevel" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test that profile does not change when battery percent remaining and battery level attributes are not available", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(10)}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua index a004c70c04..377725fba0 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua @@ -1,24 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" - +local uint32 = require "st.matter.data_types.Uint32" local clusters = require "st.matter.clusters" +test.set_rpc_version(7) + local mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + profile = t_utils.get_profile_definition("thermostat-humidity-fan-nostate.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -30,13 +21,13 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { endpoint_id = 1, clusters = { - {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, { cluster_id = clusters.Thermostat.ID, @@ -46,13 +37,16 @@ local mock_device = test.mock_device.build_test_matter_device({ }, {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat } } } }) local mock_device_simple = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("thermostat.yml"), + profile = t_utils.get_profile_definition("thermostat-nostate.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -64,7 +58,7 @@ local mock_device_simple = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { @@ -78,13 +72,16 @@ local mock_device_simple = test.mock_device.build_test_matter_device({ feature_map=2, -- Cool feature only. }, {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat } } } }) local mock_device_no_battery = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("thermostat.yml"), + profile = t_utils.get_profile_definition("thermostat-nostate.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -96,7 +93,7 @@ local mock_device_no_battery = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { @@ -109,6 +106,9 @@ local mock_device_no_battery = test.mock_device.build_test_matter_device({ feature_map=2, -- Cool feature only. }, {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat } } } @@ -118,11 +118,15 @@ local cluster_subscribe_list = { clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, - clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, clusters.FanControl.attributes.FanMode, clusters.FanControl.attributes.FanModeSequence, @@ -132,25 +136,38 @@ local cluster_subscribe_list_simple = { clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, - clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, clusters.PowerSource.attributes.BatPercentRemaining, } local cluster_subscribe_list_no_battery = { clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, - clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, } local function test_init() + -- Set MIN_SETPOINT_DEADBAND_CHECKED bypass the setpoint limit read so it does not need + -- to be checked in the init function. + mock_device:set_field("MIN_SETPOINT_DEADBAND_CHECKED", 1, {persist = true}) + mock_device_simple:set_field("MIN_SETPOINT_DEADBAND_CHECKED", 1, {persist = true}) + mock_device_no_battery:set_field("MIN_SETPOINT_DEADBAND_CHECKED", 1, {persist = true}) test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) for i, cluster in ipairs(cluster_subscribe_list) do @@ -176,6 +193,16 @@ local function test_init() test.mock_device.add_test_device(mock_device) test.mock_device.add_test_device(mock_device_simple) test.mock_device.add_test_device(mock_device_no_battery) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + local read_req = clusters.Thermostat.attributes.ControlSequenceOfOperation:read() + read_req:merge(clusters.FanControl.attributes.FanModeSequence:read()) + read_req:merge(clusters.FanControl.attributes.WindSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.PowerSource.attributes.AttributeList:read()) + read_req:merge(clusters.Thermostat.attributes.AttributeList:read()) + test.socket.matter:__expect_send({mock_device.id, read_req}) end test.set_test_init_function(test_init) @@ -183,13 +210,26 @@ test.register_coroutine_test( "Profile change on doConfigure lifecycle event due to cluster feature map", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - local read_limits = clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:read() - read_limits:merge(clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:read()) - test.socket.matter:__expect_send({mock_device.id, read_limits}) - --TODO why does provisiong state get added in the do configure event handle, but not the refres? - mock_device:expect_metadata_update({ profile = "thermostat-humidity-fan-heating-only-nostate" }) + --TODO why does provisiong state get added in the do configure event handle, but not the refres?) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(12)}) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(0x29)}) + } + ) + mock_device:expect_metadata_update({ profile = "thermostat-humidity-fan-heating-only" }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -203,32 +243,59 @@ test.register_coroutine_test( end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) -end + -- profile name does not matter, we just check that the name is different in the info_changed handler + local updates = { + profile = { + id = "new-profile" + } + } + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) +end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Profile change on doConfigure lifecycle event due to cluster feature map", function() test.socket.device_lifecycle:__queue_receive({ mock_device_simple.id, "doConfigure" }) - local read_limits = clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:read() - read_limits:merge(clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:read()) - test.socket.matter:__expect_send({mock_device_simple.id, read_limits}) - mock_device_simple:expect_metadata_update({ profile = "thermostat-cooling-only-nostate" }) mock_device_simple:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device_simple.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_simple, 1, {uint32(12)}) + } + ) + test.socket.matter:__queue_receive( + { + mock_device_simple.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device_simple, 1, {uint32(12)}) + } + ) + mock_device_simple:expect_metadata_update({ profile = "thermostat-cooling-only-nostate" }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Profile change on doConfigure lifecycle event no battery support", function() - test.socket.device_lifecycle:__queue_receive({ mock_device_no_battery.id, "doConfigure" }) - local read_limits = clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:read() - read_limits:merge(clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:read()) - test.socket.matter:__expect_send({mock_device_no_battery.id, read_limits}) + mock_device_no_battery:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + test.socket.matter:__queue_receive( + { + mock_device_no_battery.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device_no_battery, 1, {uint32(12)}) + } + ) mock_device_no_battery:expect_metadata_update({ profile = "thermostat-cooling-only-nostate-nobattery" }) - mock_device_no_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua new file mode 100644 index 0000000000..4e7704feb1 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua @@ -0,0 +1,293 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local uint32 = require "st.matter.data_types.Uint32" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.Thermostat.ID, + cluster_revision = 5, + cluster_type = "SERVER", + feature_map = 3, -- Heat and Cool features + } + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 7} + }, + device_types = { + {device_type_id = 0x002B, device_type_revision = 1} -- Fan + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + } + } +}) + +local mock_device_disorder_endpoints = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = 2, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 7} + }, + device_types = { + {device_type_id = 0x002B, device_type_revision = 1} -- Fan + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.Thermostat.ID, + cluster_revision = 5, + cluster_type = "SERVER", + feature_map = 3, -- Heat and Cool features + } + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + } + } +}) + +local cluster_subscribe_list = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, +} + +local function get_subscribe_request(device, attribute_list) + local subscribe_request = attribute_list[1]:subscribe(device) + for i, cluster in ipairs(attribute_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(device)) + end + end + return subscribe_request +end + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + local read_req = clusters.Thermostat.attributes.ControlSequenceOfOperation:read() + read_req:merge(clusters.FanControl.attributes.FanModeSequence:read()) + read_req:merge(clusters.FanControl.attributes.WindSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.Thermostat.attributes.AttributeList:read()) + test.socket.matter:__expect_send({mock_device.id, read_req}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__expect_send({mock_device.id, get_subscribe_request(mock_device, cluster_subscribe_list)}) +end +test.set_test_init_function(test_init) + +local function test_init_disorder_endpoints() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_disorder_endpoints) + + test.socket.device_lifecycle:__queue_receive({ mock_device_disorder_endpoints.id, "added" }) + local read_req = clusters.Thermostat.attributes.ControlSequenceOfOperation:read() + read_req:merge(clusters.FanControl.attributes.FanModeSequence:read()) + read_req:merge(clusters.FanControl.attributes.WindSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.Thermostat.attributes.AttributeList:read()) + test.socket.matter:__expect_send({mock_device_disorder_endpoints.id, read_req}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_disorder_endpoints.id, "init" }) + test.socket.capability:__expect_send( + mock_device_disorder_endpoints:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__expect_send({mock_device_disorder_endpoints.id, get_subscribe_request( + mock_device_disorder_endpoints, cluster_subscribe_list)}) +end + +-- run the profile configuration tests +local function test_thermostat_device_type_update_modular_profile(generic_mock_device, expected_metadata, subscribe_request) + test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) + generic_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + generic_mock_device.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(generic_mock_device, 1, {uint32(0)}) + }) + generic_mock_device:expect_metadata_update(expected_metadata) + + test.wait_for_events() + + local updated_device_profile = t_utils.get_profile_definition("thermostat-modular.yml", + {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.socket.device_lifecycle:__queue_receive(generic_mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) +end + +local expected_metadata = { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "fanSpeedPercent", + "fanMode", + "fanOscillationMode", + "thermostatHeatingSetpoint", + "thermostatCoolingSetpoint" + }, + }, + }, + profile="thermostat-modular", +} + +local new_cluster_subscribe_list = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.RockSupport, -- These two attributes will be subscribed to following the profile + clusters.FanControl.attributes.RockSetting, -- change since the fanOscillationMode capability will be enabled. +} + +test.register_coroutine_test( + "Profile change on doConfigure lifecycle event no battery & state support", + function() + test_thermostat_device_type_update_modular_profile(mock_device, expected_metadata, + get_subscribe_request(mock_device, new_cluster_subscribe_list)) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Profile change on doConfigure lifecycle event no battery & state support with disorder endpoints", + function() + test_thermostat_device_type_update_modular_profile(mock_device_disorder_endpoints, expected_metadata, + get_subscribe_request(mock_device_disorder_endpoints, new_cluster_subscribe_list)) + end, + { + test_init = test_init_disorder_endpoints, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PercentCurrent reports and setPercent commands should be handled correctly after profile change", + function() + test_thermostat_device_type_update_modular_profile(mock_device, expected_metadata, + get_subscribe_request(mock_device, new_cluster_subscribe_list)) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.FanControl.attributes.PercentCurrent:build_test_report_data(mock_device, 2, 10) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(10)) + ) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "fanSpeedPercent", component = "main", command = "setPercent", args = { 50 } } + }) + + test.socket.matter:__expect_send({ + mock_device.id, + clusters.FanControl.attributes.PercentSetting:write(mock_device, 2, 50) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua index a28296f1ec..1d772fb2f1 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua @@ -1,20 +1,10 @@ --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" - +local uint32 = require "st.matter.data_types.Uint32" local clusters = require "st.matter.clusters" local mock_device = test.mock_device.build_test_matter_device({ @@ -30,7 +20,7 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, { @@ -43,6 +33,10 @@ local mock_device = test.mock_device.build_test_matter_device({ feature_map=35, -- Heat, Cool, and Auto features. }, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat } } } @@ -53,59 +47,60 @@ local function test_init() clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.PowerSource.attributes.BatPercentRemaining, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, } - test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) for i, cluster in ipairs(cluster_subscribe_list) do if i > 1 then subscribe_request:merge(cluster:subscribe(mock_device)) end end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device.id, read_setpoint_deadband}) + test.mock_device.add_test_device(mock_device) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + local read_req = clusters.Thermostat.attributes.ControlSequenceOfOperation:read() + read_req:merge(clusters.FanControl.attributes.FanModeSequence:read()) + read_req:merge(clusters.FanControl.attributes.WindSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.FanControl.attributes.RockSupport:read()) + read_req:merge(clusters.PowerSource.attributes.AttributeList:read()) + read_req:merge(clusters.Thermostat.attributes.AttributeList:read()) + test.socket.matter:__expect_send({mock_device.id, read_req}) + + test.set_rpc_version(6) end test.set_test_init_function(test_init) -local cached_heating_setpoint = capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 24.44, unit = "C" }) -local cached_cooling_setpoint = capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 26.67, unit = "C" }) +local cached_heating_setpoint = capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 24.44, unit = "C" }, {state_change = true}) +local cached_cooling_setpoint = capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 26.67, unit = "C" }, {state_change = true}) local function configure(device) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - local read_limits = clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:read() - read_limits:merge(clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:read()) - read_limits:merge(clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:read()) - read_limits:merge(clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:read()) - read_limits:merge(clusters.Thermostat.attributes.MinSetpointDeadBand:read()) - test.socket.matter:__expect_send({device.id, read_limits}) - mock_device:expect_metadata_update({ profile = "thermostat-nostate" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() - --Populate setpoint limits - test.socket.matter:__queue_receive({ - device.id, - clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:build_test_report_data(device, 1, 1000) - }) - test.socket.matter:__queue_receive({ - device.id, - clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(device, 1, 3222) - }) - test.socket.matter:__queue_receive({ - device.id, - clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:build_test_report_data(device, 1, 1000) --10.0 celcius - }) - test.socket.matter:__queue_receive({ - device.id, - clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(device, 1, 3222) --32.22 celcius - }) - test.socket.matter:__queue_receive({ - device.id, - clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(device, 1, 16) --1.6 celcius - }) + + test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(0x0C)})}) + test.socket.matter:__queue_receive({mock_device.id, clusters.Thermostat.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(0x29)})}) + mock_device:expect_metadata_update({ profile = "thermostat" }) --populate cached setpoint values. This would normally happen due to subscription setup. test.socket.matter:__queue_receive({ @@ -117,18 +112,35 @@ local function configure(device) clusters.Thermostat.attributes.OccupiedCoolingSetpoint:build_test_report_data(mock_device, 1, 2667) --26.67 celcius }) test.socket.capability:__expect_send( - device:generate_test_message("main", cached_heating_setpoint) + device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 0.00, maximum = 100.00, step = 0.1 }, unit = "C" })) ) test.socket.capability:__expect_send( - device:generate_test_message("main", cached_cooling_setpoint) + device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 24.44, unit = "C" })) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 0.00, maximum = 100.00, step = 0.1 }, unit = "C" })) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 26.67, unit = "C" })) ) - test.wait_for_events() end test.register_coroutine_test( "Heat setpoint lower than min", function() configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:build_test_report_data(mock_device, 1, 1000) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(mock_device, 1, 3222) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) + ) + test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 9 } } @@ -136,13 +148,28 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_heating_setpoint) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Cool setpoint lower than min", function() configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:build_test_report_data(mock_device, 1, 1000) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(mock_device, 1, 3222) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) + ) + test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 9 } } @@ -150,13 +177,28 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_cooling_setpoint) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Heat setpoint higher than max", function() configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:build_test_report_data(mock_device, 1, 1000) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(mock_device, 1, 3222) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) + ) + test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 33 } } @@ -164,13 +206,28 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_heating_setpoint) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Cool setpoint higher than max", function() configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:build_test_report_data(mock_device, 1, 1000) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(mock_device, 1, 3222) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) + ) + test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 33 } } @@ -178,13 +235,21 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_cooling_setpoint) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Heat setpoint inside deadband", function() configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(mock_device, 1, 16) --1.6 celcius + }) + test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 26 } } @@ -192,13 +257,21 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_heating_setpoint) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Cool setpoint inside deadband", function() configure(mock_device) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(mock_device, 1, 16) --1.6 celcius + }) + test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 25 } } @@ -206,7 +279,116 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_cooling_setpoint) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set MIN_SETPOINT_DEADBAND_CHECKED flag on MinSetpointDeadBand attribute handler", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Thermostat.attributes.MinSetpointDeadBand:build_test_report_data(mock_device, 1, 16) --1.6 celcius + }) + test.wait_for_events() + local min_setpoint_deadband_checked = mock_device:get_field("MIN_SETPOINT_DEADBAND_CHECKED") + assert(min_setpoint_deadband_checked == true, "min_setpoint_deadband_checked is True") + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min and max heating setpoint attributes set capability constraint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:build_test_report_data(mock_device, 1, 1000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(mock_device, 1, 3222) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min and max cooling setpoint attributes set capability constraint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:build_test_report_data(mock_device, 1, 1000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(mock_device, 1, 3222) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min and max temperature attributes set capability constraint", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_device, 1, 500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 3900) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 39.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua new file mode 100644 index 0000000000..3dd153d270 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua @@ -0,0 +1,159 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=35, -- Heat, Cool, and Auto features. + }, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat + } + } + } +}) + +local function test_init() + local cluster_subscribe_list = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.PowerSource.attributes.BatPercentRemaining, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + } + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device.id, read_setpoint_deadband}) + + test.mock_device.add_test_device(mock_device) + + test.set_rpc_version(3) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Min and max heating setpoint attributes do not set capability constraint for RPC version less than 4", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit:build_test_report_data(mock_device, 1, 1000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(mock_device, 1, 3222) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min and max cooling setpoint attributes do not set capability constraint for RPC version less than 5", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit:build_test_report_data(mock_device, 1, 1000) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(mock_device, 1, 3222) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min and max temperature attributes do not set capability constraint for RPC version less than 5", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_device, 1, 500) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 4000) + } + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua index 96f2a757ea..d1373c036d 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua @@ -1,21 +1,9 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" -local utils = require "st.utils" local clusters = require "st.matter.clusters" @@ -32,7 +20,7 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode } }, { @@ -48,6 +36,46 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat + } + } + } +}) + +local mock_device_auto = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER"}, + { + cluster_id = clusters.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=35, -- Heat, Cool, and Auto features + }, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat } } } @@ -58,11 +86,16 @@ local function test_init() clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, clusters.FanControl.attributes.FanMode, clusters.FanControl.attributes.FanModeSequence, @@ -76,11 +109,48 @@ local function test_init() end end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) - + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.mock_device.add_test_device(mock_device) end test.set_test_init_function(test_init) +local function test_init_auto() + local cluster_subscribe_list = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.PowerSource.attributes.BatPercentRemaining, + } + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_auto) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_auto)) + end + end + test.socket.matter:__expect_send({mock_device_auto.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device_auto:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.socket.matter:__expect_send({mock_device_auto.id, clusters.Thermostat.attributes.MinSetpointDeadBand:read(mock_device_auto)}) + test.mock_device.add_test_device(mock_device_auto) +end + test.register_message_test( "Relative humidity reports should generate correct messages", { @@ -97,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) } + }, + { + min_api_version = 17 } ) @@ -116,6 +189,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -135,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -149,11 +228,19 @@ test.register_message_test( clusters.Thermostat.server.attributes.OccupiedHeatingSetpoint:build_test_report_data(mock_device, 1, 40*100) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { maximum = 100.0, minimum = 0.0, step = 0.1 }, unit = "C" })) + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -168,11 +255,19 @@ test.register_message_test( clusters.Thermostat.server.attributes.OccupiedCoolingSetpoint:build_test_report_data(mock_device, 1, 40*100) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { maximum = 100.0, minimum = 0.0, step = 0.1 }, unit = "C" })) + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -192,6 +287,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.cooling()) } + }, + { + min_api_version = 17 } ) @@ -211,6 +309,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 17 } ) @@ -230,6 +331,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.fan_only()) } + }, + { + min_api_version = 17 } ) @@ -249,9 +353,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.idle()) } + }, + { + min_api_version = 17 } ) +-- test.socket.matter:__expect_send({mock_device_auto.id, clusters.Thermostat.attributes.MinSetpointDeadBand:read(mock_device_auto)}) + test.register_message_test( "Thermostat mode reports should generate correct messages", { @@ -260,19 +369,30 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, 1, 5) + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, 1, 5) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) }, - { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, 1, 3) + } + }, + { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"emergency heat"})) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) }, + }, + { + min_api_version = 17 } ) @@ -291,7 +411,7 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto"})) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) }, { channel = "matter", @@ -304,7 +424,7 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "heat"})) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "heat"}, {visibility={displayed=false}})) }, { channel = "matter", @@ -317,8 +437,60 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool"})) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool"}, {visibility={displayed=false}})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Thermostat control sequence reports should generate correct messages when auto feature is supported", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + ControlSequenceOfOperation:build_test_report_data(mock_device_auto, 1, ControlSequenceOfOperation.COOLING_AND_HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + ControlSequenceOfOperation:build_test_report_data(mock_device_auto, 1, ControlSequenceOfOperation.HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "heat", "auto"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + ControlSequenceOfOperation:build_test_report_data(mock_device_auto, 1, ControlSequenceOfOperation.COOLING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "auto"}, {visibility={displayed=false}})) }, + }, + { + test_init = test_init_auto, + min_api_version = 17 } ) @@ -336,7 +508,7 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto"})) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) }, { channel = "matter", @@ -345,17 +517,113 @@ test.register_message_test( mock_device.id, clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, 1, 5) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "emergency heat"}, {visibility={displayed=false}})) }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Additional mode reports should extend the supported modes when auto is supported", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device_auto, 1, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device_auto, 1, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto", "emergency heat"}, {visibility={displayed=false}})) }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat", "auto", "emergency heat"})) + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) } + }, + { + test_init = test_init_auto, + min_api_version = 17 + } +) + +test.register_message_test( + "Additional mode reports should not extend the supported modes if they are disallowed", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device_auto, 1, 3) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "heat", "auto"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device_auto, 1, 3) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device_auto, 1, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "auto"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device_auto.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device_auto, 1, 5) + } + }, + }, + { + test_init = test_init_auto, + min_api_version = 17 } ) @@ -402,7 +670,17 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.thermostatFanMode.on()) }, - + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + FanMode:build_test_report_data(mock_device, 1, FanMode.OFF) + } + } + }, + { + min_api_version = 17 } ) @@ -421,7 +699,7 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({"on"})) + message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({"on"}, {visibility={displayed=false}})) }, { channel = "matter", @@ -434,8 +712,11 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({"auto", "on"})) + message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({"auto", "on"}, {visibility={displayed=false}})) }, + }, + { + min_api_version = 17 } ) @@ -458,6 +739,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 15*100) } } + }, + { + min_api_version = 17 } ) @@ -480,28 +764,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 1, 25*100) } } - } -) - -test.register_message_test( - "Setting the heating setpoint to a Fahrenheit value should send the appropriate commands", + }, { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 64 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, utils.round((64 - 32) * (5 / 9.0) * 100)) - } - } + min_api_version = 17 } ) @@ -524,6 +789,9 @@ test.register_message_test( clusters.Thermostat.attributes.SystemMode:write(mock_device, 1, 3) } } + }, + { + min_api_version = 17 } ) @@ -562,6 +830,9 @@ test.register_message_test( FanMode:write(mock_device, 1, FanMode.ON) } }, + }, + { + min_api_version = 17 } ) @@ -584,6 +855,9 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, 1, 5) } } + }, + { + min_api_version = 17 } ) @@ -602,18 +876,27 @@ test.register_coroutine_test("Battery percent reports should generate correct me ) ) test.wait_for_events() -end) +end, +{ + min_api_version = 17 +} +) local refresh_request = nil local attribute_refresh_list = { clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, clusters.FanControl.attributes.FanMode, clusters.FanControl.attributes.FanModeSequence, @@ -648,6 +931,9 @@ test.register_message_test( refresh_request } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua index d24d936b4d..af44bf73f6 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua @@ -1,22 +1,9 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" -local utils = require "st.utils" - local clusters = require "st.matter.clusters" local mock_device = test.mock_device.build_test_matter_device({ @@ -48,6 +35,9 @@ local mock_device = test.mock_device.build_test_matter_device({ { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, { cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER" }, { cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat } } } @@ -58,11 +48,16 @@ local function test_init() clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, clusters.FanControl.attributes.FanMode, clusters.FanControl.attributes.FanModeSequence, @@ -75,6 +70,9 @@ local function test_init() subscribe_request:merge(cluster:subscribe(mock_device)) end end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) test.mock_device.add_test_device(mock_device) end @@ -98,6 +96,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) } + }, + { + min_api_version = 17 } ) @@ -118,6 +119,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -138,6 +142,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -152,12 +159,21 @@ test.register_message_test( clusters.Thermostat.server.attributes.OccupiedHeatingSetpoint:build_test_report_data(mock_device, 3, 40 * 100) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 0.00, maximum = 100.00, step = 0.1 }, unit = "C" })) + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -172,12 +188,21 @@ test.register_message_test( clusters.Thermostat.server.attributes.OccupiedCoolingSetpoint:build_test_report_data(mock_device, 3, 40 * 100) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 0.00, maximum = 100.00, step = 0.1 }, unit = "C" })) + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -198,6 +223,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.cooling()) } + }, + { + min_api_version = 17 } ) @@ -218,6 +246,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 17 } ) @@ -238,6 +269,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.fan_only()) } + }, + { + min_api_version = 17 } ) @@ -258,6 +292,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.idle()) } + }, + { + min_api_version = 17 } ) @@ -269,20 +306,30 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, 3, 5) + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, 1, 5) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, 3, 3) + } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", - capabilities.thermostatMode.supportedThermostatModes({ "emergency heat" })) + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) }, + }, + { + min_api_version = 17 } ) @@ -303,7 +350,7 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.thermostatMode.supportedThermostatModes({ "off", "cool", "heat", "auto" })) + capabilities.thermostatMode.supportedThermostatModes({ "off", "cool", "heat" }, {visibility={displayed=false}})) }, { channel = "matter", @@ -317,7 +364,7 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.thermostatMode.supportedThermostatModes({ "off", "heat" })) + capabilities.thermostatMode.supportedThermostatModes({ "off", "heat" }, {visibility={displayed=false}})) }, { channel = "matter", @@ -331,8 +378,11 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.thermostatMode.supportedThermostatModes({ "off", "cool" })) + capabilities.thermostatMode.supportedThermostatModes({ "off", "cool" }, {visibility={displayed=false}})) }, + }, + { + min_api_version = 17 } ) @@ -351,7 +401,7 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.thermostatMode.supportedThermostatModes({ "off", "cool", "heat", "auto" })) + capabilities.thermostatMode.supportedThermostatModes({ "off", "cool", "heat" }, {visibility={displayed=false}})) }, { channel = "matter", @@ -364,14 +414,17 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) + message = mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ "off", "cool", "heat", "emergency heat" }, {visibility={displayed=false}})) }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", - capabilities.thermostatMode.supportedThermostatModes({ "off", "cool", "heat", "auto", "emergency heat" })) - } + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) + }, + }, + { + min_api_version = 17 } ) @@ -419,6 +472,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.thermostatFanMode.on()) }, + }, + { + min_api_version = 17 } ) @@ -438,7 +494,7 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.thermostatFanMode.supportedThermostatFanModes({ "on" })) + capabilities.thermostatFanMode.supportedThermostatFanModes({ "on" }, {visibility={displayed=false}})) }, { channel = "matter", @@ -452,8 +508,11 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.thermostatFanMode.supportedThermostatFanModes({ "auto", "on" })) + capabilities.thermostatFanMode.supportedThermostatFanModes({ "auto", "on" }, {visibility={displayed=false}})) }, + }, + { + min_api_version = 17 } ) @@ -476,6 +535,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 3, 15 * 100) } } + }, + { + min_api_version = 17 } ) @@ -498,29 +560,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 3, 25 * 100) } } - } -) - -test.register_message_test( - "Setting the heating setpoint to a Fahrenheit value should send the appropriate commands", + }, { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 64 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 3, - utils.round((64 - 32) * (5 / 9.0) * 100)) - } - } + min_api_version = 17 } ) @@ -543,6 +585,9 @@ test.register_message_test( clusters.Thermostat.attributes.SystemMode:write(mock_device, 3, 3) } } + }, + { + min_api_version = 17 } ) @@ -581,6 +626,9 @@ test.register_message_test( FanMode:write(mock_device, 3, FanMode.ON) } }, + }, + { + min_api_version = 17 } ) @@ -603,6 +651,9 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, 3, 5) } } + }, + { + min_api_version = 17 } ) @@ -621,18 +672,27 @@ test.register_coroutine_test("Battery percent reports should generate correct me ) ) test.wait_for_events() -end) +end, +{ + min_api_version = 17 +} +) local refresh_request = nil local attribute_refresh_list = { clusters.Thermostat.attributes.LocalTemperature, clusters.Thermostat.attributes.OccupiedCoolingSetpoint, clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, - clusters.Thermostat.attributes.LocalTemperature, clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, clusters.FanControl.attributes.FanMode, clusters.FanControl.attributes.FanModeSequence, @@ -667,6 +727,9 @@ test.register_message_test( refresh_request } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua new file mode 100644 index 0000000000..a678e85499 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua @@ -0,0 +1,333 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" +local uint32 = require "st.matter.data_types.Uint32" + +test.disable_startup_messages() + +local mock_device_basic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + { + cluster_id = clusters.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=3, -- Heat and Cool features + }, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + } + } +}) + +local mock_device_modular = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-modular.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + { + cluster_id = clusters.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=3, -- Heat and Cool features + }, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + } + } +}) + +-- create test_init functions +local function initialize_mock_device(generic_mock_device, generic_subscribed_attributes) + local subscribe_request = generic_subscribed_attributes[1]:subscribe(generic_mock_device) + for i, cluster in ipairs(generic_subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(generic_mock_device)) + end + end + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) + return subscribe_request +end + +local subscribe_request_basic +local function test_init() + test.mock_device.add_test_device(mock_device_basic) + local subscribed_attributes = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.PowerSource.attributes.BatPercentRemaining, + } + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "added" }) + local read_attributes = { + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.RockSupport, + clusters.Thermostat.attributes.AttributeList, + } + local read_request = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + for _, clus in ipairs(read_attributes) do + read_request:merge(clus:read(mock_device_basic)) + end + test.socket.matter:__expect_send({ mock_device_basic.id, read_request }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "init" }) + test.socket.capability:__expect_send( + mock_device_basic:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + subscribe_request_basic = initialize_mock_device(mock_device_basic, subscribed_attributes) +end + +-- run the profile configuration tests +local function test_thermostat_device_type_update_modular_profile(generic_mock_device, expected_metadata, subscribe_request) + test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) + generic_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + generic_mock_device.id, + clusters.Thermostat.attributes.AttributeList:build_test_report_data(generic_mock_device, 1, {uint32(0)}) + }) + generic_mock_device:expect_metadata_update(expected_metadata) + + local updated_device_profile = t_utils.get_profile_definition("thermostat-modular.yml", + {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.socket.device_lifecycle:__queue_receive(mock_device_basic:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) +end + +local expected_metadata = { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "fanMode", + "thermostatHeatingSetpoint", + "thermostatCoolingSetpoint" + }, + }, + }, + profile="thermostat-modular", +} + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities", + function() + test_thermostat_device_type_update_modular_profile(mock_device_basic, expected_metadata, subscribe_request_basic) + end, + { + test_init = test_init, + min_api_version = 17 + } +) + +local function initialize_subscribe_request(mock_device, subscribed_attributes) + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + return subscribe_request +end + + +local function test_init_modular_fingerprint() + test.mock_device.add_test_device(mock_device_modular) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "init" }) + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + }) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) +end + +test.register_coroutine_test( +"Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + [clusters.FanControl.ID] = { + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + }, + }) + local expected_metadata_modular = { + optional_component_capabilities={{"main", {"fanMode"}}}, + profile="thermostat-modular", + } + local updated_device_profile = t_utils.get_profile_definition("thermostat-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000003" + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) + end, + { test_init = test_init_modular_fingerprint } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({})) + end, + { test_init = function() test.mock_device.add_test_device(mock_device_modular) end } +) + +local function initialize_subscribe_request(mock_device, subscribed_attributes) + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + return subscribe_request +end + + +local function test_init_modular_fingerprint() + test.mock_device.add_test_device(mock_device_modular) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "init" }) + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + }) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) +end + +test.register_coroutine_test( +"Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + [clusters.FanControl.ID] = { + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + }, + }) + local expected_metadata_modular = { + optional_component_capabilities={{"main", {"fanMode"}}}, + profile="thermostat-modular", + } + local updated_device_profile = t_utils.get_profile_definition("thermostat-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000003" + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) + end, + { test_init = test_init_modular_fingerprint } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({})) + end, + { test_init = function() test.mock_device.add_test_device(mock_device_modular) end } +) + +-- run tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua new file mode 100644 index 0000000000..f7078703ac --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua @@ -0,0 +1,144 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local utils = require "st.utils" + +-- Temperature values are converted to Celsius by the hub before reaching the driver for rpc > 5. +-- This test file is meant to verify that the driver converts Fahrenheit values > 40 degrees from F to C for rpc <= 5. +test.set_rpc_version(5) + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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.FanControl.ID, cluster_type = "SERVER"}, + { + cluster_id = clusters.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=3, -- Heat and Cool features + }, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat + } + } + } +}) + +local function test_init() + local cluster_subscribe_list = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.PowerSource.attributes.BatPercentRemaining, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + 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_message_test( + "Setting the heating setpoint to a Fahrenheit value should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 90 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, + utils.round((90 - 32) * (5 / 9.0) * 100)) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 41 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, + utils.round((41 - 32) * (5 / 9.0) * 100)) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 35 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 35 * 100) + } + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua new file mode 100644 index 0000000000..b4336beeb4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua @@ -0,0 +1,440 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local version = require "version" +local clusters = require "st.matter.clusters" + +if version.api < 13 then + clusters.WaterHeaterMode = require "embedded_clusters.WaterHeaterMode" +end + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" +end + +local WATER_HEATER_EP = 10 +local ELECTRICAL_SENSOR_EP = 11 + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("water-heater-power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + 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 = WATER_HEATER_EP, + clusters = { + { + cluster_id = clusters.Thermostat.ID, + cluster_revision = 5, + cluster_type = "SERVER", + feature_map = 9, -- Heat and SCH features + }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.WaterHeaterMode.ID, cluster_type = "SERVER" }, + }, + device_types = { + {device_type_id = 0x050F, device_type_revision = 1}, -- Water Heater + } + }, + { + endpoint_id = ELECTRICAL_SENSOR_EP, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 13 }, -- IMPE, CUME, PERE + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + {device_type_id = 0x0510, device_type_revision = 1}, -- Electrical Sensor + } + } + } +}) + +local function test_init() + local cluster_subscribe_list = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.WaterHeaterMode.attributes.CurrentMode, + clusters.WaterHeaterMode.attributes.SupportedModes, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + } + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + 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_message_test( + "Heating setpoint reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.OccupiedHeatingSetpoint:build_test_report_data(mock_device, 1, 70*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 0.00, maximum = 100.00, step = 0.1 }, unit = "C" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Setting the heating setpoint should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 80 } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, WATER_HEATER_EP, 80*100) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Ensure WaterHeaderMode supportedModes are registered and setting Oven mode should send appropriate commands", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WaterHeaterMode.attributes.SupportedModes:build_test_report_data(mock_device, WATER_HEATER_EP, + { + clusters.WaterHeaterMode.types.ModeOptionStruct({ + ["label"] = "Mode 1", + ["mode"] = 0, + ["mode_tags"] = { + clusters.WaterHeaterMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.WaterHeaterMode.types.ModeOptionStruct({ + ["label"] = "Mode 2", + ["mode"] = 1, + ["mode_tags"] = { + clusters.WaterHeaterMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }) + } + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.supportedModes({ "Mode 1", "Mode 2" }, { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.supportedArguments({ "Mode 1", "Mode 2" }, { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "Mode 1" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.WaterHeaterMode.commands.ChangeToMode(mock_device, WATER_HEATER_EP, 0) -- Index where Mode 1 is stored) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Appropriate powerMeter capability events must be sent in 'W' on receiving ActivePower events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(mock_device, + ELECTRICAL_SENSOR_EP, + 15000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 15.0, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "energyMeter capability events must be sent in 'Wh' on receiving CumulativeEnergyMeasured events", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes + .CumulativeEnergyImported:build_test_report_data(mock_device, + ELECTRICAL_SENSOR_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 15000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15, unit = "Wh" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The total energy consumption of the device must be reported, but in 15+ minute intervals", + function() + test.mock_time.advance_time(901) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(mock_device, + ELECTRICAL_SENSOR_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 20000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 20Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 20, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 20, + deltaEnergy = 0.0, + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z" + })) + ) + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(mock_device, + ELECTRICAL_SENSOR_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 30000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 30Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 30, unit = "Wh" + })) + ) + + test.wait_for_events() + test.mock_time.advance_time(1001) + + + test.socket.matter:__queue_receive({ mock_device.id, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(mock_device, + ELECTRICAL_SENSOR_EP, + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 50000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) -- 30Wh + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ + value = 50, unit = "Wh" + })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 50, + deltaEnergy = 30, + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:31:41Z" + })) + ) + end, + { + test_init = function() + test_init() + end, + min_api_version = 17 + } +) + +test.register_message_test( + "WaterHeaterMode SupportedModes must be registered. CurrentMode reports should report appropriate mode capability event. Command to setMode should send appropriate changeToMode matter command", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WaterHeaterMode.attributes.SupportedModes:build_test_report_data(mock_device, + WATER_HEATER_EP, { + clusters.WaterHeaterMode.types.ModeOptionStruct({ + ["label"] = "Water Heater Mode 1", + ["mode"] = 0, + ["mode_tags"] = { + clusters.WaterHeaterMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 0 }) + } + }), + clusters.WaterHeaterMode.types.ModeOptionStruct({ + ["label"] = "Water Heater Mode 2", + ["mode"] = 1, + ["mode_tags"] = { + clusters.WaterHeaterMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 1 }) + } + }), + clusters.WaterHeaterMode.types.ModeOptionStruct({ + ["label"] = "Water Heater Mode 3", + ["mode"] = 2, + ["mode_tags"] = { + clusters.WaterHeaterMode.types.ModeTagStruct({ ["mfg_code"] = 256, ["value"] = 2 }) + } + }) + }) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.supportedModes( + { "Water Heater Mode 1", "Water Heater Mode 2", "Water Heater Mode 3" }, + { visibility = { displayed = false } })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.supportedArguments( + { "Water Heater Mode 1", "Water Heater Mode 2", "Water Heater Mode 3" }, + { visibility = { displayed = false } })) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WaterHeaterMode.attributes.CurrentMode:build_test_report_data(mock_device, WATER_HEATER_EP, 1) -- 1 is the index for Water Heater Mode 2 mode + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.mode.mode("Water Heater Mode 2")) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "Water Heater Mode 3" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.WaterHeaterMode.commands.ChangeToMode(mock_device, WATER_HEATER_EP, 2) -- Index is Water Heater Mode 3 + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "Water Heater Mode 1" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.WaterHeaterMode.commands.ChangeToMode(mock_device, WATER_HEATER_EP, 0) -- Index is Water Heater Mode 1 + } + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua b/drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua new file mode 100644 index 0000000000..6c29de18a9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua @@ -0,0 +1,63 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" + +-- Unit conversion utilities for thermostat +local thermostat_utils = require "thermostat_utils.utils" +local thermostat_fields = require "thermostat_utils.fields" + +local units = thermostat_fields.units + +test.register_coroutine_test( + "Unsupported unit conversion from PPT to MGM3 should return nil", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.PPT, units.MGM3, capabilities.ozoneMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPT to MGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from PPB to UGM3 should return nil", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.PPB, units.UGM3, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPB to UGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from NGM3 to PPM should return nil", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.NGM3, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported NGM3 to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPM to PPB should return correct value", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPM to PPB conversion") + assert(result == 100000, "Expected 100000 for PPM to PPB conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPB to PPM should return correct value", + function() + local result = thermostat_utils.convert_value_to_unit(1000, units.PPB, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPB to PPM conversion") + assert(result == 1, "Expected 1 for PPB to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unit conversion with non-numeric value should return nil", + function() + local result = thermostat_utils.convert_value_to_unit("not a number", units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil when value is not a number, got: " .. tostring(result)) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..e5b3728b61 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua @@ -0,0 +1,657 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local log = require "log" +local version = require "version" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local st_utils = require "st.utils" +local fields = require "thermostat_utils.fields" +local thermostat_utils = require "thermostat_utils.utils" + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" +end + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" +end + +if version.api < 13 then + clusters.WaterHeaterMode = require "embedded_clusters.WaterHeaterMode" +end + +local AttributeHandlers = {} + + +-- [[ THERMOSTAT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.thermostat_attribute_list_handler(driver, device, ib, response) + local device_cfg = require "thermostat_utils.device_configuration" + for _, attr in ipairs(ib.data.elements) do + -- mark whether the optional attribute ThermostatRunningState (0x029) is present and try profiling + if attr.value == 0x029 then + device:set_field(fields.profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT, true) + device_cfg.match_profile(device) + return + end + end + device:set_field(fields.profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT, false) + device_cfg.match_profile(device) +end + +function AttributeHandlers.system_mode_handler(driver, device, ib, response) + if device:get_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN) == nil then -- this being nil means the control_sequence_of_operation_handler hasn't run. + device.log.info_with({hub_logs = true}, "In the SystemMode handler: ControlSequenceOfOperation has not run yet. Exiting early.") + device:set_field(fields.SAVED_SYSTEM_MODE_IB, ib) + return + end + + local supported_modes = device:get_latest_state(device:endpoint_to_component(ib.endpoint_id), capabilities.thermostatMode.ID, capabilities.thermostatMode.supportedThermostatModes.NAME) or {} + -- check that the given mode was in the supported modes list + if thermostat_utils.tbl_contains(supported_modes, fields.THERMOSTAT_MODE_MAP[ib.data.value].NAME) then + device:emit_event_for_endpoint(ib.endpoint_id, fields.THERMOSTAT_MODE_MAP[ib.data.value]()) + return + end + -- if the value is not found in the supported modes list, check if it's disallowed and early return if so. + local disallowed_thermostat_modes = device:get_field(fields.DISALLOWED_THERMOSTAT_MODES) or {} + if thermostat_utils.tbl_contains(disallowed_thermostat_modes, fields.THERMOSTAT_MODE_MAP[ib.data.value].NAME) then + return + end + -- if we get here, then the reported mode is allowed and not in our mode map + -- add the mode to the OPTIONAL_THERMOSTAT_MODES_SEEN and supportedThermostatModes tables + local optional_modes_seen = st_utils.deep_copy(device:get_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN)) or {} + table.insert(optional_modes_seen, fields.THERMOSTAT_MODE_MAP[ib.data.value].NAME) + device:set_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN, optional_modes_seen, {persist=true}) + local sm_copy = st_utils.deep_copy(supported_modes) + table.insert(sm_copy, fields.THERMOSTAT_MODE_MAP[ib.data.value].NAME) + local supported_modes_event = capabilities.thermostatMode.supportedThermostatModes(sm_copy, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, supported_modes_event) + device:emit_event_for_endpoint(ib.endpoint_id, fields.THERMOSTAT_MODE_MAP[ib.data.value]()) +end + +function AttributeHandlers.thermostat_running_state_handler(driver, device, ib, response) + for mode, operating_state in pairs(fields.THERMOSTAT_OPERATING_MODE_MAP) do + if ((ib.data.value >> mode) & 1) > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, operating_state()) + return + end + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatOperatingState.thermostatOperatingState.idle()) +end + +function AttributeHandlers.control_sequence_of_operation_handler(driver, device, ib, response) + -- The ControlSequenceOfOperation attribute only directly specifies what can't be operated by the operating environment, not what can. + -- However, we assert here that a Cooling enum value implies that SystemMode supports cooling, and the same for a Heating enum. + -- We also assert that Off is supported if the switch capability is not supported, though per spec this is optional. + if device:get_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN) == nil then + if device:supports_capability(capabilities.switch) == false then + device:set_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN, {capabilities.thermostatMode.thermostatMode.off.NAME}, {persist=true}) + else + device:set_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN, {}, {persist=true}) + end + end + local supported_modes = st_utils.deep_copy(device:get_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN)) + local disallowed_mode_operations = {} + + local modes_for_inclusion = {} + if ib.data.value <= clusters.Thermostat.attributes.ControlSequenceOfOperation.COOLING_WITH_REHEAT then + local _, found_idx = thermostat_utils.tbl_contains(supported_modes, capabilities.thermostatMode.thermostatMode.emergency_heat.NAME) + if found_idx then + table.remove(supported_modes, found_idx) -- if seen before, remove now + end + table.insert(supported_modes, capabilities.thermostatMode.thermostatMode.cool.NAME) + table.insert(disallowed_mode_operations, capabilities.thermostatMode.thermostatMode.heat.NAME) + table.insert(disallowed_mode_operations, capabilities.thermostatMode.thermostatMode.emergency_heat.NAME) + elseif ib.data.value <= clusters.Thermostat.attributes.ControlSequenceOfOperation.HEATING_WITH_REHEAT then + local _, found_idx = thermostat_utils.tbl_contains(supported_modes, capabilities.thermostatMode.thermostatMode.precooling.NAME) + if found_idx then + table.remove(supported_modes, found_idx) -- if seen before, remove now + end + table.insert(supported_modes, capabilities.thermostatMode.thermostatMode.heat.NAME) + table.insert(disallowed_mode_operations, capabilities.thermostatMode.thermostatMode.cool.NAME) + table.insert(disallowed_mode_operations, capabilities.thermostatMode.thermostatMode.precooling.NAME) + elseif ib.data.value <= clusters.Thermostat.attributes.ControlSequenceOfOperation.COOLING_AND_HEATING_WITH_REHEAT then + table.insert(modes_for_inclusion, capabilities.thermostatMode.thermostatMode.cool.NAME) + table.insert(modes_for_inclusion, capabilities.thermostatMode.thermostatMode.heat.NAME) + end + + -- check whether the Auto Mode should be supported in SystemMode, though this is unrelated to ControlSequenceOfOperation + local auto = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.AUTOMODE}) + if #auto > 0 then + table.insert(modes_for_inclusion, capabilities.thermostatMode.thermostatMode.auto.NAME) + else + table.insert(disallowed_mode_operations, capabilities.thermostatMode.thermostatMode.auto.NAME) + end + + -- if a disallowed value was once allowed and added, it should be removed now. + for index, mode in pairs(supported_modes) do + if thermostat_utils.tbl_contains(disallowed_mode_operations, mode) then + table.remove(supported_modes, index) + end + end + -- do not include any values twice + for _, mode in pairs(modes_for_inclusion) do + if not thermostat_utils.tbl_contains(supported_modes, mode) then + table.insert(supported_modes, mode) + end + end + device:set_field(fields.DISALLOWED_THERMOSTAT_MODES, disallowed_mode_operations) + local event = capabilities.thermostatMode.supportedThermostatModes(supported_modes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + + -- will be set by the SystemMode handler if this handler hasn't run yet. + if device:get_field(fields.SAVED_SYSTEM_MODE_IB) then + AttributeHandlers.system_mode_handler(driver, device, device:get_field(fields.SAVED_SYSTEM_MODE_IB), response) + device:set_field(fields.SAVED_SYSTEM_MODE_IB, nil) + end +end + +function AttributeHandlers.min_setpoint_deadband_handler(driver, device, ib, response) + local val = ib.data.value / 10.0 + log.info("Setting " .. fields.setpoint_limit_device_field.MIN_DEADBAND .. " to " .. string.format("%s", val)) + device:set_field(fields.setpoint_limit_device_field.MIN_DEADBAND, val, { persist = true }) + device:set_field(fields.setpoint_limit_device_field.MIN_SETPOINT_DEADBAND_CHECKED, true, {persist = true}) +end + +function AttributeHandlers.abs_heat_setpoint_limit_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local MAX_TEMP_IN_C = fields.THERMOSTAT_MAX_TEMP_IN_C + local MIN_TEMP_IN_C = fields.THERMOSTAT_MIN_TEMP_IN_C + local is_water_heater_device = (thermostat_utils.get_device_type(device) == fields.WATER_HEATER_DEVICE_TYPE_ID) + if is_water_heater_device then + MAX_TEMP_IN_C = fields.WATER_HEATER_MAX_TEMP_IN_C + MIN_TEMP_IN_C = fields.WATER_HEATER_MIN_TEMP_IN_C + end + local val = ib.data.value / 100.0 + val = st_utils.clamp_value(val, MIN_TEMP_IN_C, MAX_TEMP_IN_C) + device:set_field(minOrMax, val) + local min = device:get_field(fields.setpoint_limit_device_field.MIN_HEAT) + local max = device:get_field(fields.setpoint_limit_device_field.MAX_HEAT) + if min ~= nil and max ~= nil then + if min < max then + -- Only emit the capability for RPC version >= 5 (unit conversion for + -- heating setpoint range capability is only supported for RPC >= 5) + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" })) + end + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min heating setpoint %d that is not lower than the reported max %d", min, max)) + end + end + end +end + +function AttributeHandlers.abs_cool_setpoint_limit_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local val = ib.data.value / 100.0 + val = st_utils.clamp_value(val, fields.THERMOSTAT_MIN_TEMP_IN_C, fields.THERMOSTAT_MAX_TEMP_IN_C) + device:set_field(minOrMax, val) + local min = device:get_field(fields.setpoint_limit_device_field.MIN_COOL) + local max = device:get_field(fields.setpoint_limit_device_field.MAX_COOL) + if min ~= nil and max ~= nil then + if min < max then + -- Only emit the capability for RPC version >= 5 (unit conversion for + -- cooling setpoint range capability is only supported for RPC >= 5) + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" })) + end + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min cooling setpoint %d that is not lower than the reported max %d", min, max)) + end + end + end +end + + +-- [[ TEMPERATURE MEASUREMENT CLUSER ATTRIBUTES ]] -- + +function AttributeHandlers.temperature_handler_factory(attribute) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local unit = "C" + + -- Only emit the capability for RPC version >= 5, since unit conversion for + -- range capabilities is only supported in that case. + if version.rpc >= 5 then + local event + if attribute == capabilities.thermostatCoolingSetpoint.coolingSetpoint then + local range = { + minimum = device:get_field(fields.setpoint_limit_device_field.MIN_COOL) or fields.THERMOSTAT_MIN_TEMP_IN_C, + maximum = device:get_field(fields.setpoint_limit_device_field.MAX_COOL) or fields.THERMOSTAT_MAX_TEMP_IN_C, + step = 0.1 + } + event = capabilities.thermostatCoolingSetpoint.coolingSetpointRange({value = range, unit = unit}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + elseif attribute == capabilities.thermostatHeatingSetpoint.heatingSetpoint then + local MAX_TEMP_IN_C = fields.THERMOSTAT_MAX_TEMP_IN_C + local MIN_TEMP_IN_C = fields.THERMOSTAT_MIN_TEMP_IN_C + local is_water_heater_device = thermostat_utils.get_device_type(device) == fields.WATER_HEATER_DEVICE_TYPE_ID + if is_water_heater_device then + MAX_TEMP_IN_C = fields.WATER_HEATER_MAX_TEMP_IN_C + MIN_TEMP_IN_C = fields.WATER_HEATER_MIN_TEMP_IN_C + end + + local range = { + minimum = device:get_field(fields.setpoint_limit_device_field.MIN_HEAT) or MIN_TEMP_IN_C, + maximum = device:get_field(fields.setpoint_limit_device_field.MAX_HEAT) or MAX_TEMP_IN_C, + step = 0.1 + } + event = capabilities.thermostatHeatingSetpoint.heatingSetpointRange({value = range, unit = unit}) + device:emit_event_for_endpoint(ib.endpoint_id, event) + end + end + + local temp = ib.data.value / 100.0 + device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = temp, unit = unit})) + end +end + +function AttributeHandlers.temperature_measured_value_bounds_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local temp = ib.data.value / 100.0 + local unit = "C" + temp = st_utils.clamp_value(temp, fields.THERMOSTAT_MIN_TEMP_IN_C, fields.THERMOSTAT_MAX_TEMP_IN_C) + thermostat_utils.set_field_for_endpoint(device, minOrMax, ib.endpoint_id, temp) + local min = thermostat_utils.get_field_for_endpoint(device, fields.setpoint_limit_device_field.MIN_TEMP, ib.endpoint_id) + local max = thermostat_utils.get_field_for_endpoint(device, fields.setpoint_limit_device_field.MAX_TEMP, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + -- Only emit the capability for RPC version >= 5 (unit conversion for + -- temperature range capability is only supported for RPC >= 5) + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit })) + end + thermostat_utils.set_field_for_endpoint(device, fields.setpoint_limit_device_field.MIN_TEMP, ib.endpoint_id, nil) + thermostat_utils.set_field_for_endpoint(device, fields.setpoint_limit_device_field.MAX_TEMP, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + + +--[[ FAN CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.fan_mode_handler(driver, device, ib, response) + local fan_mode_event = { + [clusters.FanControl.attributes.FanMode.OFF] = { capabilities.fanMode.fanMode.off(), + capabilities.airConditionerFanMode.fanMode("off"), + capabilities.airPurifierFanMode.airPurifierFanMode.off(), + nil }, -- 'OFF' is not supported by thermostatFanMode + [clusters.FanControl.attributes.FanMode.LOW] = { capabilities.fanMode.fanMode.low(), + capabilities.airConditionerFanMode.fanMode("low"), + capabilities.airPurifierFanMode.airPurifierFanMode.low(), + capabilities.thermostatFanMode.thermostatFanMode.on() }, + [clusters.FanControl.attributes.FanMode.MEDIUM] = { capabilities.fanMode.fanMode.medium(), + capabilities.airConditionerFanMode.fanMode("medium"), + capabilities.airPurifierFanMode.airPurifierFanMode.medium(), + capabilities.thermostatFanMode.thermostatFanMode.on() }, + [clusters.FanControl.attributes.FanMode.HIGH] = { capabilities.fanMode.fanMode.high(), + capabilities.airConditionerFanMode.fanMode("high"), + capabilities.airPurifierFanMode.airPurifierFanMode.high(), + capabilities.thermostatFanMode.thermostatFanMode.on() }, + [clusters.FanControl.attributes.FanMode.ON] = { capabilities.fanMode.fanMode.auto(), + capabilities.airConditionerFanMode.fanMode("auto"), + capabilities.airPurifierFanMode.airPurifierFanMode.auto(), + capabilities.thermostatFanMode.thermostatFanMode.on() }, + [clusters.FanControl.attributes.FanMode.AUTO] = { capabilities.fanMode.fanMode.auto(), + capabilities.airConditionerFanMode.fanMode("auto"), + capabilities.airPurifierFanMode.airPurifierFanMode.auto(), + capabilities.thermostatFanMode.thermostatFanMode.auto() }, + [clusters.FanControl.attributes.FanMode.SMART] = { capabilities.fanMode.fanMode.auto(), + capabilities.airConditionerFanMode.fanMode("auto"), + capabilities.airPurifierFanMode.airPurifierFanMode.auto(), + capabilities.thermostatFanMode.thermostatFanMode.auto() } + } + local fan_mode_idx = device:supports_capability_by_id(capabilities.fanMode.ID) and 1 or + device:supports_capability_by_id(capabilities.airConditionerFanMode.ID) and 2 or + device:supports_capability_by_id(capabilities.airPurifierFanMode.ID) and 3 or + device:supports_capability_by_id(capabilities.thermostatFanMode.ID) and 4 + if fan_mode_idx ~= false and fan_mode_event[ib.data.value][fan_mode_idx] then + device:emit_event_for_endpoint(ib.endpoint_id, fan_mode_event[ib.data.value][fan_mode_idx]) + else + log.warn(string.format("Invalid Fan Mode (%s)", ib.data.value)) + end +end + +function AttributeHandlers.fan_mode_sequence_handler(driver, device, ib, response) + local supportedFanModes, supported_fan_modes_attribute + if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then + supportedFanModes = { "off", "low", "medium", "high" } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then + supportedFanModes = { "off", "low", "high" } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then + supportedFanModes = { "off", "low", "medium", "high", "auto" } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then + supportedFanModes = { "off", "low", "high", "auto" } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_HIGH_AUTO then + supportedFanModes = { "off", "high", "auto" } + else + supportedFanModes = { "off", "high" } + end + + if device:supports_capability_by_id(capabilities.airPurifierFanMode.ID) then + supported_fan_modes_attribute = capabilities.airPurifierFanMode.supportedAirPurifierFanModes + elseif device:supports_capability_by_id(capabilities.airConditionerFanMode.ID) then + supported_fan_modes_attribute = capabilities.airConditionerFanMode.supportedAcFanModes + elseif device:supports_capability_by_id(capabilities.thermostatFanMode.ID) then + supported_fan_modes_attribute = capabilities.thermostatFanMode.supportedThermostatFanModes + -- Our thermostat fan mode control is not granular enough to handle all of the supported modes + if ib.data.value >= clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO and + ib.data.value <= clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then + supportedFanModes = { "auto", "on" } + else + supportedFanModes = { "on" } + end + else + supported_fan_modes_attribute = capabilities.fanMode.supportedFanModes + end + + local event = supported_fan_modes_attribute(supportedFanModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.percent_current_handler(driver, device, ib, response) + local speed = 0 + if ib.data.value ~= nil then + speed = st_utils.clamp_value(ib.data.value, fields.MIN_ALLOWED_PERCENT_VALUE, fields.MAX_ALLOWED_PERCENT_VALUE) + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(speed)) +end + +function AttributeHandlers.wind_support_handler(driver, device, ib, response) + local supported_wind_modes = {capabilities.windMode.windMode.noWind.NAME} + for mode, wind_mode in pairs(fields.WIND_MODE_MAP) do + if ((ib.data.value >> mode) & 1) > 0 then + table.insert(supported_wind_modes, wind_mode.NAME) + end + end + local event = capabilities.windMode.supportedWindModes(supported_wind_modes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.wind_setting_handler(driver, device, ib, response) + for index, wind_mode in pairs(fields.WIND_MODE_MAP) do + if ((ib.data.value >> index) & 1) > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, wind_mode()) + return + end + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windMode.windMode.noWind()) +end + +function AttributeHandlers.rock_support_handler(driver, device, ib, response) + local supported_rock_modes = {capabilities.fanOscillationMode.fanOscillationMode.off.NAME} + for mode, rock_mode in pairs(fields.ROCK_MODE_MAP) do + if ((ib.data.value >> mode) & 1) > 0 then + table.insert(supported_rock_modes, rock_mode.NAME) + end + end + local event = capabilities.fanOscillationMode.supportedFanOscillationModes(supported_rock_modes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.rock_setting_handler(driver, device, ib, response) + for index, rock_mode in pairs(fields.ROCK_MODE_MAP) do + if ((ib.data.value >> index) & 1) > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, rock_mode()) + return + end + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanOscillationMode.fanOscillationMode.off()) +end + + +-- [[ HEPA FILTER MONITORING CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.hepa_filter_condition_handler(driver, device, ib, response) + local component = device.profile.components["hepaFilter"] + local condition = ib.data.value + device:emit_component_event(component, capabilities.filterState.filterLifeRemaining(condition)) +end + +function AttributeHandlers.hepa_filter_change_indication_handler(driver, device, ib, response) + local component = device.profile.components["hepaFilter"] + if ib.data.value == clusters.HepaFilterMonitoring.attributes.ChangeIndication.OK then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.HepaFilterMonitoring.attributes.ChangeIndication.WARNING then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.HepaFilterMonitoring.attributes.ChangeIndication.CRITICAL then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.replace()) + end +end + + +-- [[ ACTIVATED CARBON FILTER MONITORING CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.activated_carbon_filter_condition_handler(driver, device, ib, response) + local component = device.profile.components["activatedCarbonFilter"] + local condition = ib.data.value + device:emit_component_event(component, capabilities.filterState.filterLifeRemaining(condition)) +end + +function AttributeHandlers.activated_carbon_filter_change_indication_handler(driver, device, ib, response) + local component = device.profile.components["activatedCarbonFilter"] + if ib.data.value == clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.OK then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.WARNING then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.normal()) + elseif ib.data.value == clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication.CRITICAL then + device:emit_component_event(component, capabilities.filterStatus.filterStatus.replace()) + end +end + + +--[[ AIR QUALITY SENSOR CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.air_quality_handler(driver, device, ib, response) + local state = ib.data.value + if state == 0 then -- Unknown + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.unknown()) + elseif state == 1 then -- Good + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.good()) + elseif state == 2 then -- Fair + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.moderate()) + elseif state == 3 then -- Moderate + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.slightlyUnhealthy()) + elseif state == 4 then -- Poor + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.unhealthy()) + elseif state == 5 then -- VeryPoor + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.veryUnhealthy()) + elseif state == 6 then -- ExtremelyPoor + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.hazardous()) + end +end + + +-- [[ CONCENTRATION CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.concentration_measurement_unit_factory(capability_name) + return function(driver, device, ib, response) + device:set_field(capability_name.."_unit", ib.data.value, {persist = true}) + end +end + +function AttributeHandlers.concentration_level_value_factory(attribute) + return function(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, attribute(fields.level_strings[ib.data.value])) + end +end + +function AttributeHandlers.concentration_measured_value_factory(capability_name, attribute, target_unit) + return function(driver, device, ib, response) + if not ib.data.value then return end + local reporting_unit = device:get_field(capability_name.."_unit") or fields.unit_default[capability_name] + local converted_value = thermostat_utils.convert_value_to_unit(ib.data.value, reporting_unit, target_unit, capability_name) + + if converted_value then + device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = converted_value, unit = fields.unit_strings[target_unit]})) + -- handle case where device profile supports both fineDustLevel and dustLevel + if capability_name == capabilities.fineDustSensor.NAME and device:supports_capability(capabilities.dustSensor) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.dustSensor.fineDustLevel({value = converted_value, unit = fields.unit_strings[target_unit]})) + end + end + end +end + + +-- [[ POWER SOURCE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.bat_percent_remaining_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response) + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) + local device_cfg = require "thermostat_utils.device_configuration" + for _, attr in ipairs(ib.data.elements) do + -- mark if the device if BatPercentRemaining (Attribute ID 0x0C) or + -- BatChargeLevel (Attribute ID 0x0E) is present and try profiling. + if attr.value == 0x0C then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE) + device_cfg.match_profile(device) + return + elseif attr.value == 0x0E then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL) + device_cfg.match_profile(device) + return + end + end +end + + +-- [[ ELECTRICAL POWER MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.active_power_handler(driver, device, ib, response) + if ib.data.value then + local watt_value = ib.data.value / 1000 + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.powerMeter.power({ value = watt_value, unit = "W" })) + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("powerMeter","power") + end + end +end + + +-- [[ ELECTRICAL ENERGY MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +local function periodic_energy_imported_handler(driver, device, ib, response) + if ib.data then + if version.api < 11 then + clusters.ElectricalEnergyMeasurement.server.attributes.PeriodicEnergyImported:augment_type(ib.data) + end + local endpoint_id = string.format(ib.endpoint_id) + local energy_imported_Wh = st_utils.round(ib.data.elements.energy.value / 1000) --convert mWh to Wh + local cumulative_energy_imported = device:get_field(fields.TOTAL_CUMULATIVE_ENERGY_IMPORTED_MAP) or {} + cumulative_energy_imported[endpoint_id] = cumulative_energy_imported[endpoint_id] or 0 + cumulative_energy_imported[endpoint_id] = cumulative_energy_imported[endpoint_id] + energy_imported_Wh + device:set_field(fields.TOTAL_CUMULATIVE_ENERGY_IMPORTED_MAP, cumulative_energy_imported, { persist = true }) + local total_cumulative_energy_imported = thermostat_utils.get_total_cumulative_energy_imported(device) + device:emit_component_event(device.profile.components["main"], ib.endpoint_id, capabilities.energyMeter.energy({value = total_cumulative_energy_imported, unit = "Wh"})) + thermostat_utils.report_power_consumption_to_st_energy(device, total_cumulative_energy_imported) + end +end + +local function cumulative_energy_imported_handler(driver, device, ib, response) + if ib.data then + if version.api < 11 then + clusters.ElectricalEnergyMeasurement.server.attributes.CumulativeEnergyImported:augment_type(ib.data) + end + local endpoint_id = string.format(ib.endpoint_id) + local cumulative_energy_imported = device:get_field(fields.TOTAL_CUMULATIVE_ENERGY_IMPORTED_MAP) or {} + local cumulative_energy_imported_Wh = st_utils.round( ib.data.elements.energy.value / 1000) -- convert mWh to Wh + cumulative_energy_imported[endpoint_id] = cumulative_energy_imported_Wh + device:set_field(fields.TOTAL_CUMULATIVE_ENERGY_IMPORTED_MAP, cumulative_energy_imported, { persist = true }) + local total_cumulative_energy_imported = thermostat_utils.get_total_cumulative_energy_imported(device) + device:emit_component_event(device.profile.components["main"], capabilities.energyMeter.energy({ value = total_cumulative_energy_imported, unit = "Wh" })) + thermostat_utils.report_power_consumption_to_st_energy(device, total_cumulative_energy_imported) + end +end + +function AttributeHandlers.energy_imported_factory(is_cumulative_report) + return function(driver, device, ib, response) + if is_cumulative_report then + cumulative_energy_imported_handler(driver, device, ib, response) + elseif device:get_field(fields.CUMULATIVE_REPORTS_NOT_SUPPORTED) then + periodic_energy_imported_handler(driver, device, ib, response) + end + end +end + + +-- [[ WATER HEATER MODE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.water_heater_supported_modes_handler(driver, device, ib, response) + local supportWaterHeaterModes = {} + local supportWaterHeaterModesWithIdx = {} + for _, mode in ipairs(ib.data.elements) do + if version.api < 13 then + clusters.WaterHeaterMode.types.ModeOptionStruct:augment_type(mode) + end + table.insert(supportWaterHeaterModes, mode.elements.label.value) + table.insert(supportWaterHeaterModesWithIdx, {mode.elements.mode.value, mode.elements.label.value}) + end + device:set_field(fields.SUPPORTED_WATER_HEATER_MODES_WITH_IDX, supportWaterHeaterModesWithIdx, { persist = true }) + local event = capabilities.mode.supportedModes(supportWaterHeaterModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supportWaterHeaterModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.water_heater_current_mode_handler(driver, device, ib, response) + device.log.info(string.format("water_heater_current_mode_handler mode: %s", ib.data.value)) + local supportWaterHeaterModesWithIdx = device:get_field(fields.SUPPORTED_WATER_HEATER_MODES_WITH_IDX) or {} + local currentMode = ib.data.value + for i, mode in ipairs(supportWaterHeaterModesWithIdx) do + if mode[1] == currentMode then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode[2])) + break + end + end +end + + +-- [[ RELATIVE HUMIDITY MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.relative_humidity_measured_value_handler(driver, device, ib, response) + local humidity = math.floor(ib.data.value / 100.0) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) +end + + +-- [[ ON OFF CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.on_off_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) + end +end + +return AttributeHandlers diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua new file mode 100644 index 0000000000..4a32b7a700 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/capability_handlers.lua @@ -0,0 +1,256 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local log = require "log" +local version = require "version" +local st_utils = require "st.utils" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local fields = require "thermostat_utils.fields" +local thermostat_utils = require "thermostat_utils.utils" + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" +end + +if version.api < 13 then + clusters.WaterHeaterMode = require "embedded_clusters.WaterHeaterMode" +end + +local CapabilityHandlers = {} + + +-- [[ FAN SPEED PERCENT CAPABILITY HANDLERS ]] -- + +function CapabilityHandlers.handle_fan_speed_set_percent(driver, device, cmd) + local speed = math.floor(cmd.args.percent) + device:send(clusters.FanControl.attributes.PercentSetting:write(device, thermostat_utils.component_to_endpoint(device, cmd.component, clusters.FanControl.ID), speed)) +end + + +-- [[ WIND MODE CAPABILITY HANDLERS ]] -- + +function CapabilityHandlers.handle_set_wind_mode(driver, device, cmd) + local wind_mode = 0 + if cmd.args.windMode == capabilities.windMode.windMode.sleepWind.NAME then + wind_mode = clusters.FanControl.types.WindSupportMask.SLEEP_WIND + elseif cmd.args.windMode == capabilities.windMode.windMode.naturalWind.NAME then + wind_mode = clusters.FanControl.types.WindSupportMask.NATURAL_WIND + end + device:send(clusters.FanControl.attributes.WindSetting:write(device, thermostat_utils.component_to_endpoint(device, cmd.component, clusters.FanControl.ID), wind_mode)) +end + + +-- [[ FAN OSCILLATION MODE HANDLERS ]] -- + +function CapabilityHandlers.handle_set_fan_oscillation_mode(driver, device, cmd) + local rock_mode = 0 + if cmd.args.fanOscillationMode == capabilities.fanOscillationMode.fanOscillationMode.horizontal.NAME then + rock_mode = clusters.FanControl.types.RockSupportMask.ROCK_LEFT_RIGHT + elseif cmd.args.fanOscillationMode == capabilities.fanOscillationMode.fanOscillationMode.vertical.NAME then + rock_mode = clusters.FanControl.types.RockSupportMask.ROCK_UP_DOWN + elseif cmd.args.fanOscillationMode == capabilities.fanOscillationMode.fanOscillationMode.swing.NAME then + rock_mode = clusters.FanControl.types.RockSupportMask.ROCK_ROUND + end + device:send(clusters.FanControl.attributes.RockSetting:write(device, thermostat_utils.component_to_endpoint(device, cmd.component, clusters.FanControl.ID), rock_mode)) +end + + +-- [[ MODE CAPABILITY HANDLERS ]] -- + +function CapabilityHandlers.handle_set_mode(driver, device, cmd) + device.log.info(string.format("set_water_heater_mode mode: %s", cmd.args.mode)) + local endpoint_id = thermostat_utils.component_to_endpoint(device, cmd.component, clusters.Thermostat.ID) + local supportedWaterHeaterModesWithIdx = device:get_field(fields.SUPPORTED_WATER_HEATER_MODES_WITH_IDX) or {} + for i, mode in ipairs(supportedWaterHeaterModesWithIdx) do + if cmd.args.mode == mode[2] then + device:send(clusters.WaterHeaterMode.commands.ChangeToMode(device, endpoint_id, mode[1])) + return + end + end +end + + +-- [[ FILTER STATE CAPABLITY HANDLERS ]] -- + +function CapabilityHandlers.handle_filter_state_reset_filter(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + if cmd.component == "hepaFilter" then + device:send(clusters.HepaFilterMonitoring.server.commands.ResetCondition(device, endpoint_id)) + else + device:send(clusters.ActivatedCarbonFilterMonitoring.server.commands.ResetCondition(device, endpoint_id)) + end +end + + +-- [[ SWITCH CAPABLITY HANDLERS ]] -- + +function CapabilityHandlers.handle_switch_on(driver, device, cmd) + local endpoint_id = thermostat_utils.component_to_endpoint(device, cmd.component, clusters.OnOff.ID) + local req = clusters.OnOff.server.commands.On(device, endpoint_id) + device:send(req) +end + +function CapabilityHandlers.handle_switch_off(driver, device, cmd) + local endpoint_id = thermostat_utils.component_to_endpoint(device, cmd.component, clusters.OnOff.ID) + local req = clusters.OnOff.server.commands.Off(device, endpoint_id) + device:send(req) +end + + +-- [[ THERMOSTAT MODE CAPABLITY HANDLERS ]] -- + +function CapabilityHandlers.handle_set_thermostat_mode(driver, device, cmd) + local mode_id = nil + for value, mode in pairs(fields.THERMOSTAT_MODE_MAP) do + if mode.NAME == cmd.args.mode then + mode_id = value + break + end + end + if mode_id then + device:send(clusters.Thermostat.attributes.SystemMode:write(device, thermostat_utils.component_to_endpoint(device, cmd.component, clusters.Thermostat.ID), mode_id)) + end +end + +function CapabilityHandlers.thermostat_mode_command_factory(mode_name) + return function(driver, device, cmd) + return CapabilityHandlers.handle_set_thermostat_mode(driver, device, {component = cmd.component, args = {mode = mode_name}}) + end +end + + +-- [[ FAN MODE CAPABILITY HANDLERS ]] -- + +local function set_fan_mode(device, cmd, fan_mode_capability) + local command_argument = cmd.args.fanMode + if fan_mode_capability == capabilities.airPurifierFanMode then + command_argument = cmd.args.airPurifierFanMode + elseif fan_mode_capability == capabilities.thermostatFanMode then + command_argument = cmd.args.mode + end + local fan_mode_id + if command_argument == "off" then + fan_mode_id = clusters.FanControl.attributes.FanMode.OFF + elseif command_argument == "on" then + fan_mode_id = clusters.FanControl.attributes.FanMode.ON + elseif command_argument == "auto" then + fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO + elseif command_argument == "high" then + fan_mode_id = clusters.FanControl.attributes.FanMode.HIGH + elseif command_argument == "medium" then + fan_mode_id = clusters.FanControl.attributes.FanMode.MEDIUM + elseif thermostat_utils.tbl_contains({ "low", "sleep", "quiet", "windFree" }, command_argument) then + fan_mode_id = clusters.FanControl.attributes.FanMode.LOW + else + device.log.warn(string.format("Invalid Fan Mode (%s) received from capability command", command_argument)) + return + end + device:send(clusters.FanControl.attributes.FanMode:write(device, thermostat_utils.component_to_endpoint(device, cmd.component, clusters.FanControl.ID), fan_mode_id)) +end + +function CapabilityHandlers.fan_mode_command_factory(fan_mode_capability) + return function(driver, device, cmd) + set_fan_mode(device, cmd, fan_mode_capability) + end +end + + +-- [[ THERMOSTAT FAN MODE CAPABILITY HANDLERS ]] -- + +function CapabilityHandlers.thermostat_fan_mode_command_factory(mode_name) + return function(driver, device, cmd) + set_fan_mode(device, {component = cmd.component, args = {mode = mode_name}}, capabilities.thermostatFanMode) + end +end + + +-- [[ THERMOSTAT HEATING/COOLING CAPABILITY HANDLERS ]] -- + +function CapabilityHandlers.thermostat_set_setpoint_factory(setpoint) + return function(driver, device, cmd) + local endpoint_id = thermostat_utils.component_to_endpoint(device, cmd.component, clusters.Thermostat.ID) + local MAX_TEMP_IN_C = fields.THERMOSTAT_MAX_TEMP_IN_C + local MIN_TEMP_IN_C = fields.THERMOSTAT_MIN_TEMP_IN_C + local is_water_heater_device = thermostat_utils.get_device_type(device) == fields.WATER_HEATER_DEVICE_TYPE_ID + if is_water_heater_device then + MAX_TEMP_IN_C = fields.WATER_HEATER_MAX_TEMP_IN_C + MIN_TEMP_IN_C = fields.WATER_HEATER_MIN_TEMP_IN_C + end + local value = cmd.args.setpoint + if version.rpc <= 5 and value > MAX_TEMP_IN_C then + value = st_utils.f_to_c(value) + end + + -- Gather cached setpoint values when considering setpoint limits + -- Note: cached values should always exist, but defaults are chosen just in case to prevent + -- nil operation errors, and deadband logic from triggering. + local cached_cooling_val, cooling_setpoint = device:get_latest_state( + cmd.component, capabilities.thermostatCoolingSetpoint.ID, + capabilities.thermostatCoolingSetpoint.coolingSetpoint.NAME, + MAX_TEMP_IN_C, { value = MAX_TEMP_IN_C, unit = "C" } + ) + if cooling_setpoint and cooling_setpoint.unit == "F" then + cached_cooling_val = st_utils.f_to_c(cached_cooling_val) + end + local cached_heating_val, heating_setpoint = device:get_latest_state( + cmd.component, capabilities.thermostatHeatingSetpoint.ID, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME, + MIN_TEMP_IN_C, { value = MIN_TEMP_IN_C, unit = "C" } + ) + if heating_setpoint and heating_setpoint.unit == "F" then + cached_heating_val = st_utils.f_to_c(cached_heating_val) + end + local is_auto_capable = #device:get_endpoints( + clusters.Thermostat.ID, + {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.AUTOMODE} + ) > 0 + + --Check setpoint limits for the device + local setpoint_type = string.match(setpoint.NAME, "Heat") or "Cool" + local deadband = device:get_field(fields.setpoint_limit_device_field.MIN_DEADBAND) or 2.5 --spec default + if setpoint_type == "Heat" then + local min = device:get_field(fields.setpoint_limit_device_field.MIN_HEAT) or MIN_TEMP_IN_C + local max = device:get_field(fields.setpoint_limit_device_field.MAX_HEAT) or MAX_TEMP_IN_C + if value < min or value > max then + log.warn(string.format( + "Invalid setpoint (%s) outside the min (%s) and the max (%s)", + value, min, max + )) + device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpoint(heating_setpoint, {state_change = true})) + return + end + if is_auto_capable and value > (cached_cooling_val - deadband) then + log.warn(string.format( + "Invalid setpoint (%s) is greater than the cooling setpoint (%s) with the deadband (%s)", + value, cooling_setpoint, deadband + )) + device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpoint(heating_setpoint, {state_change = true})) + return + end + else + local min = device:get_field(fields.setpoint_limit_device_field.MIN_COOL) or MIN_TEMP_IN_C + local max = device:get_field(fields.setpoint_limit_device_field.MAX_COOL) or MAX_TEMP_IN_C + if value < min or value > max then + log.warn(string.format( + "Invalid setpoint (%s) outside the min (%s) and the max (%s)", + value, min, max + )) + device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpoint(cooling_setpoint, {state_change = true})) + return + end + if is_auto_capable and value < (cached_heating_val + deadband) then + log.warn(string.format( + "Invalid setpoint (%s) is less than the heating setpoint (%s) with the deadband (%s)", + value, heating_setpoint, deadband + )) + device:emit_event_for_endpoint(endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpoint(cooling_setpoint, {state_change = true})) + return + end + end + device:send(setpoint:write(device, thermostat_utils.component_to_endpoint(device, cmd.component, clusters.Thermostat.ID), st_utils.round(value * 100.0))) + end +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua new file mode 100644 index 0000000000..3ae5c76c7e --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/device_configuration.lua @@ -0,0 +1,331 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "thermostat_utils.embedded_cluster_utils" +local version = require "version" +local fields = require "thermostat_utils.fields" +local thermostat_utils = require "thermostat_utils.utils" + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" +end + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" +end + +if version.api < 13 then + clusters.WaterHeaterMode = require "embedded_clusters.WaterHeaterMode" +end + +local DeviceConfigurationHelpers = {} + +function DeviceConfigurationHelpers.supported_level_measurements(device) + local measurement_caps, level_caps = {}, {} + for _, details in ipairs(fields.AIR_QUALITY_MAP) do + local cap_id = details[1] + local cluster = details[3] + -- capability describes either a HealthConcern or Measurement/Sensor + if (cap_id:match("HealthConcern$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.LEVEL_INDICATION }) + if #attr_eps > 0 then + table.insert(level_caps, cap_id) + end + elseif (cap_id:match("Measurement$") or cap_id:match("Sensor$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.NUMERIC_MEASUREMENT }) + if #attr_eps > 0 then + table.insert(measurement_caps, cap_id) + end + end + end + return measurement_caps, level_caps +end + +function DeviceConfigurationHelpers.get_thermostat_optional_capabilities(device) + local heat_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) + local cool_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) + local running_state_supported = device:get_field(fields.profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT) + + local supported_thermostat_capabilities = {} + + if #heat_eps > 0 then + table.insert(supported_thermostat_capabilities, capabilities.thermostatHeatingSetpoint.ID) + end + if #cool_eps > 0 then + table.insert(supported_thermostat_capabilities, capabilities.thermostatCoolingSetpoint.ID) + end + + if running_state_supported then + table.insert(supported_thermostat_capabilities, capabilities.thermostatOperatingState.ID) + end + + return supported_thermostat_capabilities +end + +function DeviceConfigurationHelpers.get_air_quality_optional_capabilities(device) + local supported_air_quality_capabilities = {} + + local measurement_caps, level_caps = DeviceConfigurationHelpers.supported_level_measurements(device) + + for _, cap_id in ipairs(measurement_caps) do + table.insert(supported_air_quality_capabilities, cap_id) + end + + for _, cap_id in ipairs(level_caps) do + table.insert(supported_air_quality_capabilities, cap_id) + end + + return supported_air_quality_capabilities +end + + +local DeviceConfiguration = {} + +function DeviceConfiguration.match_modular_profile_air_purifer(device) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local hepa_filter_component_capabilities = {} + local ac_filter_component_capabilties = {} + local profile_name = "air-purifier-modular" + + local MAIN_COMPONENT_IDX = 1 + local CAPABILITIES_LIST_IDX = 2 + + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID) + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + end + if #temp_eps > 0 then + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + end + + local hepa_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.HepaFilterMonitoring.ID) + local ac_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.ActivatedCarbonFilterMonitoring.ID) + + if #hepa_filter_eps > 0 then + local filter_state_eps = embedded_cluster_utils.get_endpoints(device, clusters.HepaFilterMonitoring.ID, {feature_bitmap = clusters.HepaFilterMonitoring.types.Feature.CONDITION}) + if #filter_state_eps > 0 then + table.insert(hepa_filter_component_capabilities, capabilities.filterState.ID) + end + + table.insert(hepa_filter_component_capabilities, capabilities.filterStatus.ID) + end + if #ac_filter_eps > 0 then + local filter_state_eps = embedded_cluster_utils.get_endpoints(device, clusters.ActivatedCarbonFilterMonitoring.ID, {feature_bitmap = clusters.ActivatedCarbonFilterMonitoring.types.Feature.CONDITION}) + if #filter_state_eps > 0 then + table.insert(ac_filter_component_capabilties, capabilities.filterState.ID) + end + + table.insert(ac_filter_component_capabilties, capabilities.filterStatus.ID) + end + + -- determine fan capabilities, note that airPurifierFanMode is already mandatory + local rock_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.ROCKING}) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + + if #rock_eps > 0 then + table.insert(main_component_capabilities, capabilities.fanOscillationMode.ID) + end + if #wind_eps > 0 then + table.insert(main_component_capabilities, capabilities.windMode.ID) + end + + local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) + + if #thermostat_eps > 0 then + -- thermostatMode and temperatureMeasurement should be expected if thermostat is present + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + + -- only add temperatureMeasurement if it is not already added via TemperatureMeasurement cluster support + if #temp_eps == 0 then + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + end + local thermostat_capabilities = DeviceConfigurationHelpers.get_thermostat_optional_capabilities(device) + for _, capability_id in pairs(thermostat_capabilities) do + table.insert(main_component_capabilities, capability_id) + end + end + + local aqs_eps = embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID) + if #aqs_eps > 0 then + table.insert(main_component_capabilities, capabilities.airQualityHealthConcern.ID) + end + + local supported_air_quality_capabilities = DeviceConfigurationHelpers.get_air_quality_optional_capabilities(device) + for _, capability_id in pairs(supported_air_quality_capabilities) do + table.insert(main_component_capabilities, capability_id) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + if #ac_filter_component_capabilties > 0 then + table.insert(optional_supported_component_capabilities, {"activatedCarbonFilter", ac_filter_component_capabilties}) + end + if #hepa_filter_component_capabilities > 0 then + table.insert(optional_supported_component_capabilities, {"hepaFilter", hepa_filter_component_capabilities}) + end + + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. + -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. + if version.api < 15 or version.rpc < 9 then + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.airPurifierFanMode.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.fanSpeedPercent.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) + + device:set_field(fields.SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + end +end + +function DeviceConfiguration.match_modular_profile_thermostat(device) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local profile_name = "thermostat-modular" + + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + end + + -- determine fan capabilities + local fan_eps = device:get_endpoints(clusters.FanControl.ID) + local rock_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.ROCKING}) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + + if #fan_eps > 0 then + if #device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.MULTI_SPEED}) > 0 then + table.insert(main_component_capabilities, capabilities.fanSpeedPercent.ID) + if #device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.AUTO}) > 0 then + table.insert(main_component_capabilities, capabilities.fanMode.ID) + end + else + table.insert(main_component_capabilities, capabilities.fanMode.ID) + end + end + if #rock_eps > 0 then + table.insert(main_component_capabilities, capabilities.fanOscillationMode.ID) + end + if #wind_eps > 0 then + table.insert(main_component_capabilities, capabilities.windMode.ID) + end + + local thermostat_capabilities = DeviceConfigurationHelpers.get_thermostat_optional_capabilities(device) + for _, capability_id in pairs(thermostat_capabilities) do + table.insert(main_component_capabilities, capability_id) + end + + local battery_supported = device:get_field(fields.profiling_data.BATTERY_SUPPORT) + if battery_supported == fields.battery_support.BATTERY_LEVEL then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + elseif battery_supported == fields.battery_support.BATTERY_PERCENTAGE then + table.insert(main_component_capabilities, capabilities.battery.ID) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. + -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. + if version.api < 15 or version.rpc < 9 then + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + table.insert(main_component_capabilities, capabilities.refresh.ID) + table.insert(main_component_capabilities, capabilities.firmwareUpdate.ID) + + device:set_field(fields.SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + end +end + +function DeviceConfiguration.match_modular_profile_room_ac(device) + local running_state_supported = device:get_field(fields.profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT) + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local profile_name = "room-air-conditioner-modular" + + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + end + + -- determine fan capabilities + local fan_eps = device:get_endpoints(clusters.FanControl.ID) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + -- Note: Room AC does not support the rocking feature of FanControl. + + if #fan_eps > 0 then + table.insert(main_component_capabilities, capabilities.airConditionerFanMode.ID) + table.insert(main_component_capabilities, capabilities.fanSpeedPercent.ID) + end + if #wind_eps > 0 then + table.insert(main_component_capabilities, capabilities.windMode.ID) + end + + local heat_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) + local cool_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) + + if #heat_eps > 0 then + table.insert(main_component_capabilities, capabilities.thermostatHeatingSetpoint.ID) + end + if #cool_eps > 0 then + table.insert(main_component_capabilities, capabilities.thermostatCoolingSetpoint.ID) + end + + if running_state_supported then + table.insert(main_component_capabilities, capabilities.thermostatOperatingState.ID) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. + -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. + if version.api < 15 or version.rpc < 9 then + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(main_component_capabilities, capabilities.switch.ID) + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + table.insert(main_component_capabilities, capabilities.refresh.ID) + table.insert(main_component_capabilities, capabilities.firmwareUpdate.ID) + + device:set_field(fields.SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + end +end + +local match_modular_device_type = { + [fields.AP_DEVICE_TYPE_ID] = DeviceConfiguration.match_modular_profile_air_purifer, + [fields.RAC_DEVICE_TYPE_ID] = DeviceConfiguration.match_modular_profile_room_ac, + [fields.THERMOSTAT_DEVICE_TYPE_ID] = DeviceConfiguration.match_modular_profile_thermostat, +} + +local function profiling_data_still_required(device) + for _, field in pairs(fields.profiling_data) do + if device:get_field(field) == nil then + return true -- data still required if a field is nil + end + end + return false +end + +function DeviceConfiguration.match_profile(device) + if profiling_data_still_required(device) then return end + local primary_device_type = thermostat_utils.get_device_type(device) + if version.api >= 14 and version.rpc >= 8 and match_modular_device_type[primary_device_type] then + match_modular_device_type[primary_device_type](device) + return + else + local legacy_device_cfg = require "thermostat_utils.legacy_device_configuration" + legacy_device_cfg.match_profile(device) + end +end + +return DeviceConfiguration diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/embedded_cluster_utils.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/embedded_cluster_utils.lua new file mode 100644 index 0000000000..3368fe2fa4 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/embedded_cluster_utils.lua @@ -0,0 +1,94 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local version = require "version" +local clusters = require "st.matter.clusters" +local utils = require "st.utils" + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" + clusters.AirQuality = require "embedded_clusters.AirQuality" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" +end + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" +end + +if version.api < 13 then + clusters.WaterHeaterMode = require "embedded_clusters.WaterHeaterMode" +end + +local embedded_cluster_utils = {} + +local embedded_clusters_api_10 = { + [clusters.HepaFilterMonitoring.ID] = clusters.HepaFilterMonitoring, + [clusters.ActivatedCarbonFilterMonitoring.ID] = clusters.ActivatedCarbonFilterMonitoring, + [clusters.AirQuality.ID] = clusters.AirQuality, + [clusters.CarbonMonoxideConcentrationMeasurement.ID] = clusters.CarbonMonoxideConcentrationMeasurement, + [clusters.CarbonDioxideConcentrationMeasurement.ID] = clusters.CarbonDioxideConcentrationMeasurement, + [clusters.FormaldehydeConcentrationMeasurement.ID] = clusters.FormaldehydeConcentrationMeasurement, + [clusters.NitrogenDioxideConcentrationMeasurement.ID] = clusters.NitrogenDioxideConcentrationMeasurement, + [clusters.OzoneConcentrationMeasurement.ID] = clusters.OzoneConcentrationMeasurement, + [clusters.Pm1ConcentrationMeasurement.ID] = clusters.Pm1ConcentrationMeasurement, + [clusters.Pm10ConcentrationMeasurement.ID] = clusters.Pm10ConcentrationMeasurement, + [clusters.Pm25ConcentrationMeasurement.ID] = clusters.Pm25ConcentrationMeasurement, + [clusters.RadonConcentrationMeasurement.ID] = clusters.RadonConcentrationMeasurement, + [clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID] = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement, +} + +local embedded_clusters_api_11 = { + [clusters.ElectricalEnergyMeasurement.ID] = clusters.ElectricalEnergyMeasurement, + [clusters.ElectricalPowerMeasurement.ID] = clusters.ElectricalPowerMeasurement, +} + +local embedded_clusters_api_13 = { + [clusters.WaterHeaterMode.ID] = clusters.WaterHeaterMode +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 10 and embedded_clusters_api_10[cluster_id] ~= nil or + version.api < 11 and embedded_clusters_api_11[cluster_id] ~= nil or + version.api < 13 and embedded_clusters_api_13[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters_api_10[cluster_id] or embedded_clusters_api_11[cluster_id] or embedded_clusters_api_13[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end +end + +return embedded_cluster_utils \ No newline at end of file diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua new file mode 100644 index 0000000000..ddedab385d --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua @@ -0,0 +1,254 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local log = require "log" +local version = require "version" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local st_utils = require "st.utils" + +if version.api < 10 then + clusters.CarbonDioxideConcentrationMeasurement = require "embedded_clusters.CarbonDioxideConcentrationMeasurement" + clusters.CarbonMonoxideConcentrationMeasurement = require "embedded_clusters.CarbonMonoxideConcentrationMeasurement" + clusters.Pm10ConcentrationMeasurement = require "embedded_clusters.Pm10ConcentrationMeasurement" + clusters.Pm25ConcentrationMeasurement = require "embedded_clusters.Pm25ConcentrationMeasurement" + clusters.FormaldehydeConcentrationMeasurement = require "embedded_clusters.FormaldehydeConcentrationMeasurement" + clusters.NitrogenDioxideConcentrationMeasurement = require "embedded_clusters.NitrogenDioxideConcentrationMeasurement" + clusters.OzoneConcentrationMeasurement = require "embedded_clusters.OzoneConcentrationMeasurement" + clusters.RadonConcentrationMeasurement = require "embedded_clusters.RadonConcentrationMeasurement" + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "embedded_clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement" + clusters.Pm1ConcentrationMeasurement = require "embedded_clusters.Pm1ConcentrationMeasurement" + clusters.Thermostat.types.ThermostatSystemMode.DRY = 0x8 -- ThermostatSystemMode added in Matter 1.2 + clusters.Thermostat.types.ThermostatSystemMode.SLEEP = 0x9 -- ThermostatSystemMode added in Matter 1.2 +end + +local ThermostatFields = {} + +ThermostatFields.SUPPORTED_COMPONENT_CAPABILITIES = "__supported_component_capabilities" + +ThermostatFields.SAVED_SYSTEM_MODE_IB = "__saved_system_mode_ib" +ThermostatFields.DISALLOWED_THERMOSTAT_MODES = "__DISALLOWED_CONTROL_OPERATIONS" +ThermostatFields.OPTIONAL_THERMOSTAT_MODES_SEEN = "__OPTIONAL_THERMOSTAT_MODES_SEEN" + +ThermostatFields.RAC_DEVICE_TYPE_ID = 0x0072 +ThermostatFields.AP_DEVICE_TYPE_ID = 0x002D +ThermostatFields.FAN_DEVICE_TYPE_ID = 0x002B +ThermostatFields.WATER_HEATER_DEVICE_TYPE_ID = 0x050F +ThermostatFields.HEAT_PUMP_DEVICE_TYPE_ID = 0x0309 +ThermostatFields.THERMOSTAT_DEVICE_TYPE_ID = 0x0301 +ThermostatFields.ELECTRICAL_SENSOR_DEVICE_TYPE_ID = 0x0510 + +ThermostatFields.MIN_ALLOWED_PERCENT_VALUE = 0 +ThermostatFields.MAX_ALLOWED_PERCENT_VALUE = 100 +ThermostatFields.CUMULATIVE_REPORTS_NOT_SUPPORTED = "__cumulative_reports_not_supported" +ThermostatFields.LAST_IMPORTED_REPORT_TIMESTAMP = "__last_imported_report_timestamp" +ThermostatFields.MINIMUM_ST_ENERGY_REPORT_INTERVAL = (15 * 60) -- 15 minutes, reported in seconds +ThermostatFields.TOTAL_CUMULATIVE_ENERGY_IMPORTED_MAP = "__total_cumulative_energy_imported_map" +ThermostatFields.SUPPORTED_WATER_HEATER_MODES_WITH_IDX = "__supported_water_heater_modes_with_idx" +ThermostatFields.COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" +ThermostatFields.MGM3_PPM_CONVERSION_FACTOR = 24.45 + +-- For RPC version < 6: +-- issue context: driver cannot know a setpoint capability's unit (whether Celsius or Farenheit) +-- when a command is received, as the received arguments do not contain the unit. +-- workaround: map the following temperature ranges to either Celsius or Farenheit: +-- For Thermostats: +-- 1. if the received setpoint command value is in the range 5 ~ 40, it is inferred as *C +-- 2. if the received setpoint command value is in the range 41 ~ 104, it is inferred as *F +-- For Water Heaters: +-- 1. if the received setpoint command value is in the range 30 ~ 80, it is inferred as *C +-- 2. if the received setpoint command value is in the range 86 ~ 176, it is inferred as *F +-- For RPC version >= 6: +-- temperatureSetpoint always reports in Celsius, removing the need for the above workaround. +-- In this case, we use these fields simply to limit the setpoint's range to "reasonable" values on the platform. +ThermostatFields.THERMOSTAT_MAX_TEMP_IN_C = version.rpc >= 6 and 100.0 or 40.0 +ThermostatFields.THERMOSTAT_MIN_TEMP_IN_C = version.rpc >= 6 and 0.0 or 5.0 +ThermostatFields.WATER_HEATER_MAX_TEMP_IN_C = version.rpc >= 6 and 100.0 or 80.0 +ThermostatFields.WATER_HEATER_MIN_TEMP_IN_C = version.rpc >= 6 and 0.0 or 30.0 + +ThermostatFields.setpoint_limit_device_field = { + MIN_SETPOINT_DEADBAND_CHECKED = "MIN_SETPOINT_DEADBAND_CHECKED", + MIN_HEAT = "MIN_HEAT", + MAX_HEAT = "MAX_HEAT", + MIN_COOL = "MIN_COOL", + MAX_COOL = "MAX_COOL", + MIN_DEADBAND = "MIN_DEADBAND", + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + +ThermostatFields.battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" +} + +ThermostatFields.profiling_data = { + BATTERY_SUPPORT = "__BATTERY_SUPPORT", + THERMOSTAT_RUNNING_STATE_SUPPORT = "__THERMOSTAT_RUNNING_STATE_SUPPORT" +} + +ThermostatFields.THERMOSTAT_MODE_MAP = { + [clusters.Thermostat.types.ThermostatSystemMode.OFF] = capabilities.thermostatMode.thermostatMode.off, + [clusters.Thermostat.types.ThermostatSystemMode.AUTO] = capabilities.thermostatMode.thermostatMode.auto, + [clusters.Thermostat.types.ThermostatSystemMode.COOL] = capabilities.thermostatMode.thermostatMode.cool, + [clusters.Thermostat.types.ThermostatSystemMode.HEAT] = capabilities.thermostatMode.thermostatMode.heat, + [clusters.Thermostat.types.ThermostatSystemMode.EMERGENCY_HEATING] = capabilities.thermostatMode.thermostatMode.emergency_heat, + [clusters.Thermostat.types.ThermostatSystemMode.PRECOOLING] = capabilities.thermostatMode.thermostatMode.precooling, + [clusters.Thermostat.types.ThermostatSystemMode.FAN_ONLY] = capabilities.thermostatMode.thermostatMode.fanonly, + [clusters.Thermostat.types.ThermostatSystemMode.DRY] = capabilities.thermostatMode.thermostatMode.dryair, + [clusters.Thermostat.types.ThermostatSystemMode.SLEEP] = capabilities.thermostatMode.thermostatMode.asleep, +} + +ThermostatFields.THERMOSTAT_OPERATING_MODE_MAP = { + [0] = capabilities.thermostatOperatingState.thermostatOperatingState.heating, + [1] = capabilities.thermostatOperatingState.thermostatOperatingState.cooling, + [2] = capabilities.thermostatOperatingState.thermostatOperatingState.fan_only, + [3] = capabilities.thermostatOperatingState.thermostatOperatingState.heating, + [4] = capabilities.thermostatOperatingState.thermostatOperatingState.cooling, + [5] = capabilities.thermostatOperatingState.thermostatOperatingState.fan_only, + [6] = capabilities.thermostatOperatingState.thermostatOperatingState.fan_only, +} + +ThermostatFields.WIND_MODE_MAP = { + [0] = capabilities.windMode.windMode.sleepWind, + [1] = capabilities.windMode.windMode.naturalWind +} + +ThermostatFields.ROCK_MODE_MAP = { + [0] = capabilities.fanOscillationMode.fanOscillationMode.horizontal, + [1] = capabilities.fanOscillationMode.fanOscillationMode.vertical, + [2] = capabilities.fanOscillationMode.fanOscillationMode.swing +} + +ThermostatFields.AIR_QUALITY_MAP = { + {capabilities.carbonDioxideMeasurement.ID, "-co2", clusters.CarbonDioxideConcentrationMeasurement}, + {capabilities.carbonDioxideHealthConcern.ID, "-co2", clusters.CarbonDioxideConcentrationMeasurement}, + {capabilities.carbonMonoxideMeasurement.ID, "-co", clusters.CarbonMonoxideConcentrationMeasurement}, + {capabilities.carbonMonoxideHealthConcern.ID, "-co", clusters.CarbonMonoxideConcentrationMeasurement}, + {capabilities.dustSensor.ID, "-pm10", clusters.Pm10ConcentrationMeasurement}, + {capabilities.dustHealthConcern.ID, "-pm10", clusters.Pm10ConcentrationMeasurement}, + {capabilities.fineDustSensor.ID, "-pm25", clusters.Pm25ConcentrationMeasurement}, + {capabilities.fineDustHealthConcern.ID, "-pm25", clusters.Pm25ConcentrationMeasurement}, + {capabilities.formaldehydeMeasurement.ID, "-ch2o", clusters.FormaldehydeConcentrationMeasurement}, + {capabilities.formaldehydeHealthConcern.ID, "-ch2o", clusters.FormaldehydeConcentrationMeasurement}, + {capabilities.nitrogenDioxideHealthConcern.ID, "-no2", clusters.NitrogenDioxideConcentrationMeasurement}, + {capabilities.nitrogenDioxideMeasurement.ID, "-no2", clusters.NitrogenDioxideConcentrationMeasurement}, + {capabilities.ozoneHealthConcern.ID, "-ozone", clusters.OzoneConcentrationMeasurement}, + {capabilities.ozoneMeasurement.ID, "-ozone", clusters.OzoneConcentrationMeasurement}, + {capabilities.radonHealthConcern.ID, "-radon", clusters.RadonConcentrationMeasurement}, + {capabilities.radonMeasurement.ID, "-radon", clusters.RadonConcentrationMeasurement}, + {capabilities.tvocHealthConcern.ID, "-tvoc", clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement}, + {capabilities.tvocMeasurement.ID, "-tvoc", clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement}, + {capabilities.veryFineDustHealthConcern.ID, "-pm1", clusters.Pm1ConcentrationMeasurement}, + {capabilities.veryFineDustSensor.ID, "-pm1", clusters.Pm1ConcentrationMeasurement}, +} + +ThermostatFields.units = { + PPM = 0, + PPB = 1, + PPT = 2, + MGM3 = 3, + UGM3 = 4, + NGM3 = 5, + PM3 = 6, + BQM3 = 7, + PCIL = 0xFF -- not in matter spec +} + +local units = ThermostatFields.units -- copy units to avoid references below + +ThermostatFields.unit_strings = { + [units.PPM] = "ppm", + [units.PPB] = "ppb", + [units.PPT] = "ppt", + [units.MGM3] = "mg/m^3", + [units.NGM3] = "ng/m^3", + [units.UGM3] = "μg/m^3", + [units.BQM3] = "Bq/m^3", + [units.PCIL] = "pCi/L" +} + +ThermostatFields.unit_default = { + [capabilities.carbonMonoxideMeasurement.NAME] = units.PPM, + [capabilities.carbonDioxideMeasurement.NAME] = units.PPM, + [capabilities.nitrogenDioxideMeasurement.NAME] = units.PPM, + [capabilities.ozoneMeasurement.NAME] = units.PPM, + [capabilities.formaldehydeMeasurement.NAME] = units.PPM, + [capabilities.veryFineDustSensor.NAME] = units.UGM3, + [capabilities.fineDustSensor.NAME] = units.UGM3, + [capabilities.dustSensor.NAME] = units.UGM3, + [capabilities.radonMeasurement.NAME] = units.BQM3, + [capabilities.tvocMeasurement.NAME] = units.PPB -- TVOC is typically within the range of 0-5500 ppb, with good to moderate values being < 660 ppb +} + +-- All ConcentrationMesurement clusters inherit from the same base cluster definitions, +-- so CarbonMonoxideConcentratinMeasurement is used below but the same enum types exist +-- in all ConcentrationMeasurement clusters +ThermostatFields.level_strings = { + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.UNKNOWN] = "unknown", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.LOW] = "good", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.MEDIUM] = "moderate", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.HIGH] = "unhealthy", + [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.CRITICAL] = "hazardous", +} + +-- measured in g/mol +ThermostatFields.molecular_weights = { + [capabilities.carbonDioxideMeasurement.NAME] = 44.010, + [capabilities.nitrogenDioxideMeasurement.NAME] = 28.014, + [capabilities.ozoneMeasurement.NAME] = 48.0, + [capabilities.formaldehydeMeasurement.NAME] = 30.031, + [capabilities.veryFineDustSensor.NAME] = "N/A", + [capabilities.fineDustSensor.NAME] = "N/A", + [capabilities.dustSensor.NAME] = "N/A", + [capabilities.radonMeasurement.NAME] = 222.018, + [capabilities.tvocMeasurement.NAME] = "N/A", +} + +local function is_valid_molecular_weight(molecular_weight) + if type(molecular_weight) ~= "number" or molecular_weight <= 0 then + log.warn_with({hub_logs = true}, string.format("unit conversion molecular weight (%s) is not a valid number", molecular_weight)) + return false + end + return true +end + +ThermostatFields.unit_conversion = { + [units.PPM] = { + [units.PPM] = function(value) return st_utils.round(value) end, + [units.PPB] = function(value) return st_utils.round(value * (10^3)) end, + [units.UGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight * 10^3) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) end + end, + [units.MGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) end + end,}, + [units.PPB] = { + [units.PPM] = function(value) return st_utils.round(value/(10^3)) end, + [units.PPB] = function(value) return st_utils.round(value) end, + }, + [units.PPT] = { + [units.PPM] = function(value) return st_utils.round(value/(10^6)) end + }, + [units.MGM3] = { + [units.UGM3] = function(value) return st_utils.round(value * (10^3)) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / molecular_weight) end + end, + }, + [units.UGM3] = { + [units.UGM3] = function(value) return st_utils.round(value) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / (molecular_weight * 10^3)) end + end, + }, + [units.NGM3] = { + [units.UGM3] = function(value) return st_utils.round(value/(10^3)) end + }, + [units.BQM3] = { + [units.PCIL] = function(value) return st_utils.round(value/37) end + }, +} + +return ThermostatFields \ No newline at end of file diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/legacy_device_configuration.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/legacy_device_configuration.lua new file mode 100644 index 0000000000..6cdda2a53a --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/legacy_device_configuration.lua @@ -0,0 +1,259 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local version = require "version" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "thermostat_utils.embedded_cluster_utils" +local fields = require "thermostat_utils.fields" +local thermostat_utils = require "thermostat_utils.utils" + +if version.api < 10 then + clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" + clusters.ActivatedCarbonFilterMonitoring = require "embedded_clusters.ActivatedCarbonFilterMonitoring" + clusters.AirQuality = require "embedded_clusters.AirQuality" +end + +local LegacyConfigurationHelpers = {} + +function LegacyConfigurationHelpers.create_level_measurement_profile(device) + local meas_name, level_name = "", "" + for _, details in ipairs(fields.AIR_QUALITY_MAP) do + local cap_id = details[1] + local cluster = details[3] + -- capability describes either a HealthConcern or Measurement/Sensor + if (cap_id:match("HealthConcern$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.LEVEL_INDICATION }) + if #attr_eps > 0 then + level_name = level_name .. details[2] + end + elseif (cap_id:match("Measurement$") or cap_id:match("Sensor$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.NUMERIC_MEASUREMENT }) + if #attr_eps > 0 then + meas_name = meas_name .. details[2] + end + end + end + return meas_name, level_name +end + +function LegacyConfigurationHelpers.create_air_quality_sensor_profile(device) + local aqs_eps = embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID) + local profile_name = "" + if #aqs_eps > 0 then + profile_name = profile_name .. "-aqs" + end + local meas_name, level_name = LegacyConfigurationHelpers.create_level_measurement_profile(device) + if meas_name ~= "" then + profile_name = profile_name .. meas_name .. "-meas" + end + if level_name ~= "" then + profile_name = profile_name .. level_name .. "-level" + end + return profile_name +end + +function LegacyConfigurationHelpers.create_fan_profile(device) + local fan_eps = device:get_endpoints(clusters.FanControl.ID) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + local rock_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.ROCKING}) + local profile_name = "" + if #fan_eps > 0 then + profile_name = profile_name .. "-fan" + end + if #rock_eps > 0 then + profile_name = profile_name .. "-rock" + end + if #wind_eps > 0 then + profile_name = profile_name .. "-wind" + end + return profile_name +end + +function LegacyConfigurationHelpers.create_air_purifier_profile(device) + local hepa_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.HepaFilterMonitoring.ID) + local ac_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.ActivatedCarbonFilterMonitoring.ID) + local fan_eps_seen = false + local profile_name = "air-purifier" + if #hepa_filter_eps > 0 then + profile_name = profile_name .. "-hepa" + end + if #ac_filter_eps > 0 then + profile_name = profile_name .. "-ac" + end + + -- air purifier profiles include -fan later in the name for historical reasons. + -- save this information for use at that point. + local fan_profile = LegacyConfigurationHelpers.create_fan_profile(device) + if fan_profile ~= "" then + fan_eps_seen = true + end + fan_profile = string.gsub(fan_profile, "-fan", "") + profile_name = profile_name .. fan_profile + + return profile_name, fan_eps_seen +end + +function LegacyConfigurationHelpers.create_thermostat_modes_profile(device) + local heat_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) + local cool_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) + + local thermostat_modes = "" + if #heat_eps == 0 and #cool_eps == 0 then + return "No Heating nor Cooling Support" + elseif #heat_eps > 0 and #cool_eps == 0 then + thermostat_modes = thermostat_modes .. "-heating-only" + elseif #cool_eps > 0 and #heat_eps == 0 then + thermostat_modes = thermostat_modes .. "-cooling-only" + end + return thermostat_modes +end + + +local LegacyDeviceConfiguration = {} + +function LegacyDeviceConfiguration.match_profile(device) + local running_state_supported = device:get_field(fields.profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT) + local battery_supported = device:get_field(fields.profiling_data.BATTERY_SUPPORT) + + local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + local device_type = thermostat_utils.get_device_type(device) + local profile_name + if device_type == fields.RAC_DEVICE_TYPE_ID then + profile_name = "room-air-conditioner" + + if #humidity_eps > 0 then + profile_name = profile_name .. "-humidity" + end + + -- Room AC does not support the rocking feature of FanControl. + local fan_name = LegacyConfigurationHelpers.create_fan_profile(device) + fan_name = string.gsub(fan_name, "-rock", "") + profile_name = profile_name .. fan_name + + local thermostat_modes = LegacyConfigurationHelpers.create_thermostat_modes_profile(device) + if thermostat_modes == "" then + profile_name = profile_name .. "-heating-cooling" + else + device.log.warn_with({hub_logs=true}, "Device does not support both heating and cooling. No matching profile") + return + end + + if profile_name == "room-air-conditioner-humidity-fan-wind-heating-cooling" then + profile_name = "room-air-conditioner" + end + + if not running_state_supported and profile_name == "room-air-conditioner-fan-heating-cooling" then + profile_name = profile_name .. "-nostate" + end + + elseif device_type == fields.FAN_DEVICE_TYPE_ID then + profile_name = LegacyConfigurationHelpers.create_fan_profile(device) + -- remove leading "-" + profile_name = string.sub(profile_name, 2) + if profile_name == "fan" then + profile_name = "fan-generic" + end + + elseif device_type == fields.AP_DEVICE_TYPE_ID then + local fan_eps_found + profile_name, fan_eps_found = LegacyConfigurationHelpers.create_air_purifier_profile(device) + if #thermostat_eps > 0 then + profile_name = profile_name .. "-thermostat" + + if #humidity_eps > 0 then + profile_name = profile_name .. "-humidity" + end + + if fan_eps_found then + profile_name = profile_name .. "-fan" + end + + local thermostat_modes = LegacyConfigurationHelpers.create_thermostat_modes_profile(device) + if thermostat_modes ~= "No Heating nor Cooling Support" then + profile_name = profile_name .. thermostat_modes + end + + if not running_state_supported then + profile_name = profile_name .. "-nostate" + end + + if battery_supported == fields.battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + elseif battery_supported == fields.battery_support.NO_BATTERY then + profile_name = profile_name .. "-nobattery" + end + elseif #device:get_endpoints(clusters.TemperatureMeasurement.ID) > 0 then + profile_name = profile_name .. "-temperature" + + if #humidity_eps > 0 then + profile_name = profile_name .. "-humidity" + end + + if fan_eps_found then + profile_name = profile_name .. "-fan" + end + end + profile_name = profile_name .. LegacyConfigurationHelpers.create_air_quality_sensor_profile(device) + elseif device_type == fields.WATER_HEATER_DEVICE_TYPE_ID then + -- If a Water Heater is composed of Electrical Sensor device type, it must support both ElectricalEnergyMeasurement and + -- ElectricalPowerMeasurement clusters. + local electrical_sensor_eps = thermostat_utils.get_endpoints_by_device_type(device, fields.ELECTRICAL_SENSOR_DEVICE_TYPE_ID) or {} + if #electrical_sensor_eps > 0 then + profile_name = "water-heater-power-energy-powerConsumption" + end + elseif device_type == fields.HEAT_PUMP_DEVICE_TYPE_ID then + profile_name = "heat-pump" + local MAX_HEAT_PUMP_THERMOSTAT_COMPONENTS = 2 + for i = 1, math.min(MAX_HEAT_PUMP_THERMOSTAT_COMPONENTS, #thermostat_eps) do + profile_name = profile_name .. "-thermostat" + if thermostat_utils.tbl_contains(humidity_eps, thermostat_eps[i]) then + profile_name = profile_name .. "-humidity" + end + end + elseif #thermostat_eps > 0 then + profile_name = "thermostat" + + if #humidity_eps > 0 then + profile_name = profile_name .. "-humidity" + end + + -- thermostat profiles support neither wind nor rocking FanControl attributes + local fan_name = LegacyConfigurationHelpers.create_fan_profile(device) + if fan_name ~= "" then + profile_name = profile_name .. "-fan" + end + + local thermostat_modes = LegacyConfigurationHelpers.create_thermostat_modes_profile(device) + if thermostat_modes == "No Heating nor Cooling Support" then + device.log.warn_with({hub_logs=true}, "Device does not support either heating or cooling. No matching profile") + return + else + profile_name = profile_name .. thermostat_modes + end + + if not running_state_supported then + profile_name = profile_name .. "-nostate" + end + + if battery_supported == fields.battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + elseif battery_supported == fields.battery_support.NO_BATTERY then + profile_name = profile_name .. "-nobattery" + end + else + device.log.warn_with({hub_logs=true}, "Device type is not supported in thermostat driver") + return + end + + if profile_name then + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + end + -- clear all profiling data fields after profiling is complete. + for _, field in pairs(fields.profiling_data) do + device:set_field(field, nil) + end +end + +return LegacyDeviceConfiguration diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua new file mode 100644 index 0000000000..940fe9835e --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua @@ -0,0 +1,244 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local log = require "log" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local fields = require "thermostat_utils.fields" +local embedded_cluster_utils = require "thermostat_utils.embedded_cluster_utils" + +local ThermostatUtils = {} + +function ThermostatUtils.tbl_contains(array, value) + if value == nil then return false end + for _, element in pairs(array or {}) do + if element == value then + return true + end + end + return false +end + +function ThermostatUtils.get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +function ThermostatUtils.set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +function ThermostatUtils.find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = embedded_cluster_utils.get_endpoints(device, cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return res +end + +function ThermostatUtils.component_to_endpoint(device, component_name, cluster_id) + -- Use the find_default_endpoint function to return the first endpoint that + -- supports a given cluster. + local component_to_endpoint_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) + if component_to_endpoint_map ~= nil and component_to_endpoint_map[component_name] ~= nil then + return component_to_endpoint_map[component_name] + end + if not cluster_id then return device.MATTER_DEFAULT_ENDPOINT end + return ThermostatUtils.find_default_endpoint(device, cluster_id) +end + +function ThermostatUtils.endpoint_to_component(device, endpoint_id) + local component_to_endpoint_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) + if component_to_endpoint_map ~= nil then + for comp, ep in pairs(component_to_endpoint_map) do + if ep == endpoint_id then + return comp + end + end + end + return "main" +end + +function ThermostatUtils.get_total_cumulative_energy_imported(device) + local total_cumulative_energy_imported = device:get_field(fields.TOTAL_CUMULATIVE_ENERGY_IMPORTED_MAP) or {} + local total_energy = 0 + for _, energyWh in pairs(total_cumulative_energy_imported) do + total_energy = total_energy + energyWh + end + return total_energy +end + +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function ThermostatUtils.deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not ThermostatUtils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return ThermostatUtils.deep_equals(mt_a, mt_b, opts, seen) +end + +function ThermostatUtils.get_endpoints_by_device_type(device, device_type) + local endpoints = {} + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == device_type then + table.insert(endpoints, ep.endpoint_id) + break + end + end + end + table.sort(endpoints) + return endpoints +end + + -- set the supportedThermostatOperatingStates attribute if the thermostatOperatingState capability is supported and it has not been set before +function ThermostatUtils.handle_thermostat_operating_state_info(device) + local thermostat_operating_state_supported = device:supports_capability(capabilities.thermostatOperatingState) + local latest_supported_operating_states = thermostat_operating_state_supported and device:get_latest_state( + "main", capabilities.thermostatOperatingState.ID, capabilities.thermostatOperatingState.supportedThermostatOperatingStates.NAME + ) + if thermostat_operating_state_supported and latest_supported_operating_states == nil then + local supported_operating_modes = { "idle" } + if #device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) > 0 then + table.insert(supported_operating_modes, "heating") + end + if #device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) > 0 then + table.insert(supported_operating_modes, "cooling") + end + local thermostat_ep_id = device:get_endpoints(clusters.Thermostat.ID)[1] + device:emit_event_for_endpoint(thermostat_ep_id, capabilities.thermostatOperatingState.supportedThermostatOperatingStates(supported_operating_modes, {visibility = {displayed = false}})) + end +end + +function ThermostatUtils.get_device_type(device) + -- For cases where a device has multiple device types, this list indicates which + -- device type will be the "main" device type for purposes of selecting a profile + -- with an appropriate category. This is done to promote consistency between + -- devices with similar device type compositions that may report their device types + -- listed in different orders + local device_type_priority = { + [fields.HEAT_PUMP_DEVICE_TYPE_ID] = 1, + [fields.RAC_DEVICE_TYPE_ID] = 2, + [fields.AP_DEVICE_TYPE_ID] = 3, + [fields.THERMOSTAT_DEVICE_TYPE_ID] = 4, + [fields.FAN_DEVICE_TYPE_ID] = 5, + [fields.WATER_HEATER_DEVICE_TYPE_ID] = 6, + } + + local main_device_type = false + + for _, ep in ipairs(device.endpoints) do + if ep.device_types ~= nil then + for _, dt in ipairs(ep.device_types) do + if not device_type_priority[main_device_type] or (device_type_priority[dt.device_type_id] and + device_type_priority[dt.device_type_id] < device_type_priority[main_device_type]) then + main_device_type = dt.device_type_id + end + end + end + end + + return main_device_type +end + +function ThermostatUtils.convert_value_to_unit(value, from_unit, to_unit, capability_name) + local conversion_function = fields.unit_conversion[from_unit] and fields.unit_conversion[from_unit][to_unit] + if not conversion_function then + log.info_with( {hub_logs = true} , string.format("Unsupported unit conversion from %s to %s", fields.unit_strings[from_unit], fields.unit_strings[to_unit])) + return + elseif type(value) ~= "number" then + log.info_with( {hub_logs = true} , string.format("unit conversion value (%s) is not a number", value)) + return + end + return conversion_function(value, fields.molecular_weights[capability_name]) +end + +function ThermostatUtils.supports_capability_by_id_modular(device, capability, component) + if not device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then + device.log.warn_with({hub_logs = true}, "Device has overriden supports_capability_by_id, but does not have supported capabilities set.") + return false + end + for _, component_capabilities in ipairs(device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES)) do + local comp_id = component_capabilities[1] + local capability_ids = component_capabilities[2] + if (component == nil) or (component == comp_id) then + for _, cap in ipairs(capability_ids) do + if cap == capability then + return true + end + end + end + end + return false +end + +function ThermostatUtils.report_power_consumption_to_st_energy(device, latest_total_imported_energy_wh) + local current_time = os.time() + local last_time = device:get_field(fields.LAST_IMPORTED_REPORT_TIMESTAMP) or 0 + + -- Ensure that the previous report was sent at least 15 minutes ago + if fields.MINIMUM_ST_ENERGY_REPORT_INTERVAL >= (current_time - last_time) then + return + end + + device:set_field(fields.LAST_IMPORTED_REPORT_TIMESTAMP, current_time, { persist = true }) + + -- Calculate the energy delta between reports + local energy_delta_wh = 0.0 + local previous_imported_report = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME) + if previous_imported_report and previous_imported_report.energy then + energy_delta_wh = math.max(latest_total_imported_energy_wh - previous_imported_report.energy, 0.0) + end + + local epoch_to_iso8601 = function(time) return os.date("!%Y-%m-%dT%H:%M:%SZ", time) end + + -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' + device:emit_component_event(device.profile.components["main"], capabilities.powerConsumptionReport.powerConsumption({ + start = epoch_to_iso8601(last_time), + ["end"] = epoch_to_iso8601(current_time - 1), + deltaEnergy = energy_delta_wh, + energy = latest_total_imported_energy_wh + })) +end + +return ThermostatUtils diff --git a/drivers/SmartThings/matter-window-covering/fingerprints.yml b/drivers/SmartThings/matter-window-covering/fingerprints.yml index aa3884dded..34406cf01c 100644 --- a/drivers/SmartThings/matter-window-covering/fingerprints.yml +++ b/drivers/SmartThings/matter-window-covering/fingerprints.yml @@ -5,6 +5,67 @@ matterManufacturer: vendorId: 0x130A productId: 0x55 deviceProfileName: window-covering-battery + - id: "4874/109" + deviceLabel: Eve MotionBlinds for Roller Blinds + vendorId: 0x130A + productId: 0x006D + deviceProfileName: window-covering-battery + - id: "4874/98" + deviceLabel: Eve MotionBlinds for Honeycomb Blinds + vendorId: 0x130A + productId: 0x0062 + deviceProfileName: window-covering-battery + - id: "4874/99" + deviceLabel: Eve MotionBlinds for Venetian Blinds + vendorId: 0x130A + productId: 0x0063 + deviceProfileName: window-covering-battery + - id: "4874/100" + deviceLabel: Eve MotionBlinds for Curtains + vendorId: 0x130A + productId: 0x0064 + deviceProfileName: window-covering-battery + - id: "4874/96" + deviceLabel: Eve Shutter Switch + vendorId: 0x130A + productId: 0x0060 + deviceProfileName: window-covering +# Griesser + - id: "5435/14337" + deviceLabel: MSM-1 + vendorId: 0x153B + productId: 0x3801 + deviceProfileName: window-covering-tilt +# HooRii + - id: "4945/61171" + deviceLabel: HooRii Window Covering + vendorId: 0x1351 + productId: 0xEEF3 + deviceProfileName: window-covering-battery +# Meross + - id: "4933/61453" + deviceLabel: Smart Wi-Fi Roller Shutter Timer + vendorId: 0x1345 + productId: 0xF00D + deviceProfileName: window-covering +# Mamaba + - id: "4965/4097" + deviceLabel: Wi-Fi Curtain + vendorId: 0x1365 + productId: 0x1001 + deviceProfileName: window-covering +# SmartWave + - id: "5376/10001" + deviceLabel: SmartWave Motorized Roller Shades 100 Blackout Flex + vendorId: 0x1500 + productId: 0x2711 + deviceProfileName: window-covering-battery +# SmartWings + - id: "5231/4097" + deviceLabel: SmartWings Window Covering + vendorId: 0x146F + productId: 0x1001 + deviceProfileName: window-covering-battery #Zemismart - id: "Zemismart MT01 Slide Curtain" deviceLabel: Zemismart MT01 Slide Curtain @@ -15,7 +76,7 @@ matterManufacturer: deviceLabel: Zemismart MT25B Roller Motor vendorId: 0x139C productId: 0xFF60 - deviceProfileName: window-covering + deviceProfileName: window-covering - id: "5020/65296" deviceLabel: Zemismart MT82 Smart Curtain vendorId: 0x139C @@ -26,15 +87,182 @@ matterManufacturer: vendorId: 0x139C productId: 0xFF15 deviceProfileName: window-covering - - id: "5020/65535" - deviceLabel: Zemismart ZM25M Matter Roller Motor + - id: "5020/64050" + deviceLabel: Zemismart ZM02 Smart Curtain + vendorId: 0x139C + productId: 0xFA32 + deviceProfileName: window-covering + - id: "5020/64017" + deviceLabel: Zemismart ZM25C Smart Curtain vendorId: 0x139C - productId: 0xFFFF + productId: 0xFA11 deviceProfileName: window-covering - + - id: "5020/64049" + deviceLabel: Zemismart ZM01 Smart Curtain + vendorId: 0x139C + productId: 0xFA31 + deviceProfileName: window-covering + - id: "5020/64023" + deviceLabel: Zemismart ZM24A Smart Curtain + vendorId: 0x139C + productId: 0xFA17 + deviceProfileName: window-covering +#WINDOWSTORY + - id: "5496/6657" + deviceLabel: GATEWAY-MT + vendorId: 0x1578 + productId: 0x1A01 + deviceProfileName: window-covering-tilt +#WISTAR + - id: "5207/3" + deviceLabel: WISTAR WSERD16-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0003 + deviceProfileName: window-covering-battery + - id: "5207/4" + deviceLabel: WISTAR WSERD24 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0004 + deviceProfileName: window-covering + - id: "5207/5" + deviceLabel: WISTAR WSERD40-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0005 + deviceProfileName: window-covering-battery + - id: "5207/6" + deviceLabel: WISTAR WSERD40-L Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0006 + deviceProfileName: window-covering-battery + - id: "5207/7" + deviceLabel: WISTAR WSERD40-T Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0007 + deviceProfileName: window-covering + - id: "5207/8" + deviceLabel: WISTAR WSERD50-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0008 + deviceProfileName: window-covering-battery + - id: "5207/9" + deviceLabel: WISTAR WSERD50-L Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0009 + deviceProfileName: window-covering-battery + - id: "5207/16" + deviceLabel: WISTAR WSERD50-T Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0010 + deviceProfileName: window-covering + - id: "5207/19" + deviceLabel: WISTAR WSER60 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0013 + deviceProfileName: window-covering + - id: "5207/17" + deviceLabel: WISTAR WSER40 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0011 + deviceProfileName: window-covering + - id: "5207/18" + deviceLabel: WISTAR WSER50 Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0012 + deviceProfileName: window-covering + - id: "5207/2" + deviceLabel: WISTAR WSERD30-B Smart Tubular Motor + vendorId: 0x1457 + productId: 0x0002 + deviceProfileName: window-covering-battery + - id: "5207/22" + deviceLabel: WISTAR WSCMXH Smart Vertical Blind Motor + vendorId: 0x1457 + productId: 0x0016 + deviceProfileName: window-covering-tilt + - id: "5207/23" + deviceLabel: WISTAR WSCMXF Smart Vertical Blind Motor + vendorId: 0x1457 + productId: 0x0017 + deviceProfileName: window-covering-tilt + - id: "5207/24" + deviceLabel: WISTAR WSCMXF-LED Smart Vertical Blind Motor + vendorId: 0x1457 + productId: 0x0018 + deviceProfileName: window-covering-tilt + - id: "5207/20" + deviceLabel: WISTAR WSCMQ Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0014 + deviceProfileName: window-covering + - id: "5207/21" + deviceLabel: WISTAR WSCMXI Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0015 + deviceProfileName: window-covering + - id: "5207/32" + deviceLabel: WISTAR WSCMT Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0020 + deviceProfileName: window-covering + - id: "5207/34" + deviceLabel: WISTAR WSCMXB Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0022 + deviceProfileName: window-covering + - id: "5207/35" + deviceLabel: WISTAR WSCMXC Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0023 + deviceProfileName: window-covering + - id: "5207/38" + deviceLabel: WISTAR WSCMXJ Smart Curtain Motor + vendorId: 0x1457 + productId: 0x0026 + deviceProfileName: window-covering +#Yooksmart + - id: "5411/1052" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x041C + deviceProfileName: window-covering-battery + - id: "5411/2660" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A64 + deviceProfileName: window-covering-battery + - id: "5411/2661" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A65 + deviceProfileName: window-covering-battery + - id: "5411/2662" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A66 + deviceProfileName: window-covering-battery + - id: "5411/2663" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A67 + deviceProfileName: window-covering-battery + - id: "5411/2664" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A68 + deviceProfileName: window-covering-battery + - id: "5411/2665" + deviceLabel: Smart WindowCovering Series + vendorId: 0x1523 + productId: 0x0A69 + deviceProfileName: window-covering-battery matterGeneric: - id: "windowcovering" deviceLabel: Matter Window Covering deviceTypes: - id: 0x0202 # Window Covering deviceProfileName: window-covering + - id: "closure" + deviceLabel: Matter Closure + deviceTypes: + - id: 0x0230 # Closure + deviceProfileName: covering diff --git a/drivers/SmartThings/matter-window-covering/profiles/covering.yml b/drivers/SmartThings/matter-window-covering/profiles/covering.yml new file mode 100644 index 0000000000..11e2319a5a --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/covering.yml @@ -0,0 +1,56 @@ +name: covering +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeLevel + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +- id: windowShade1 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +- id: windowShade2 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +- id: windowShade3 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +- id: windowShade4 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/door.yml b/drivers/SmartThings/matter-window-covering/profiles/door.yml new file mode 100644 index 0000000000..bd19c2dc8f --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/door.yml @@ -0,0 +1,56 @@ +name: door +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: level + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Door +- id: door1 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door2 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door3 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door4 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/garage-door.yml b/drivers/SmartThings/matter-window-covering/profiles/garage-door.yml new file mode 100644 index 0000000000..a75ae63580 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/garage-door.yml @@ -0,0 +1,56 @@ +name: garage-door +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: level + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: GarageDoor +- id: door1 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +- id: door2 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +- id: door3 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +- id: door4 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/gate.yml b/drivers/SmartThings/matter-window-covering/profiles/gate.yml new file mode 100644 index 0000000000..8cc49b425f --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/gate.yml @@ -0,0 +1,56 @@ +name: gate +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: level + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Door +- id: door1 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door2 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door3 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door4 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml index 05664bcced..ad2dc6f0ac 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate @@ -17,5 +19,5 @@ components: categories: - name: Blind preferences: - - preferenceId: presetPosition + - preferenceId: reverse explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml new file mode 100644 index 0000000000..c541d47ae8 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml @@ -0,0 +1,23 @@ +name: window-covering-batteryLevel +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: statelessWindowShadeLevelStep + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml index 0c007970b9..94e98998b3 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml @@ -9,6 +9,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate @@ -18,5 +20,5 @@ components: categories: - name: Blind preferences: - - preferenceId: presetPosition + - preferenceId: reverse explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml new file mode 100644 index 0000000000..bca45d37af --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml @@ -0,0 +1,25 @@ +name: window-covering-tilt-battery +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: statelessWindowShadeLevelStep + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml new file mode 100644 index 0000000000..ce05fc82bc --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml @@ -0,0 +1,19 @@ +name: window-covering-tilt-only-battery +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml new file mode 100644 index 0000000000..1ae1e4a7a7 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml @@ -0,0 +1,17 @@ +name: window-covering-tilt-only +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml new file mode 100644 index 0000000000..99a579e2c6 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml @@ -0,0 +1,23 @@ +name: window-covering-tilt +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: statelessWindowShadeLevelStep + version: 1 + - id: windowShadeTiltLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml index e381c13797..e90a3fdb23 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh @@ -15,5 +17,5 @@ components: categories: - name: Blind preferences: - - preferenceId: presetPosition + - preferenceId: reverse explicit: true diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/init.lua new file mode 100644 index 0000000000..76adc03375 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/init.lua @@ -0,0 +1,97 @@ +local cluster_base = require "st.matter.cluster_base" +local ClosureControlServerAttributes = require "embedded_clusters.ClosureControl.server.attributes" +local ClosureControlServerCommands = require "embedded_clusters.ClosureControl.server.commands" +local ClosureControlTypes = require "embedded_clusters.ClosureControl.types" + +local ClosureControl = {} + +ClosureControl.ID = 0x0104 +ClosureControl.NAME = "ClosureControl" +ClosureControl.server = {} +ClosureControl.client = {} +ClosureControl.server.attributes = ClosureControlServerAttributes:set_parent_cluster(ClosureControl) +ClosureControl.server.commands = ClosureControlServerCommands:set_parent_cluster(ClosureControl) +ClosureControl.types = ClosureControlTypes + +function ClosureControl:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "CountdownTime", + [0x0001] = "MainState", + [0x0002] = "CurrentErrorList", + [0x0003] = "OverallCurrentState", + [0x0004] = "OverallTargetState", + [0x0005] = "LatchControlModes", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ClosureControl:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "Stop", + [0x0001] = "MoveTo", + [0x0002] = "Calibrate", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + + +ClosureControl.attribute_direction_map = { + ["CountdownTime"] = "server", + ["MainState"] = "server", + ["CurrentErrorList"] = "server", + ["OverallCurrentState"] = "server", + ["OverallTargetState"] = "server", + ["LatchControlModes"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +ClosureControl.command_direction_map = { + ["Stop"] = "server", + ["MoveTo"] = "server", + ["Calibrate"] = "server", +} + +ClosureControl.FeatureMap = ClosureControl.types.Feature + +function ClosureControl.are_features_supported(feature, feature_map) + if (ClosureControl.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ClosureControl.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ClosureControl.NAME)) + end + return ClosureControl[direction].attributes[key] +end +ClosureControl.attributes = {} +setmetatable(ClosureControl.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ClosureControl.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ClosureControl.NAME)) + end + return ClosureControl[direction].commands[key] +end +ClosureControl.commands = {} +setmetatable(ClosureControl.commands, command_helper_mt) + +setmetatable(ClosureControl, {__index = cluster_base}) + +return ClosureControl diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..f86ac60dbe --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..7f6827b026 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AttributeList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AttributeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AttributeList.element_type) + end +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) +return AttributeList diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CountdownTime.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CountdownTime.lua new file mode 100644 index 0000000000..b6e4f861b6 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CountdownTime.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CountdownTime = { + ID = 0x0000, + NAME = "CountdownTime", + base_type = require "st.matter.data_types.Uint32", +} + +function CountdownTime:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CountdownTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CountdownTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CountdownTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CountdownTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CountdownTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CountdownTime, {__call = CountdownTime.new_value, __index = CountdownTime.base_type}) +return CountdownTime diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CurrentErrorList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CurrentErrorList.lua new file mode 100644 index 0000000000..21076e7e5d --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CurrentErrorList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentErrorList = { + ID = 0x0002, + NAME = "CurrentErrorList", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.ClosureControl.types.ClosureErrorEnum", +} + +function CurrentErrorList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, CurrentErrorList.element_type) + end +end + +function CurrentErrorList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CurrentErrorList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentErrorList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentErrorList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentErrorList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentErrorList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CurrentErrorList, {__call = CurrentErrorList.new_value, __index = CurrentErrorList.base_type}) +return CurrentErrorList diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/LatchControlModes.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/LatchControlModes.lua new file mode 100644 index 0000000000..d62831b4f3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/LatchControlModes.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LatchControlModes = { + ID = 0x0005, + NAME = "LatchControlModes", + base_type = require "embedded_clusters.ClosureControl.types.LatchControlModesBitmap", +} + +function LatchControlModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function LatchControlModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LatchControlModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LatchControlModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LatchControlModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LatchControlModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LatchControlModes, {__call = LatchControlModes.new_value, __index = LatchControlModes.base_type}) +return LatchControlModes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/MainState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/MainState.lua new file mode 100644 index 0000000000..5f1bbc06e2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/MainState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MainState = { + ID = 0x0001, + NAME = "MainState", + base_type = require "embedded_clusters.ClosureControl.types.MainStateEnum", +} + +function MainState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function MainState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MainState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MainState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MainState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MainState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MainState, {__call = MainState.new_value, __index = MainState.base_type}) +return MainState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallCurrentState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallCurrentState.lua new file mode 100644 index 0000000000..f93477ec47 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallCurrentState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OverallCurrentState = { + ID = 0x0003, + NAME = "OverallCurrentState", + base_type = require "embedded_clusters.ClosureControl.types.OverallCurrentStateStruct", +} + +function OverallCurrentState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OverallCurrentState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallCurrentState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallCurrentState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OverallCurrentState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OverallCurrentState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OverallCurrentState, {__call = OverallCurrentState.new_value, __index = OverallCurrentState.base_type}) +return OverallCurrentState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallTargetState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallTargetState.lua new file mode 100644 index 0000000000..6c8cbb9ee3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallTargetState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OverallTargetState = { + ID = 0x0004, + NAME = "OverallTargetState", + base_type = require "embedded_clusters.ClosureControl.types.OverallTargetStateStruct", +} + +function OverallTargetState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OverallTargetState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallTargetState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallTargetState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OverallTargetState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OverallTargetState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OverallTargetState, {__call = OverallTargetState.new_value, __index = OverallTargetState.base_type}) +return OverallTargetState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/init.lua new file mode 100644 index 0000000000..0c71152760 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/init.lua @@ -0,0 +1,19 @@ +local attr_mt = {} +attr_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureControl.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureControlServerAttributes = {} + +function ClosureControlServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureControlServerAttributes, attr_mt) + +return ClosureControlServerAttributes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Calibrate.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Calibrate.lua new file mode 100644 index 0000000000..1a2931c0ad --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Calibrate.lua @@ -0,0 +1,91 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Calibrate = {} + +Calibrate.NAME = "Calibrate" +Calibrate.ID = 0x0002 +Calibrate.field_defs = { +} + +function Calibrate:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Calibrate:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Calibrate, + __tostring = Calibrate.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function Calibrate:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Calibrate:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Calibrate:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Calibrate, {__call = Calibrate.init}) + +return Calibrate diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/MoveTo.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/MoveTo.lua new file mode 100644 index 0000000000..fb83ed49c2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/MoveTo.lua @@ -0,0 +1,112 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MoveTo = {} + +MoveTo.NAME = "MoveTo" +MoveTo.ID = 0x0001 +MoveTo.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.ClosureControl.types.TargetPositionEnum", + }, + { + name = "latch", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +function MoveTo:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function MoveTo:init(device, endpoint_id, position, latch, speed) + local out = {} + local args = {position, latch, speed} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = MoveTo, + __tostring = MoveTo.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function MoveTo:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MoveTo:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function MoveTo:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MoveTo, {__call = MoveTo.init}) + +return MoveTo diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Stop.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Stop.lua new file mode 100644 index 0000000000..9ac41ba122 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Stop.lua @@ -0,0 +1,90 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Stop = {} + +Stop.NAME = "Stop" +Stop.ID = 0x0000 +Stop.field_defs = { +} + +function Stop:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Stop:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Stop, + __tostring = Stop.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Stop:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Stop:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Stop:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Stop, {__call = Stop.init}) + +return Stop diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/init.lua new file mode 100644 index 0000000000..1125ef4296 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/init.lua @@ -0,0 +1,19 @@ +local command_mt = {} +command_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureControl.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureControlServerCommands = {} + +function ClosureControlServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureControlServerCommands, command_mt) + +return ClosureControlServerCommands diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/ClosureErrorEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/ClosureErrorEnum.lua new file mode 100644 index 0000000000..5e5a09da56 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/ClosureErrorEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ClosureErrorEnum = {} +local new_mt = UintABC.new_mt({NAME = "ClosureErrorEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.PHYSICALLY_BLOCKED] = "PHYSICALLY_BLOCKED", + [self.BLOCKED_BY_SENSOR] = "BLOCKED_BY_SENSOR", + [self.TEMPERATURE_LIMITED] = "TEMPERATURE_LIMITED", + [self.MAINTENANCE_REQUIRED] = "MAINTENANCE_REQUIRED", + [self.INTERNAL_INTERFERENCE] = "INTERNAL_INTERFERENCE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.PHYSICALLY_BLOCKED = 0x00 +new_mt.__index.BLOCKED_BY_SENSOR = 0x01 +new_mt.__index.TEMPERATURE_LIMITED = 0x02 +new_mt.__index.MAINTENANCE_REQUIRED = 0x03 +new_mt.__index.INTERNAL_INTERFERENCE = 0x04 + +ClosureErrorEnum.PHYSICALLY_BLOCKED = 0x00 +ClosureErrorEnum.BLOCKED_BY_SENSOR = 0x01 +ClosureErrorEnum.TEMPERATURE_LIMITED = 0x02 +ClosureErrorEnum.MAINTENANCE_REQUIRED = 0x03 +ClosureErrorEnum.INTERNAL_INTERFERENCE = 0x04 + +ClosureErrorEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ClosureErrorEnum, new_mt) + +return ClosureErrorEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/CurrentPositionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/CurrentPositionEnum.lua new file mode 100644 index 0000000000..a5fee86d89 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/CurrentPositionEnum.lua @@ -0,0 +1,39 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local CurrentPositionEnum = {} +local new_mt = UintABC.new_mt({NAME = "CurrentPositionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.FULLY_CLOSED] = "FULLY_CLOSED", + [self.FULLY_OPENED] = "FULLY_OPENED", + [self.PARTIALLY_OPENED] = "PARTIALLY_OPENED", + [self.OPENED_FOR_PEDESTRIAN] = "OPENED_FOR_PEDESTRIAN", + [self.OPENED_FOR_VENTILATION] = "OPENED_FOR_VENTILATION", + [self.OPENED_AT_SIGNATURE] = "OPENED_AT_SIGNATURE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.FULLY_CLOSED = 0x00 +new_mt.__index.FULLY_OPENED = 0x01 +new_mt.__index.PARTIALLY_OPENED = 0x02 +new_mt.__index.OPENED_FOR_PEDESTRIAN = 0x03 +new_mt.__index.OPENED_FOR_VENTILATION = 0x04 +new_mt.__index.OPENED_AT_SIGNATURE = 0x05 + +CurrentPositionEnum.FULLY_CLOSED = 0x00 +CurrentPositionEnum.FULLY_OPENED = 0x01 +CurrentPositionEnum.PARTIALLY_OPENED = 0x02 +CurrentPositionEnum.OPENED_FOR_PEDESTRIAN = 0x03 +CurrentPositionEnum.OPENED_FOR_VENTILATION = 0x04 +CurrentPositionEnum.OPENED_AT_SIGNATURE = 0x05 + +CurrentPositionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(CurrentPositionEnum, new_mt) + +return CurrentPositionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/Feature.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/Feature.lua new file mode 100644 index 0000000000..e41485dbe7 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/Feature.lua @@ -0,0 +1,228 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.POSITIONING = 0x0001 +Feature.MOTION_LATCHING = 0x0002 +Feature.INSTANTANEOUS = 0x0004 +Feature.SPEED = 0x0008 +Feature.VENTILATION = 0x0010 +Feature.PEDESTRIAN = 0x0020 +Feature.CALIBRATION = 0x0040 +Feature.PROTECTION = 0x0080 +Feature.MANUALLY_OPERABLE = 0x0100 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + POSITIONING = 0x0001, + MOTION_LATCHING = 0x0002, + INSTANTANEOUS = 0x0004, + SPEED = 0x0008, + VENTILATION = 0x0010, + PEDESTRIAN = 0x0020, + CALIBRATION = 0x0040, + PROTECTION = 0x0080, + MANUALLY_OPERABLE = 0x0100, +} + +Feature.is_positioning_set = function(self) + return (self.value & self.POSITIONING) ~= 0 +end + +Feature.set_positioning = function(self) + if self.value ~= nil then + self.value = self.value | self.POSITIONING + else + self.value = self.POSITIONING + end +end + +Feature.unset_positioning = function(self) + self.value = self.value & (~self.POSITIONING & self.BASE_MASK) +end +Feature.is_motion_latching_set = function(self) + return (self.value & self.MOTION_LATCHING) ~= 0 +end + +Feature.set_motion_latching = function(self) + if self.value ~= nil then + self.value = self.value | self.MOTION_LATCHING + else + self.value = self.MOTION_LATCHING + end +end + +Feature.unset_motion_latching = function(self) + self.value = self.value & (~self.MOTION_LATCHING & self.BASE_MASK) +end + +Feature.is_instantaneous_set = function(self) + return (self.value & self.INSTANTANEOUS) ~= 0 +end + +Feature.set_instantaneous = function(self) + if self.value ~= nil then + self.value = self.value | self.INSTANTANEOUS + else + self.value = self.INSTANTANEOUS + end +end + +Feature.unset_instantaneous = function(self) + self.value = self.value & (~self.INSTANTANEOUS & self.BASE_MASK) +end + +Feature.is_speed_set = function(self) + return (self.value & self.SPEED) ~= 0 +end + +Feature.set_speed = function(self) + if self.value ~= nil then + self.value = self.value | self.SPEED + else + self.value = self.SPEED + end +end + +Feature.unset_speed = function(self) + self.value = self.value & (~self.SPEED & self.BASE_MASK) +end + +Feature.is_ventilation_set = function(self) + return (self.value & self.VENTILATION) ~= 0 +end + +Feature.set_ventilation = function(self) + if self.value ~= nil then + self.value = self.value | self.VENTILATION + else + self.value = self.VENTILATION + end +end + +Feature.unset_ventilation = function(self) + self.value = self.value & (~self.VENTILATION & self.BASE_MASK) +end + +Feature.is_pedestrian_set = function(self) + return (self.value & self.PEDESTRIAN) ~= 0 +end + +Feature.set_pedestrian = function(self) + if self.value ~= nil then + self.value = self.value | self.PEDESTRIAN + else + self.value = self.PEDESTRIAN + end +end + +Feature.unset_pedestrian = function(self) + self.value = self.value & (~self.PEDESTRIAN & self.BASE_MASK) +end + +Feature.is_calibration_set = function(self) + return (self.value & self.CALIBRATION) ~= 0 +end + +Feature.set_calibration = function(self) + if self.value ~= nil then + self.value = self.value | self.CALIBRATION + else + self.value = self.CALIBRATION + end +end + +Feature.unset_calibration = function(self) + self.value = self.value & (~self.CALIBRATION & self.BASE_MASK) +end + +Feature.is_protection_set = function(self) + return (self.value & self.PROTECTION) ~= 0 +end + +Feature.set_protection = function(self) + if self.value ~= nil then + self.value = self.value | self.PROTECTION + else + self.value = self.PROTECTION + end +end + +Feature.unset_protection = function(self) + self.value = self.value & (~self.PROTECTION & self.BASE_MASK) +end + +Feature.is_manually_operable_set = function(self) + return (self.value & self.MANUALLY_OPERABLE) ~= 0 +end + +Feature.set_manually_operable = function(self) + if self.value ~= nil then + self.value = self.value | self.MANUALLY_OPERABLE + else + self.value = self.MANUALLY_OPERABLE + end +end + +Feature.unset_manually_operable = function(self) + self.value = self.value & (~self.MANUALLY_OPERABLE & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.POSITIONING | + Feature.MOTION_LATCHING | + Feature.INSTANTANEOUS | + Feature.SPEED | + Feature.VENTILATION | + Feature.PEDESTRIAN | + Feature.CALIBRATION | + Feature.PROTECTION | + Feature.MANUALLY_OPERABLE + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_positioning_set = Feature.is_positioning_set, + set_positioning = Feature.set_positioning, + unset_positioning = Feature.unset_positioning, + is_motion_latching_set = Feature.is_motion_latching_set, + set_motion_latching = Feature.set_motion_latching, + unset_motion_latching = Feature.unset_motion_latching, + is_instantaneous_set = Feature.is_instantaneous_set, + set_instantaneous = Feature.set_instantaneous, + unset_instantaneous = Feature.unset_instantaneous, + is_speed_set = Feature.is_speed_set, + set_speed = Feature.set_speed, + unset_speed = Feature.unset_speed, + is_ventilation_set = Feature.is_ventilation_set, + set_ventilation = Feature.set_ventilation, + unset_ventilation = Feature.unset_ventilation, + is_pedestrian_set = Feature.is_pedestrian_set, + set_pedestrian = Feature.set_pedestrian, + unset_pedestrian = Feature.unset_pedestrian, + is_calibration_set = Feature.is_calibration_set, + set_calibration = Feature.set_calibration, + unset_calibration = Feature.unset_calibration, + is_protection_set = Feature.is_protection_set, + set_protection = Feature.set_protection, + unset_protection = Feature.unset_protection, + is_manually_operable_set = Feature.is_manually_operable_set, + set_manually_operable = Feature.set_manually_operable, + unset_manually_operable = Feature.unset_manually_operable, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/LatchControlModesBitmap.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/LatchControlModesBitmap.lua new file mode 100644 index 0000000000..ba6188e4a3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/LatchControlModesBitmap.lua @@ -0,0 +1,64 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LatchControlModesBitmap = {} +local new_mt = UintABC.new_mt({NAME = "LatchControlModesBitmap", ID = data_types.name_to_id_map["Uint8"]}, 1) + +LatchControlModesBitmap.BASE_MASK = 0xFFFF +LatchControlModesBitmap.REMOTE_LATCHING = 0x0001 +LatchControlModesBitmap.REMOTE_UNLATCHING = 0x0002 + +LatchControlModesBitmap.mask_fields = { + BASE_MASK = 0xFFFF, + REMOTE_LATCHING = 0x0001, + REMOTE_UNLATCHING = 0x0002, +} + +LatchControlModesBitmap.is_remote_latching_set = function(self) + return (self.value & self.REMOTE_LATCHING) ~= 0 +end + +LatchControlModesBitmap.set_remote_latching = function(self) + if self.value ~= nil then + self.value = self.value | self.REMOTE_LATCHING + else + self.value = self.REMOTE_LATCHING + end +end + +LatchControlModesBitmap.unset_remote_latching = function(self) + self.value = self.value & (~self.REMOTE_LATCHING & self.BASE_MASK) +end + +LatchControlModesBitmap.is_remote_unlatching_set = function(self) + return (self.value & self.REMOTE_UNLATCHING) ~= 0 +end + +LatchControlModesBitmap.set_remote_unlatching = function(self) + if self.value ~= nil then + self.value = self.value | self.REMOTE_UNLATCHING + else + self.value = self.REMOTE_UNLATCHING + end +end + +LatchControlModesBitmap.unset_remote_unlatching = function(self) + self.value = self.value & (~self.REMOTE_UNLATCHING & self.BASE_MASK) +end + +LatchControlModesBitmap.mask_methods = { + is_remote_latching_set = LatchControlModesBitmap.is_remote_latching_set, + set_remote_latching = LatchControlModesBitmap.set_remote_latching, + unset_remote_latching = LatchControlModesBitmap.unset_remote_latching, + is_remote_unlatching_set = LatchControlModesBitmap.is_remote_unlatching_set, + set_remote_unlatching = LatchControlModesBitmap.set_remote_unlatching, + unset_remote_unlatching = LatchControlModesBitmap.unset_remote_unlatching, +} + +LatchControlModesBitmap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LatchControlModesBitmap, new_mt) + +return LatchControlModesBitmap diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/MainStateEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/MainStateEnum.lua new file mode 100644 index 0000000000..916e27dda0 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/MainStateEnum.lua @@ -0,0 +1,45 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local MainStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "MainStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.STOPPED] = "STOPPED", + [self.MOVING] = "MOVING", + [self.WAITING_FOR_MOTION] = "WAITING_FOR_MOTION", + [self.ERROR] = "ERROR", + [self.CALIBRATING] = "CALIBRATING", + [self.PROTECTED] = "PROTECTED", + [self.DISENGAGED] = "DISENGAGED", + [self.SETUP_REQUIRED] = "SETUP_REQUIRED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.STOPPED = 0x00 +new_mt.__index.MOVING = 0x01 +new_mt.__index.WAITING_FOR_MOTION = 0x02 +new_mt.__index.ERROR = 0x03 +new_mt.__index.CALIBRATING = 0x04 +new_mt.__index.PROTECTED = 0x05 +new_mt.__index.DISENGAGED = 0x06 +new_mt.__index.SETUP_REQUIRED = 0x07 + +MainStateEnum.STOPPED = 0x00 +MainStateEnum.MOVING = 0x01 +MainStateEnum.WAITING_FOR_MOTION = 0x02 +MainStateEnum.ERROR = 0x03 +MainStateEnum.CALIBRATING = 0x04 +MainStateEnum.PROTECTED = 0x05 +MainStateEnum.DISENGAGED = 0x06 +MainStateEnum.SETUP_REQUIRED = 0x07 + +MainStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(MainStateEnum, new_mt) + +return MainStateEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallCurrentStateStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallCurrentStateStruct.lua new file mode 100644 index 0000000000..cf4aaa91df --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallCurrentStateStruct.lua @@ -0,0 +1,91 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OverallCurrentStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "OverallCurrentStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +OverallCurrentStateStruct.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = true, + is_optional = true, + data_type = require "embedded_clusters.ClosureControl.types.CurrentPositionEnum", + }, + { + name = "latch", + field_id = 1, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, + { + name = "secure_state", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Boolean", + }, +} + +OverallCurrentStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +OverallCurrentStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = OverallCurrentStateStruct.init +new_mt.__index.serialize = OverallCurrentStateStruct.serialize + +OverallCurrentStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(OverallCurrentStateStruct, new_mt) + +return OverallCurrentStateStruct diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallTargetStateStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallTargetStateStruct.lua new file mode 100644 index 0000000000..f52fff4d1c --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallTargetStateStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OverallTargetStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "OverallTargetStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +OverallTargetStateStruct.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = true, + is_optional = true, + data_type = require "embedded_clusters.ClosureControl.types.TargetPositionEnum", + }, + { + name = "latch", + field_id = 1, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +OverallTargetStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +OverallTargetStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = OverallTargetStateStruct.init +new_mt.__index.serialize = OverallTargetStateStruct.serialize + +OverallTargetStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(OverallTargetStateStruct, new_mt) + +return OverallTargetStateStruct diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/TargetPositionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/TargetPositionEnum.lua new file mode 100644 index 0000000000..b7a6122863 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/TargetPositionEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local TargetPositionEnum = {} +local new_mt = UintABC.new_mt({NAME = "TargetPositionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.MOVE_TO_FULLY_CLOSED] = "MOVE_TO_FULLY_CLOSED", + [self.MOVE_TO_FULLY_OPEN] = "MOVE_TO_FULLY_OPEN", + [self.MOVE_TO_PEDESTRIAN_POSITION] = "MOVE_TO_PEDESTRIAN_POSITION", + [self.MOVE_TO_VENTILATION_POSITION] = "MOVE_TO_VENTILATION_POSITION", + [self.MOVE_TO_SIGNATURE_POSITION] = "MOVE_TO_SIGNATURE_POSITION", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.MOVE_TO_FULLY_CLOSED = 0x00 +new_mt.__index.MOVE_TO_FULLY_OPEN = 0x01 +new_mt.__index.MOVE_TO_PEDESTRIAN_POSITION = 0x02 +new_mt.__index.MOVE_TO_VENTILATION_POSITION = 0x03 +new_mt.__index.MOVE_TO_SIGNATURE_POSITION = 0x04 + +TargetPositionEnum.MOVE_TO_FULLY_CLOSED = 0x00 +TargetPositionEnum.MOVE_TO_FULLY_OPEN = 0x01 +TargetPositionEnum.MOVE_TO_PEDESTRIAN_POSITION = 0x02 +TargetPositionEnum.MOVE_TO_VENTILATION_POSITION = 0x03 +TargetPositionEnum.MOVE_TO_SIGNATURE_POSITION = 0x04 + +TargetPositionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(TargetPositionEnum, new_mt) + +return TargetPositionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/init.lua new file mode 100644 index 0000000000..6531f734a3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/init.lua @@ -0,0 +1,10 @@ +local types_mt = {} +types_mt.__index = function(self, key) + return require("embedded_clusters.ClosureControl.types." .. key) +end + +local ClosureControlTypes = {} + +setmetatable(ClosureControlTypes, types_mt) + +return ClosureControlTypes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/init.lua new file mode 100644 index 0000000000..eb2f4cdae2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/init.lua @@ -0,0 +1,106 @@ +local cluster_base = require "st.matter.cluster_base" +local ClosureDimensionServerAttributes = require "embedded_clusters.ClosureDimension.server.attributes" +local ClosureDimensionServerCommands = require "embedded_clusters.ClosureDimension.server.commands" +local ClosureDimensionTypes = require "embedded_clusters.ClosureDimension.types" + +local ClosureDimension = {} + +ClosureDimension.ID = 0x0105 +ClosureDimension.NAME = "ClosureDimension" +ClosureDimension.server = {} +ClosureDimension.client = {} +ClosureDimension.server.attributes = ClosureDimensionServerAttributes:set_parent_cluster(ClosureDimension) +ClosureDimension.server.commands = ClosureDimensionServerCommands:set_parent_cluster(ClosureDimension) +ClosureDimension.types = ClosureDimensionTypes + +function ClosureDimension:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "CurrentState", + [0x0001] = "TargetState", + [0x0002] = "Resolution", + [0x0003] = "StepValue", + [0x0004] = "Unit", + [0x0005] = "UnitRange", + [0x0006] = "LimitRange", + [0x0007] = "TranslationDirection", + [0x0008] = "RotationAxis", + [0x0009] = "Overflow", + [0x000A] = "ModulationType", + [0x000B] = "LatchControlModes", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ClosureDimension:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "SetTarget", + [0x0001] = "Step", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ClosureDimension.attribute_direction_map = { + ["CurrentState"] = "server", + ["TargetState"] = "server", + ["Resolution"] = "server", + ["StepValue"] = "server", + ["Unit"] = "server", + ["UnitRange"] = "server", + ["LimitRange"] = "server", + ["TranslationDirection"] = "server", + ["RotationAxis"] = "server", + ["Overflow"] = "server", + ["ModulationType"] = "server", + ["LatchControlModes"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +ClosureDimension.command_direction_map = { + ["SetTarget"] = "server", + ["Step"] = "server", +} + +ClosureDimension.FeatureMap = ClosureDimension.types.Feature + +function ClosureDimension.are_features_supported(feature, feature_map) + if (ClosureDimension.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ClosureDimension.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ClosureDimension.NAME)) + end + return ClosureDimension[direction].attributes[key] +end +ClosureDimension.attributes = {} +setmetatable(ClosureDimension.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ClosureDimension.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ClosureDimension.NAME)) + end + return ClosureDimension[direction].commands[key] +end +ClosureDimension.commands = {} +setmetatable(ClosureDimension.commands, command_helper_mt) + +setmetatable(ClosureDimension, {__index = cluster_base}) + +return ClosureDimension diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/CurrentState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/CurrentState.lua new file mode 100644 index 0000000000..cd0566b774 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/CurrentState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentState = { + ID = 0x0000, + NAME = "CurrentState", + base_type = require "embedded_clusters.ClosureDimension.types.DimensionStateStruct", +} + +function CurrentState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CurrentState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CurrentState, {__call = CurrentState.new_value, __index = CurrentState.base_type}) +return CurrentState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/LimitRange.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/LimitRange.lua new file mode 100644 index 0000000000..8f9b357bf8 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/LimitRange.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LimitRange = { + ID = 0x0006, + NAME = "LimitRange", + base_type = require "embedded_clusters.ClosureDimension.types.RangePercent100thsStruct", +} + +function LimitRange:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function LimitRange:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LimitRange:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LimitRange:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LimitRange:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LimitRange:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LimitRange, {__call = LimitRange.new_value, __index = LimitRange.base_type}) +return LimitRange diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/StepValue.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/StepValue.lua new file mode 100644 index 0000000000..c6958a20bb --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/StepValue.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local StepValue = { + ID = 0x0003, + NAME = "StepValue", + base_type = require "st.matter.data_types.Uint16", +} + +function StepValue:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function StepValue:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StepValue:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StepValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function StepValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function StepValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(StepValue, {__call = StepValue.new_value, __index = StepValue.base_type}) +return StepValue diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/TargetState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/TargetState.lua new file mode 100644 index 0000000000..fa242ca507 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/TargetState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local TargetState = { + ID = 0x0001, + NAME = "TargetState", + base_type = require "embedded_clusters.ClosureDimension.types.DimensionStateStruct", +} + +function TargetState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function TargetState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TargetState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TargetState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function TargetState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function TargetState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(TargetState, {__call = TargetState.new_value, __index = TargetState.base_type}) +return TargetState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/init.lua new file mode 100644 index 0000000000..8728e92157 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/init.lua @@ -0,0 +1,19 @@ +local attr_mt = {} +attr_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureDimension.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureDimensionServerAttributes = {} + +function ClosureDimensionServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureDimensionServerAttributes, attr_mt) + +return ClosureDimensionServerAttributes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/SetTarget.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/SetTarget.lua new file mode 100644 index 0000000000..c14a550f16 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/SetTarget.lua @@ -0,0 +1,112 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetTarget = {} + +SetTarget.NAME = "SetTarget" +SetTarget.ID = 0x0000 +SetTarget.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "latch", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +function SetTarget:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SetTarget:init(device, endpoint_id, position, latch, speed) + local out = {} + local args = {position, latch, speed} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetTarget, + __tostring = SetTarget.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function SetTarget:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetTarget:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetTarget:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetTarget, {__call = SetTarget.init}) + +return SetTarget diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/Step.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/Step.lua new file mode 100644 index 0000000000..597d2ddd74 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/Step.lua @@ -0,0 +1,112 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Step = {} + +Step.NAME = "Step" +Step.ID = 0x0001 +Step.field_defs = { + { + name = "direction", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "embedded_clusters.ClosureDimension.types.StepDirectionEnum", + }, + { + name = "number_of_steps", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +function Step:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Step:init(device, endpoint_id, direction, number_of_steps, speed) + local out = {} + local args = {direction, number_of_steps, speed} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Step, + __tostring = Step.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function Step:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Step:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Step:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Step, {__call = Step.init}) + +return Step diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/init.lua new file mode 100644 index 0000000000..74003acc00 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/init.lua @@ -0,0 +1,19 @@ +local command_mt = {} +command_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureDimension.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureDimensionServerCommands = {} + +function ClosureDimensionServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureDimensionServerCommands, command_mt) + +return ClosureDimensionServerCommands diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/DimensionStateStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/DimensionStateStruct.lua new file mode 100644 index 0000000000..ecf718cbf3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/DimensionStateStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local DimensionStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "DimensionStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +DimensionStateStruct.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "latch", + field_id = 1, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +DimensionStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +DimensionStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = DimensionStateStruct.init +new_mt.__index.serialize = DimensionStateStruct.serialize + +DimensionStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(DimensionStateStruct, new_mt) + +return DimensionStateStruct diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/Feature.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/Feature.lua new file mode 100644 index 0000000000..ecf029105c --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/Feature.lua @@ -0,0 +1,207 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.POSITIONING = 0x0001 +Feature.MOTION_LATCHING = 0x0002 +Feature.UNIT = 0x0004 +Feature.LIMITATION = 0x0008 +Feature.SPEED = 0x0010 +Feature.TRANSLATION = 0x0020 +Feature.ROTATION = 0x0040 +Feature.MODULATION = 0x0080 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + POSITIONING = 0x0001, + MOTION_LATCHING = 0x0002, + UNIT = 0x0004, + LIMITATION = 0x0008, + SPEED = 0x0010, + TRANSLATION = 0x0020, + ROTATION = 0x0040, + MODULATION = 0x0080, +} + +Feature.is_positioning_set = function(self) + return (self.value & self.POSITIONING) ~= 0 +end + +Feature.set_positioning = function(self) + if self.value ~= nil then + self.value = self.value | self.POSITIONING + else + self.value = self.POSITIONING + end +end + +Feature.unset_positioning = function(self) + self.value = self.value & (~self.POSITIONING & self.BASE_MASK) +end + +Feature.is_motion_latching_set = function(self) + return (self.value & self.MOTION_LATCHING) ~= 0 +end + +Feature.set_motion_latching = function(self) + if self.value ~= nil then + self.value = self.value | self.MOTION_LATCHING + else + self.value = self.MOTION_LATCHING + end +end + +Feature.unset_motion_latching = function(self) + self.value = self.value & (~self.MOTION_LATCHING & self.BASE_MASK) +end + +Feature.is_unit_set = function(self) + return (self.value & self.UNIT) ~= 0 +end + +Feature.set_unit = function(self) + if self.value ~= nil then + self.value = self.value | self.UNIT + else + self.value = self.UNIT + end +end + +Feature.unset_unit = function(self) + self.value = self.value & (~self.UNIT & self.BASE_MASK) +end + +Feature.is_limitation_set = function(self) + return (self.value & self.LIMITATION) ~= 0 +end + +Feature.set_limitation = function(self) + if self.value ~= nil then + self.value = self.value | self.LIMITATION + else + self.value = self.LIMITATION + end +end + +Feature.unset_limitation = function(self) + self.value = self.value & (~self.LIMITATION & self.BASE_MASK) +end + +Feature.is_speed_set = function(self) + return (self.value & self.SPEED) ~= 0 +end + +Feature.set_speed = function(self) + if self.value ~= nil then + self.value = self.value | self.SPEED + else + self.value = self.SPEED + end +end + +Feature.unset_speed = function(self) + self.value = self.value & (~self.SPEED & self.BASE_MASK) +end + +Feature.is_translation_set = function(self) + return (self.value & self.TRANSLATION) ~= 0 +end + +Feature.set_translation = function(self) + if self.value ~= nil then + self.value = self.value | self.TRANSLATION + else + self.value = self.TRANSLATION + end +end + +Feature.unset_translation = function(self) + self.value = self.value & (~self.TRANSLATION & self.BASE_MASK) +end + +Feature.is_rotation_set = function(self) + return (self.value & self.ROTATION) ~= 0 +end + +Feature.set_rotation = function(self) + if self.value ~= nil then + self.value = self.value | self.ROTATION + else + self.value = self.ROTATION + end +end + +Feature.unset_rotation = function(self) + self.value = self.value & (~self.ROTATION & self.BASE_MASK) +end + +Feature.is_modulation_set = function(self) + return (self.value & self.MODULATION) ~= 0 +end + +Feature.set_modulation = function(self) + if self.value ~= nil then + self.value = self.value | self.MODULATION + else + self.value = self.MODULATION + end +end + +Feature.unset_modulation = function(self) + self.value = self.value & (~self.MODULATION & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.POSITIONING | + Feature.MOTION_LATCHING | + Feature.UNIT | + Feature.LIMITATION | + Feature.SPEED | + Feature.TRANSLATION | + Feature.ROTATION | + Feature.MODULATION + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_positioning_set = Feature.is_positioning_set, + set_positioning = Feature.set_positioning, + unset_positioning = Feature.unset_positioning, + is_motion_latching_set = Feature.is_motion_latching_set, + set_motion_latching = Feature.set_motion_latching, + unset_motion_latching = Feature.unset_motion_latching, + is_unit_set = Feature.is_unit_set, + set_unit = Feature.set_unit, + unset_unit = Feature.unset_unit, + is_limitation_set = Feature.is_limitation_set, + set_limitation = Feature.set_limitation, + unset_limitation = Feature.unset_limitation, + is_speed_set = Feature.is_speed_set, + set_speed = Feature.set_speed, + unset_speed = Feature.unset_speed, + is_translation_set = Feature.is_translation_set, + set_translation = Feature.set_translation, + unset_translation = Feature.unset_translation, + is_rotation_set = Feature.is_rotation_set, + set_rotation = Feature.set_rotation, + unset_rotation = Feature.unset_rotation, + is_modulation_set = Feature.is_modulation_set, + set_modulation = Feature.set_modulation, + unset_modulation = Feature.unset_modulation, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/StepDirectionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/StepDirectionEnum.lua new file mode 100644 index 0000000000..c33e8a23ef --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/StepDirectionEnum.lua @@ -0,0 +1,27 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local StepDirectionEnum = {} +local new_mt = UintABC.new_mt({NAME = "StepDirectionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.DECREASE] = "DECREASE", + [self.INCREASE] = "INCREASE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.DECREASE = 0x00 +new_mt.__index.INCREASE = 0x01 + +StepDirectionEnum.DECREASE = 0x00 +StepDirectionEnum.INCREASE = 0x01 + +StepDirectionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(StepDirectionEnum, new_mt) + +return StepDirectionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/TranslationDirectionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/TranslationDirectionEnum.lua new file mode 100644 index 0000000000..b84a353442 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/TranslationDirectionEnum.lua @@ -0,0 +1,57 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local TranslationDirectionEnum = {} +local new_mt = UintABC.new_mt({NAME = "TranslationDirectionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.DOWNWARD] = "DOWNWARD", + [self.UPWARD] = "UPWARD", + [self.VERTICAL_MASK] = "VERTICAL_MASK", + [self.VERTICAL_SYMMETRY] = "VERTICAL_SYMMETRY", + [self.LEFTWARD] = "LEFTWARD", + [self.RIGHTWARD] = "RIGHTWARD", + [self.HORIZONTAL_MASK] = "HORIZONTAL_MASK", + [self.HORIZONTAL_SYMMETRY] = "HORIZONTAL_SYMMETRY", + [self.FORWARD] = "FORWARD", + [self.BACKWARD] = "BACKWARD", + [self.DEPTH_MASK] = "DEPTH_MASK", + [self.DEPTH_SYMMETRY] = "DEPTH_SYMMETRY", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.DOWNWARD = 0x00 +new_mt.__index.UPWARD = 0x01 +new_mt.__index.VERTICAL_MASK = 0x02 +new_mt.__index.VERTICAL_SYMMETRY = 0x03 +new_mt.__index.LEFTWARD = 0x04 +new_mt.__index.RIGHTWARD = 0x05 +new_mt.__index.HORIZONTAL_MASK = 0x06 +new_mt.__index.HORIZONTAL_SYMMETRY = 0x07 +new_mt.__index.FORWARD = 0x08 +new_mt.__index.BACKWARD = 0x09 +new_mt.__index.DEPTH_MASK = 0x0A +new_mt.__index.DEPTH_SYMMETRY = 0x0B + +TranslationDirectionEnum.DOWNWARD = 0x00 +TranslationDirectionEnum.UPWARD = 0x01 +TranslationDirectionEnum.VERTICAL_MASK = 0x02 +TranslationDirectionEnum.VERTICAL_SYMMETRY = 0x03 +TranslationDirectionEnum.LEFTWARD = 0x04 +TranslationDirectionEnum.RIGHTWARD = 0x05 +TranslationDirectionEnum.HORIZONTAL_MASK = 0x06 +TranslationDirectionEnum.HORIZONTAL_SYMMETRY = 0x07 +TranslationDirectionEnum.FORWARD = 0x08 +TranslationDirectionEnum.BACKWARD = 0x09 +TranslationDirectionEnum.DEPTH_MASK = 0x0A +TranslationDirectionEnum.DEPTH_SYMMETRY = 0x0B + +TranslationDirectionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(TranslationDirectionEnum, new_mt) + +return TranslationDirectionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/init.lua new file mode 100644 index 0000000000..8bc2fe2cb2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/init.lua @@ -0,0 +1,10 @@ +local types_mt = {} +types_mt.__index = function(self, key) + return require("embedded_clusters.ClosureDimension.types." .. key) +end + +local ClosureDimensionTypes = {} + +setmetatable(ClosureDimensionTypes, types_mt) + +return ClosureDimensionTypes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/init.lua new file mode 100644 index 0000000000..78739a10db --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/init.lua @@ -0,0 +1,55 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local DescriptorServerAttributes = require "embedded_clusters.Descriptor.server.attributes" + +local Descriptor = {} + +Descriptor.ID = 0x001D +Descriptor.NAME = "Descriptor" +Descriptor.server = {} +Descriptor.client = {} +Descriptor.server.attributes = DescriptorServerAttributes:set_parent_cluster(Descriptor) + +function Descriptor:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0003] = "PartsList", + [0x0004] = "TagList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function Descriptor:get_server_command_by_id(command_id) + local server_id_map = { + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +Descriptor.attribute_direction_map = { + ["PartsList"] = "server", + ["TagList"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Descriptor.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Descriptor.NAME)) + end + return Descriptor[direction].attributes[key] +end +Descriptor.attributes = {} +setmetatable(Descriptor.attributes, attribute_helper_mt) + +setmetatable(Descriptor, {__index = cluster_base}) + +return Descriptor + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua new file mode 100644 index 0000000000..be83b6f878 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PartsList = { + ID = 0x0003, + NAME = "PartsList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint16", +} + +function PartsList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, PartsList.element_type) + end +end + +function PartsList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function PartsList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PartsList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PartsList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PartsList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PartsList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(PartsList, {__call = PartsList.new_value, __index = PartsList.base_type}) +return PartsList + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/TagList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/TagList.lua new file mode 100644 index 0000000000..6bc889d407 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/TagList.lua @@ -0,0 +1,80 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local TagList = { + ID = 0x0004, + NAME = "TagList", + _cluster = require "embedded_clusters.Descriptor", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.Global.types.SemanticTagStruct", +} + +function TagList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, TagList.element_type) + end +end + +function TagList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function TagList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function TagList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TagList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function TagList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function TagList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(TagList, {__call = TagList.new_value, __index = TagList.base_type}) +return TagList + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/init.lua new file mode 100644 index 0000000000..ab933c601f --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Descriptor.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local DescriptorServerAttributes = {} + +function DescriptorServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DescriptorServerAttributes, attr_mt) + +return DescriptorServerAttributes + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/Feature.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/Feature.lua new file mode 100644 index 0000000000..90c03f67b0 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/Feature.lua @@ -0,0 +1,56 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.TAG_LIST = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + TAG_LIST = 0x0001, +} + +Feature.is_tag_list_set = function(self) + return (self.value & self.TAG_LIST) ~= 0 +end + +Feature.set_tag_list = function(self) + if self.value ~= nil then + self.value = self.value | self.TAG_LIST + else + self.value = self.TAG_LIST + end +end + +Feature.unset_tag_list = function(self) + self.value = self.value & (~self.TAG_LIST & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.TAG_LIST + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_tag_list_set = Feature.is_tag_list_set, + set_tag_list = Feature.set_tag_list, + unset_tag_list = Feature.unset_tag_list, +} + +Feature.augment_type = function(_cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/init.lua new file mode 100644 index 0000000000..e76712b2af --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/init.lua @@ -0,0 +1,9 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_loader = require "st.utils.lazy_loader" + +local DescriptorTypes = lazy_loader:new { prefix = "embedded_clusters.Descriptor.types." } + +return DescriptorTypes + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/init.lua new file mode 100644 index 0000000000..5c0c673c40 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/init.lua @@ -0,0 +1,8 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local GlobalTypes = require "embedded_clusters.Global.types" + +local Global = {} +Global.types = GlobalTypes +return Global diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/SemanticTagStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/SemanticTagStruct.lua new file mode 100644 index 0000000000..c0e6f8caa7 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/SemanticTagStruct.lua @@ -0,0 +1,94 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local SemanticTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "SemanticTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +SemanticTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "namespace_id", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "tag", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "label", + field_id = 3, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +SemanticTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do -- Note: _idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +SemanticTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = SemanticTagStruct.init +new_mt.__index.serialize = SemanticTagStruct.serialize + +SemanticTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(SemanticTagStruct, new_mt) + +return SemanticTagStruct + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/ThreeLevelAutoEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/ThreeLevelAutoEnum.lua new file mode 100644 index 0000000000..a203c9b505 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/ThreeLevelAutoEnum.lua @@ -0,0 +1,38 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ThreeLevelAutoEnum = {} +local new_mt = UintABC.new_mt({NAME = "ThreeLevelAutoEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.AUTO] = "AUTO", + [self.LOW] = "LOW", + [self.MEDIUM] = "MEDIUM", + [self.HIGH] = "HIGH", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.AUTO = 0x00 +new_mt.__index.LOW = 0x01 +new_mt.__index.MEDIUM = 0x02 +new_mt.__index.HIGH = 0x03 + +ThreeLevelAutoEnum.AUTO = 0x00 +ThreeLevelAutoEnum.LOW = 0x01 +ThreeLevelAutoEnum.MEDIUM = 0x02 +ThreeLevelAutoEnum.HIGH = 0x03 + +ThreeLevelAutoEnum.augment_type = function(_cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ThreeLevelAutoEnum, new_mt) + + +return ThreeLevelAutoEnum + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/init.lua new file mode 100644 index 0000000000..f4b0f9b225 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.Global.types." .. key) + end + return types_mt.__types_cache[key] +end + +local GlobalTypes = {} + +setmetatable(GlobalTypes, types_mt) + + +return GlobalTypes diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 68575056c3..ba03737a73 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -1,35 +1,140 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --Note: Currently only support for window shades with the PositionallyAware Feature --Note: No support for setting device into calibration mode, it must be done manually local capabilities = require "st.capabilities" -local log = require "log" +local im = require "st.matter.interaction_model" local clusters = require "st.matter.clusters" local MatterDriver = require "st.matter.driver" +local utils = require "st.utils" + +local battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" +} +local OP_STATUS_BITMAP = { + idle = 0x00, + opening = 0x01, + closing = 0x02 +} +local DEFAULT_PRESET_LEVEL = 50 -local DEFAULT_LEVEL = 0 -local PROFILE_MATCHED = "__profile_matched" -local IS_MOVING = "__is_moving" -local EVENT_STATE = "__event_state" +local TARGET_LIFT_PERCENT = { + TIMEOUT_DELAY_S = 120, + TIMEOUT = "__target_lift_percent_timeout", + STATE = "__target_lift_percent" +} -local WindowCoveringEventEnum = { - NO_EVENT = 0x00, - CURRENT_POSITION_EVENT = 0x01, - OPERATIONAL_STATE_EVENT = 0x02 +local TARGET_TILT_PERCENT = { + TIMEOUT_DELAY_S = 120, + TIMEOUT = "__target_tilt_percent_timeout", + STATE = "__target_tilt_percent" } +local TARGET_REACH_TOLERANCE = 0.5 + +--- Gets the current window shade status based on the lift and tilt positions. +---@param device any a Matter device object +---@return any status the current window shade status +local function get_idle_window_shade_status(device) + -- Update the window shade status according to the lift and tilt positions. + -- LIFT TILT Window Shade + -- 100 any Open + -- 1-99 any Partially Open + -- 0 1-100 Partially Open + -- 0 0 Closed + -- 0 nil Closed + -- nil 100 Open + -- nil 1-99 Partially Open + -- nil 0 Closed + -- Note that lift or tilt may be nil if either the window shade does not + -- support them or if they haven't been received from a device report yet. + + local lift_position = device:get_latest_state( + "main", + capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME + ) + local tilt_position = device:get_latest_state( + "main", + capabilities.windowShadeTiltLevel.ID, + capabilities.windowShadeTiltLevel.shadeTiltLevel.NAME + ) + local reverse = device.preferences.reverse + local windowShade = capabilities.windowShade.windowShade + local status_event = windowShade.unknown() + + if lift_position == nil then + if tilt_position == 0 then + status_event = reverse and windowShade.open() or windowShade.closed() + elseif tilt_position == 100 then + status_event = reverse and windowShade.closed() or windowShade.open() + else + status_event = windowShade.partially_open() + end + elseif lift_position == 100 then + status_event = reverse and windowShade.closed() or windowShade.open() + elseif lift_position > 0 then + status_event = windowShade.partially_open() + elseif lift_position == 0 then + if tilt_position == nil or tilt_position == 0 then + status_event = reverse and windowShade.open() or windowShade.closed() + elseif tilt_position > 0 then + status_event = windowShade.partially_open() + end + end + return status_event +end + +--- Clears the cached target percentage and cancels any associated timeout. +--- @param device any a Matter device object +--- @param op_fields table a table containing operational field constants used for caching target percentages and timeouts. +local function clear_cached_data(device, op_fields) + local current_timer = device:get_field(op_fields.TIMEOUT) + if current_timer then + device.thread:cancel_timer(current_timer) + end + device:set_field(op_fields.STATE, nil) + device:set_field(op_fields.TIMEOUT, nil) +end + +--- Tries to emit the idle window shade status if no target percentages are set. +--- @param device any a Matter device object +local function try_emit_idle_window_shade_status(device) + if not device:get_field(TARGET_LIFT_PERCENT.STATE) and + not device:get_field(TARGET_TILT_PERCENT.STATE) then + device:emit_event(get_idle_window_shade_status(device)) + end +end + +--- Caches the target percentage for a device and sets a timeout to clear it. +--- @param device any a Matter device object +--- @param data number the target percentage to cache +--- @param op_fields table a table containing operational field constants used for caching target percentages and timeouts. +local function cache_data_with_timeout(device, data, op_fields) + device:set_field(op_fields.STATE, data) + local previous_timer = device:get_field(op_fields.TIMEOUT) + if previous_timer then + device.thread:cancel_timer(previous_timer) + end + local new_timer = device.thread:call_with_delay(op_fields.TIMEOUT_DELAY_S, function() + device:set_field(op_fields.STATE, nil) + device:set_field(op_fields.TIMEOUT, nil) + try_emit_idle_window_shade_status(device) + end) + device:set_field(op_fields.TIMEOUT, new_timer) +end + +local function is_target_value_reached(current_value, target_value) + if (target_value and math.abs(current_value - target_value) <= TARGET_REACH_TOLERANCE) then + return true + end + return false +end + local function find_default_endpoint(device, cluster) local res = device.MATTER_DEFAULT_ENDPOINT local eps = device:get_endpoints(cluster) @@ -49,36 +154,65 @@ local function component_to_endpoint(device, component_name) return find_default_endpoint(device, clusters.WindowCovering.ID) end -local function match_profile(device) +local function match_profile(device, battery_supported) + local lift_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) + local tilt_eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) local profile_name = "window-covering" - local battery_eps = device:get_endpoints(clusters.PowerSource.ID, - {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #tilt_eps > 0 then + profile_name = profile_name .. "-tilt" + if #lift_eps == 0 then + profile_name = profile_name .. "-only" + end + end - if #battery_eps > 0 then - profile_name = "window-covering-battery" + if battery_supported == battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + elseif battery_supported == battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" end device:try_update_metadata({profile = profile_name}) - device:set_field(PROFILE_MATCHED, 1) end local function device_init(driver, device) - device:set_field(EVENT_STATE, WindowCoveringEventEnum.NO_EVENT) - device:set_field(IS_MOVING, false) - if not device:get_field(PROFILE_MATCHED) then - match_profile(device) - end + -- set unused fields to nil. These field settings can be removed after a single driver update. + device:set_field("__preset_level_key", nil) + device:set_field("__reverse_polarity", nil) + device:set_component_to_endpoint_fn(component_to_endpoint) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then + -- These should only ever be nil once (and at the same time) for already-installed devices + -- It can be removed after migration is complete + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}})) + local preset_position = device.preferences.presetPosition or DEFAULT_PRESET_LEVEL + device:emit_event(capabilities.windowShadePreset.position(preset_position, {visibility = {displayed = false}})) + end device:subscribe() end +local function do_configure(driver, device) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps > 0 then + local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(attribute_list_read) + else + match_profile(device, battery_support.NO_BATTERY) + end +end + local function info_changed(driver, device, event, args) if device.profile.id ~= args.old_st_store.profile.id then - -- Profile has changed, resubscribe device:subscribe() - else - -- Something else has changed info (SW update, reinterview, etc.), so - -- try updating profile as needed - match_profile(device) + elseif device.matter_version.software ~= args.old_st_store.matter_version.software then + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps > 0 then + local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(attribute_list_read) + else + match_profile(device, battery_support.NO_BATTERY) + end end end @@ -88,131 +222,175 @@ local function device_added(driver, device) ) end -local function device_removed(driver, device) log.info("device removed") end - -- capability handlers local function handle_preset(driver, device, cmd) + local preset_target_percent = device:get_latest_state( + "main", + capabilities.windowShadePreset.ID, + capabilities.windowShadePreset.position.NAME, + DEFAULT_PRESET_LEVEL + ) + cache_data_with_timeout(device, preset_target_percent, TARGET_LIFT_PERCENT) + local hundredths_lift_percent = (100 - preset_target_percent) * 100 local endpoint_id = device:component_to_endpoint(cmd.component) - local lift_value = 100 - device.preferences.presetPosition - local hundredths_lift_percent = lift_value * 100 - local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( - device, endpoint_id, hundredths_lift_percent - ) + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percent + )) +end - device:send(req) +local function handle_set_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:emit_event_for_endpoint(endpoint_id, capabilities.windowShadePreset.position(cmd.args.position)) end --- close covering local function handle_close(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) - device:send(req) + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_LIFT_PERCENT) + end + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_TILT_PERCENT) + end + device:send(device.preferences.reverse and + clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) or + clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + ) end --- open covering local function handle_open(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) - device:send(req) + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_LIFT_PERCENT) + end + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_TILT_PERCENT) + end + device:send(device.preferences.reverse and + clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) or + clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + ) end --- pause covering local function handle_pause(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id) - device:send(req) + device:send(clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id)) end --- move to shade level --- beteween 0-100 +-- move to shade level between 0-100 local function handle_shade_level(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local lift_percentage_value = 100 - cmd.args.shadeLevel - local hundredths_lift_percentage = lift_percentage_value * 100 - local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( - device, endpoint_id, hundredths_lift_percentage - ) - device:send(req) + local hundredths_lift_percentage = (100 - cmd.args.shadeLevel) * 100 + cache_data_with_timeout(device, cmd.args.shadeLevel, TARGET_LIFT_PERCENT) + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percentage + )) end --- current lift percentage, changed to 100ths percent -local function current_pos_handler(driver, device, ib, response) - local position = 0 - if ib.data.value ~= nil then - position = 100 - math.floor((ib.data.value / 100)) - device:emit_event_for_endpoint( - ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(position) - ) - end - if device:get_field(EVENT_STATE) == WindowCoveringEventEnum.OPERATIONAL_STATE_EVENT then - if not device:get_field(IS_MOVING) then - if position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.closed()) - elseif position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.open()) - elseif position > 0 and position < 100 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.partially_open()) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.unknown()) - end - end - device:set_field(EVENT_STATE, WindowCoveringEventEnum.NO_EVENT) - else - device:set_field(EVENT_STATE, WindowCoveringEventEnum.CURRENT_POSITION_EVENT) +local function handle_step_shade_level(driver, device, cmd) + local step = cmd.args.stepSize + local latest_lift_percentage = device:get_latest_state( + "main", + capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME, + 0 + ) + local target_lift_percent = device:get_field(TARGET_LIFT_PERCENT.STATE) or latest_lift_percentage + local updated_target_lift = utils.clamp_value(target_lift_percent + step, 0, 100) + cache_data_with_timeout(device, updated_target_lift, TARGET_LIFT_PERCENT) + driver:inject_capability_command(device, { + capability = capabilities.windowShadeLevel.ID, + component = cmd.component, + command = capabilities.windowShadeLevel.commands.setShadeLevel.NAME, + named_args = { shadeLevel = updated_target_lift } + }) +end + +-- move to shade tilt level between 0-100 +local function handle_shade_tilt_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local hundredths_tilt_percentage = (100 - cmd.args.level) * 100 + cache_data_with_timeout(device, cmd.args.level, TARGET_TILT_PERCENT) + device:send(clusters.WindowCovering.server.commands.GoToTiltPercentage( + device, endpoint_id, hundredths_tilt_percentage + )) +end + +-- attribute handlers +local function current_position_lift_percent_100ths_handler(driver, device, ib, response) + if not ib.data.value then return end + local lift_percent = 100 - math.floor(ib.data.value / 100) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(lift_percent)) + + if is_target_value_reached(lift_percent, device:get_field(TARGET_LIFT_PERCENT.STATE)) then + clear_cached_data(device, TARGET_LIFT_PERCENT) + try_emit_idle_window_shade_status(device) end end --- checks the current position of the shade -local function current_status_handler(driver, device, ib, response) - local attr = capabilities.windowShade.windowShade - local position = device:get_latest_state( - "main", capabilities.windowShadeLevel.ID, - capabilities.windowShadeLevel.shadeLevel.NAME - ) or DEFAULT_LEVEL - for _, rb in ipairs(response.info_blocks) do - if rb.info_block.attribute_id == clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID and - rb.info_block.cluster_id == clusters.WindowCovering.ID and - rb.info_block.data.value ~= nil then - position = 100 - math.floor((rb.info_block.data.value / 100)) - end +local function current_position_tilt_percent_100ths_handler(driver, device, ib, response) + if not ib.data.value then return end + local tilt_percent = 100 - math.floor(ib.data.value / 100) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeTiltLevel.shadeTiltLevel(tilt_percent)) + + if is_target_value_reached(tilt_percent, device:get_field(TARGET_TILT_PERCENT.STATE)) then + clear_cached_data(device, TARGET_TILT_PERCENT) + try_emit_idle_window_shade_status(device) end - local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL --Could use LIFT instead - if device:get_field(EVENT_STATE) == WindowCoveringEventEnum.CURRENT_POSITION_EVENT then - if state == 0 then -- not moving - if position == 100 then -- open - device:emit_event_for_endpoint(ib.endpoint_id, attr.open()) - elseif position == 0 then -- closed - device:emit_event_for_endpoint(ib.endpoint_id, attr.closed()) - else - device:emit_event_for_endpoint(ib.endpoint_id, attr.partially_open()) - end - elseif state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, attr.opening()) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, attr.closing()) +end + +local function target_position_lift_percent_100ths_handler(driver, device, ib, response) + if type(ib.data.value) == "number" then + local target_lift_percent = 100 - math.floor(ib.data.value / 100) + local latest_lift_percentage = device:get_latest_state( + "main", + capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME + ) + if latest_lift_percentage and is_target_value_reached(latest_lift_percentage, target_lift_percent) then + clear_cached_data(device, TARGET_LIFT_PERCENT) + try_emit_idle_window_shade_status(device) else - device:emit_event_for_endpoint(ib.endpoint_id, attr.unknown()) + cache_data_with_timeout(device, target_lift_percent, TARGET_LIFT_PERCENT) end - device:set_field(EVENT_STATE, WindowCoveringEventEnum.NO_EVENT) - else - if state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, attr.opening()) - device:set_field(IS_MOVING, true) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, attr.closing()) - device:set_field(IS_MOVING, true) + end +end + +local function target_position_tilt_percent_100ths_handler(driver, device, ib, response) + if type(ib.data.value) == "number" then + local target_tilt_percent = 100 - math.floor(ib.data.value / 100) + local latest_tilt_percent = device:get_latest_state( + "main", + capabilities.windowShadeTiltLevel.ID, + capabilities.windowShadeTiltLevel.shadeTiltLevel.NAME + ) + if latest_tilt_percent and is_target_value_reached(latest_tilt_percent, target_tilt_percent) then + clear_cached_data(device, TARGET_TILT_PERCENT) + try_emit_idle_window_shade_status(device) else - device:set_field(IS_MOVING, false) + cache_data_with_timeout(device, target_tilt_percent, TARGET_TILT_PERCENT) end - device:set_field(EVENT_STATE, WindowCoveringEventEnum.OPERATIONAL_STATE_EVENT) end end -local function level_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - --TODO should we invert this like we do for CurrentLiftPercentage100ths? - local level = math.floor((ib.data.value / 254.0 * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(level)) +-- checks the current position of the shade +local function operational_status_handler(driver, device, ib, response) + if not ib.data.value then return end + local global_op_status = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL + + local reverse = device.preferences.reverse + local windowShade = capabilities.windowShade.windowShade + local status_event + if global_op_status == OP_STATUS_BITMAP.idle then + try_emit_idle_window_shade_status(device) + elseif global_op_status == OP_STATUS_BITMAP.opening then + status_event = reverse and windowShade.closing() or windowShade.opening() + elseif global_op_status == OP_STATUS_BITMAP.closing then + status_event = reverse and windowShade.opening() or windowShade.closing() + else + status_event = windowShade.unknown() + end + if status_event then + device:emit_event_for_endpoint(ib.endpoint_id, status_event) end end @@ -222,21 +400,48 @@ local function battery_percent_remaining_attr_handler(driver, device, ib, respon end end +local function battery_charge_level_attr_handler(driver, device, ib, response) + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +local function power_source_attribute_list_handler(driver, device, ib, response) + for _, attr in ipairs(ib.data.elements or {}) do + -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) is present. + if attr.value == 0x0C then + match_profile(device, battery_support.BATTERY_PERCENTAGE) + return + elseif attr.value == 0x0E then + match_profile(device, battery_support.BATTERY_LEVEL) + return + end + end +end + local matter_driver_template = { - lifecycle_handlers = {init = device_init, removed = device_removed, added = device_added, infoChanged = info_changed}, + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed, + doConfigure = do_configure + }, matter_handlers = { attr = { - --TODO LevelControl may not be needed for certified devices since - -- certified should use CurrentPositionLiftPercent100ths attr - [clusters.LevelControl.ID] = { - [clusters.LevelControl.attributes.CurrentLevel.ID] = level_attr_handler, - }, [clusters.WindowCovering.ID] = { - --uses percent100ths more often - [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler, - [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_position_lift_percent_100ths_handler, + [clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths.ID] = current_position_tilt_percent_100ths_handler, + [clusters.WindowCovering.attributes.TargetPositionLiftPercent100ths.ID] = target_position_lift_percent_100ths_handler, + [clusters.WindowCovering.attributes.TargetPositionTiltPercent100ths.ID] = target_position_tilt_percent_100ths_handler, + [clusters.WindowCovering.attributes.OperationalStatus.ID] = operational_status_handler, }, [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.AttributeList.ID] = power_source_attribute_list_handler, + [clusters.PowerSource.attributes.BatChargeLevel.ID] = battery_charge_level_attr_handler, [clusters.PowerSource.attributes.BatPercentRemaining.ID] = battery_percent_remaining_attr_handler, } }, @@ -248,17 +453,23 @@ local matter_driver_template = { [capabilities.windowShadeLevel.ID] = { clusters.LevelControl.attributes.CurrentLevel, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.attributes.TargetPositionLiftPercent100ths, + }, + [capabilities.windowShadeTiltLevel.ID] = { + clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths, + clusters.WindowCovering.attributes.TargetPositionTiltPercent100ths, }, [capabilities.battery.ID] = { clusters.PowerSource.attributes.BatPercentRemaining - } + }, + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel, + }, }, capability_handlers = { - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = nil --TODO: define me! - }, [capabilities.windowShadePreset.ID] = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = handle_preset, + [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = handle_set_preset, }, [capabilities.windowShade.ID] = { [capabilities.windowShade.commands.close.NAME] = handle_close, @@ -268,14 +479,26 @@ local matter_driver_template = { [capabilities.windowShadeLevel.ID] = { [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = handle_shade_level, }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = handle_step_shade_level + }, + [capabilities.windowShadeTiltLevel.ID] = { + [capabilities.windowShadeTiltLevel.commands.setShadeTiltLevel.NAME] = handle_shade_tilt_level, + }, }, supported_capabilities = { capabilities.windowShadeLevel, + capabilities.windowShadeTiltLevel, capabilities.windowShade, capabilities.windowShadePreset, + capabilities.doorControl, + capabilities.level, capabilities.battery, + capabilities.batteryLevel, }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } local matter_driver = MatterDriver("matter-window-covering", matter_driver_template) -matter_driver:run() \ No newline at end of file +matter_driver:run() diff --git a/drivers/SmartThings/matter-window-covering/src/lazy_load_subdriver.lua b/drivers/SmartThings/matter-window-covering/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..a04740d267 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/lazy_load_subdriver.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + local MatterDriver = require "st.matter.driver" + local version = require "version" + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers.lua new file mode 100644 index 0000000000..3a833f39bc --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("sub_drivers.closure"), +} +return sub_drivers diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/can_handle.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/can_handle.lua new file mode 100644 index 0000000000..0ca3579b75 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CLOSURE_CONTROL_CLUSTER_ID = 0x0104 + +return function(opts, driver, device) + local embedded_cluster_utils = require "sub_drivers.closure.closure_utils.embedded_cluster_utils" + if #embedded_cluster_utils.get_endpoints(device, CLOSURE_CONTROL_CLUSTER_ID) > 0 then + return true, require("sub_drivers.closure") + end + return false +end diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..24faac339b --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/attribute_handlers.lua @@ -0,0 +1,114 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" + clusters.Global = require "embedded_clusters.Global" +end + +local fields = require "sub_drivers.closure.closure_utils.fields" +local closure_utils = require "sub_drivers.closure.closure_utils.utils" + +local ClosureAttrHandlers = {} + +function ClosureAttrHandlers.main_state_attr_handler(driver, device, ib, response) + if ib.data.value == nil then return end + closure_utils.set_closure_control_state(device, ib.endpoint_id, { main = ib.data.value }) + closure_utils.emit_closure_control_capability(device, ib.endpoint_id) +end + +function ClosureAttrHandlers.overall_current_state_attr_handler(driver, device, ib, response) + if not ib.data.elements then return end + clusters.ClosureControl.types.OverallCurrentStateStruct:augment_type(ib.data) + for _, v in pairs(ib.data.elements or {}) do + if v.field_id == 0 then + local current = v.value + closure_utils.set_closure_control_state(device, ib.endpoint_id, { current = current }) + closure_utils.emit_closure_control_capability(device, ib.endpoint_id) + break + end + end +end + +function ClosureAttrHandlers.overall_target_state_attr_handler(driver, device, ib, response) + if not ib.data.elements then return end + clusters.ClosureControl.types.OverallTargetStateStruct:augment_type(ib.data) + for _, v in pairs(ib.data.elements or {}) do + if v.field_id == 0 then + local target = v.value + closure_utils.set_closure_control_state(device, ib.endpoint_id, { target = target }) + closure_utils.emit_closure_control_capability(device, ib.endpoint_id) + break + end + end +end + +function ClosureAttrHandlers.closure_dimension_current_state_handler(driver, device, ib, response) + if not ib.data.elements then return end + clusters.ClosureDimension.types.DimensionStateStruct:augment_type(ib.data) + local pos_field = ib.data.elements.position + if not pos_field or pos_field.value == nil then return end + local level = math.floor(pos_field.value / 100) + if device:supports_capability_by_id(capabilities.doorControl.ID) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.level.level(level)) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(level)) + end +end + +function ClosureAttrHandlers.tag_list_handler(driver, device, ib, response) + if not ib.data.elements then return end + local tag_value + if version.api < 16 then + clusters.Global.types.SemanticTagStruct:augment_type(ib.data) + end + for _, v in ipairs(ib.data.elements) do + local tag = v.elements + if tag and tag.namespace_id and tag.namespace_id.value == 0x44 then + tag_value = tag.tag and tag.tag.value + break + end + end + + local closure_tag_map = { + [0] = fields.closure_tag_list.COVERING, + [1] = fields.closure_tag_list.WINDOW, + [2] = fields.closure_tag_list.BARRIER, + [3] = fields.closure_tag_list.CABINET, + [4] = fields.closure_tag_list.GATE, + [5] = fields.closure_tag_list.GARAGE_DOOR, + [6] = fields.closure_tag_list.DOOR, + } + + local closure_tag = fields.closure_tag_list.NA + if tag_value ~= nil and closure_tag_map[tag_value] ~= nil then + closure_tag = closure_tag_map[tag_value] + end + + device:set_field(fields.CLOSURE_TAG, closure_tag, {persist = true}) + closure_utils.match_profile(device) +end + +function ClosureAttrHandlers.power_source_attribute_list_handler(driver, device, ib, response) + for _, attr in ipairs(ib.data.elements) do + if attr.value == 0x0C then -- BatPercentRemaining + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist = true}) + closure_utils.match_profile(device) + return + elseif attr.value == 0x0E then -- BatChargeLevel + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist = true}) + closure_utils.match_profile(device) + return + end + end + -- No battery attribute found + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + closure_utils.match_profile(device) +end + +return ClosureAttrHandlers diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/capability_handlers.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/capability_handlers.lua new file mode 100644 index 0000000000..4084d0fae1 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/capability_handlers.lua @@ -0,0 +1,71 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" +end + +local fields = require "sub_drivers.closure.closure_utils.fields" +local closure_utils = require "sub_drivers.closure.closure_utils.utils" + +local ClosureCapabilityHandlers = {} + +-- close covering (or door/gate) +function ClosureCapabilityHandlers.handle_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local reverse = device:get_field(fields.REVERSE_POLARITY) + local req = reverse and + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) or + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) + device:send(req) +end + +-- open covering (or door/gate) +function ClosureCapabilityHandlers.handle_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local reverse = device:get_field(fields.REVERSE_POLARITY) + local req = reverse and + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) or + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) + device:send(req) +end + +-- pause / stop covering +function ClosureCapabilityHandlers.handle_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ClosureControl.server.commands.Stop(device, endpoint_id)) +end + +-- move to shade level 0-100 for covering Closure devices +function ClosureCapabilityHandlers.handle_shade_level(driver, device, cmd) + local dim_eps = closure_utils.get_closure_dimension_eps(device) + local endpoint_id = #dim_eps == 1 and dim_eps[1] or device:component_to_endpoint(cmd.component) + if endpoint_id then + device:send(clusters.ClosureDimension.server.commands.SetTarget( + device, endpoint_id, cmd.args.shadeLevel * 100 + )) + end +end + +-- move to level 0-100 for door/gate/garage-door Closure devices +function ClosureCapabilityHandlers.handle_level(driver, device, cmd) + local dim_eps = closure_utils.get_closure_dimension_eps(device) + local endpoint_id = #dim_eps == 1 and dim_eps[1] or device:component_to_endpoint(cmd.component) + device:send(clusters.ClosureDimension.server.commands.SetTarget( + device, endpoint_id, cmd.args.level * 100 + )) +end + +return ClosureCapabilityHandlers diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/embedded_cluster_utils.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/embedded_cluster_utils.lua new file mode 100644 index 0000000000..022f86987a --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/embedded_cluster_utils.lua @@ -0,0 +1,62 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local utils = require "st.utils" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +local embedded_cluster_utils = {} + +local embedded_clusters_16 = { + [clusters.Descriptor.ID] = clusters.Descriptor +} + +local embedded_clusters_20 = { + [clusters.ClosureControl.ID] = clusters.ClosureControl, + [clusters.ClosureDimension.ID] = clusters.ClosureDimension +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if (version.api < 16 and embedded_clusters_16[cluster_id] ~= nil) + or (version.api < 20 and embedded_clusters_20[cluster_id] ~= nil) then + local embedded_cluster = embedded_clusters_16[cluster_id] or embedded_clusters_20[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end + end + + return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/fields.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/fields.lua new file mode 100644 index 0000000000..11cd32b8bd --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/fields.lua @@ -0,0 +1,37 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local fields = {} + +fields.REVERSE_POLARITY = "__reverse_polarity" +fields.PRESET_LEVEL_KEY = "__preset_level_key" +fields.DEFAULT_PRESET_LEVEL = 50 + +fields.battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE", +} + +fields.CLOSURE_CONTROL_STATE_CACHE = "__closure_control_state_cache" +fields.CLOSURE_BATTERY_SUPPORT = "__closure_battery_support" +fields.CLOSURE_TAG = "__closure_tag" + +fields.closure_tag_list = { + NA = "N/A", + COVERING = "COVERING", + WINDOW = "WINDOW", + BARRIER = "BARRIER", + CABINET = "CABINET", + GATE = "GATE", + GARAGE_DOOR = "GARAGE_DOOR", + DOOR = "DOOR", +} + +-- The maximum number of supported panels for a closure device. Note that this is an +-- arbitrary number and should be raised if needed by a closure device with more panels. +fields.MAX_CLOSURE_PANELS = 4 + +fields.SUBSCRIBED_ATTRIBUTES_KEY = "__subscribed_attributes" + +return fields diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/utils.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/utils.lua new file mode 100644 index 0000000000..8597d029f5 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/utils.lua @@ -0,0 +1,319 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" +local log = require "log" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" +end + +local fields = require "sub_drivers.closure.closure_utils.fields" +local embedded_cluster_utils = require "sub_drivers.closure.closure_utils.embedded_cluster_utils" + +local utils = {} + +function utils.find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = embedded_cluster_utils.get_endpoints(device, cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then -- 0 is the Matter RootNode endpoint + return v + end + end + device.log.warn(string.format( + "Did not find default endpoint, will use endpoint %d instead", + device.MATTER_DEFAULT_ENDPOINT + )) + return res +end + +function utils.get_closure_dimension_eps(device) + local eps = embedded_cluster_utils.get_endpoints(device, clusters.ClosureDimension.ID) or {} + table.sort(eps) + local result = {} + for _, ep in ipairs(eps) do + if ep ~= 0 then + table.insert(result, ep) + if #result >= fields.MAX_CLOSURE_PANELS then break end + end + end + return result +end + +--- Single-panel devices always map to "main"; +--- multi-panel devices map to "windowShade1"..."windowShade4" or "door1"..."door4". +function utils.endpoint_to_component(device, ep_id) + local dim_eps = utils.get_closure_dimension_eps(device) + if #dim_eps > 1 then + local is_door_type = device:supports_capability_by_id(capabilities.doorControl.ID) + local prefix = is_door_type and "door" or "windowShade" + for i, ep in ipairs(dim_eps) do + if ep == ep_id then + return prefix .. i + end + end + end + return "main" +end + +function utils.component_to_endpoint(device, component_name) + local dim_eps = utils.get_closure_dimension_eps(device) + if #dim_eps > 1 then + local comp_num = tonumber(component_name:match("(%d+)$")) + if comp_num and dim_eps[comp_num] then + return dim_eps[comp_num] + end + end + return utils.find_default_endpoint(device, clusters.ClosureControl.ID) +end + +function utils.match_profile(device) + if not device:get_field(fields.CLOSURE_TAG) or not device:get_field(fields.CLOSURE_BATTERY_SUPPORT) then + log.warn("Closure tag or battery support not set yet, cannot match profile") + return + end + + local tag = device:get_field(fields.CLOSURE_TAG) + local profile_name + local is_door_type = true + + if tag == fields.closure_tag_list.GATE then + profile_name = "gate" + elseif tag == fields.closure_tag_list.GARAGE_DOOR then + profile_name = "garage-door" + elseif tag == fields.closure_tag_list.DOOR then + profile_name = "door" + else + -- COVERING, WINDOW, BARRIER, CABINET, NA -> generic covering profile + profile_name = "covering" + is_door_type = false + end + + local optional_caps = {} + local main_component_capabilities = {} + + local closure_battery = device:get_field(fields.CLOSURE_BATTERY_SUPPORT) + if closure_battery == fields.battery_support.BATTERY_PERCENTAGE then + table.insert(main_component_capabilities, capabilities.battery.ID) + elseif closure_battery == fields.battery_support.BATTERY_LEVEL then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + end + + -- ClosureDimension capabilities: windowShadeLevel (covering) or level (door types) + local dim_eps = utils.get_closure_dimension_eps(device) + if #dim_eps > 0 then + local dim_cap = is_door_type and capabilities.level.ID or capabilities.windowShadeLevel.ID + if #dim_eps == 1 then + -- Single ClosureDimension: enable the capability on the main component. + table.insert(main_component_capabilities, dim_cap) + else + -- Multiple ClosureDimensions: one optional component+capability per panel. + local prefix = is_door_type and "door" or "windowShade" + for i = 1, math.min(#dim_eps, fields.MAX_CLOSURE_PANELS) do + table.insert(optional_caps, {prefix .. i, {dim_cap}}) + end + end + end + + if #main_component_capabilities > 0 then + table.insert(optional_caps, 1, {"main", main_component_capabilities}) + end + + device:try_update_metadata({ + profile = profile_name, + optional_component_capabilities = #optional_caps > 0 and optional_caps or nil, + }) +end + +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function utils.deep_equals(a, b, opts, seen) + if a == b then return true end + if type(a) ~= type(b) then return false end + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end + + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + for k, v in pairs(a) do + if not utils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return utils.deep_equals(mt_a, mt_b, opts, seen) +end + +function utils.set_closure_control_state(device, endpoint_id, field) + local cache = device:get_field(fields.CLOSURE_CONTROL_STATE_CACHE) or {} + if not cache[endpoint_id] then cache[endpoint_id] = {} end + for k, v in pairs(field) do + cache[endpoint_id][k] = v + end + device:set_field(fields.CLOSURE_CONTROL_STATE_CACHE, cache) +end + +--- Emits the appropriate windowShade / doorControl capability event from the +--- cached MainState, OverallCurrentState.position, and OverallTargetState.position. +function utils.emit_closure_control_capability(device, endpoint_id) + local cache = device:get_field(fields.CLOSURE_CONTROL_STATE_CACHE) + if not cache then return end + local closure_control_state = cache[endpoint_id] or {} + local reverse = device:get_field(fields.REVERSE_POLARITY) + + local main = closure_control_state.main + local current = closure_control_state.current + local target = closure_control_state.target + + local closure_capability = capabilities.windowShade.windowShade + if device:supports_capability_by_id(capabilities.doorControl.ID) then + closure_capability = capabilities.doorControl.door + end + + if main == clusters.ClosureControl.types.MainStateEnum.MOVING then + if target == clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED then + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.opening() or closure_capability.closing() + ) + elseif target == clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN then + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.closing() or closure_capability.opening() + ) + end + elseif main == clusters.ClosureControl.types.MainStateEnum.STOPPED or main == nil then + if current == nil then return end + if current == clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED then + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.open() or closure_capability.closed() + ) + elseif current == clusters.ClosureControl.types.CurrentPositionEnum.FULLY_OPENED or + device:supports_capability_by_id(capabilities.doorControl.ID) then + -- doorControl does not support partially_open; treat any non-fully-closed as open + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.closed() or closure_capability.open() + ) + else + device:emit_event_for_endpoint(endpoint_id, closure_capability.partially_open()) + end + end +end + +--- helper for the switch subscribe override, which adds to a subscribed request for a checked device +--- +--- @param checked_device any a Matter device object, either a parent or child device, so not necessarily the same as device +--- @param subscribe_request table a subscribe request that will be appended to as needed for the device +--- @param capabilities_seen table a list of capabilities that have already been checked by previously handled devices +--- @param attributes_seen table a list of attributes that have already been checked +--- @param subscribed_attributes table key-value pairs mapping capability ids to subscribed attributes +function utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, subscribed_attributes) + for _, component in pairs(checked_device.st_store.profile.components) do + for _, capability in pairs(component.capabilities) do + if not capabilities_seen[capability.id] then + for _, attr in ipairs(subscribed_attributes[capability.id] or {}) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + end + capabilities_seen[capability.id] = true -- only loop through any capability once + end + end + end +end + +function utils.subscribe(device) + local closure_subscribed_attributes = { + [capabilities.windowShade.ID] = { + clusters.ClosureControl.attributes.MainState, + clusters.ClosureControl.attributes.OverallCurrentState, + clusters.ClosureControl.attributes.OverallTargetState, + }, + [capabilities.doorControl.ID] = { + clusters.ClosureControl.attributes.MainState, + clusters.ClosureControl.attributes.OverallCurrentState, + clusters.ClosureControl.attributes.OverallTargetState, + }, + [capabilities.windowShadeLevel.ID] = { + clusters.ClosureDimension.attributes.CurrentState, + }, + [capabilities.level.ID] = { + clusters.ClosureDimension.attributes.CurrentState, + }, + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining, + }, + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel, + }, + } + + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + local capabilities_seen, attributes_seen = {}, {} + local additional_attributes = {} + + -- The refresh capability command handler in the lua libs uses this key to determine which attributes to read. + device:set_field(fields.SUBSCRIBED_ATTRIBUTES_KEY, attributes_seen) + + -- If the type of battery support has not yet been determined, add the PowerSource AttributeList to the list of + -- subscribed attributes in order to determine which if any battery capability should be used. + if device:get_field(fields.CLOSURE_BATTERY_SUPPORT) == nil then + local ib = im.InteractionInfoBlock(nil, clusters.PowerSource.ID, clusters.PowerSource.attributes.AttributeList.ID) + subscribe_request:with_info_block(ib) + end + + if device:get_field(fields.CLOSURE_TAG) == nil then + table.insert(additional_attributes, clusters.Descriptor.attributes.TagList) + end + + utils.populate_subscribe_request_for_device( + device, subscribe_request, capabilities_seen, attributes_seen, closure_subscribed_attributes + ) + + for _, attr in ipairs(additional_attributes) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + end + + if #subscribe_request.info_blocks > 0 then + device:send(subscribe_request) + end +end + +return utils diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/init.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/init.lua new file mode 100644 index 0000000000..397167d3df --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/init.lua @@ -0,0 +1,147 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local log = require "log" +local version = require "version" +local embedded_cluster_utils = require "sub_drivers.closure.closure_utils.embedded_cluster_utils" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" + clusters.Global = require "embedded_clusters.Global" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + + +local fields = require "sub_drivers.closure.closure_utils.fields" +local closure_utils = require "sub_drivers.closure.closure_utils.utils" +local attribute_handlers = require "sub_drivers.closure.closure_handlers.attribute_handlers" +local capability_handlers = require "sub_drivers.closure.closure_handlers.capability_handlers" + +-- --------------------------------------------------------------------------- +-- Lifecycle handlers +-- --------------------------------------------------------------------------- + +local ClosureLifecycleHandlers = {} + +function ClosureLifecycleHandlers.device_init(driver, device) + device:set_component_to_endpoint_fn(closure_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(closure_utils.endpoint_to_component) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, + capabilities.windowShadePreset.position.NAME) == nil then + device:emit_event(capabilities.windowShadePreset.supportedCommands( + {"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}} + )) + local preset_position = device:get_field(fields.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + fields.DEFAULT_PRESET_LEVEL + device:emit_event(capabilities.windowShadePreset.position( + preset_position, {visibility = {displayed = false}} + )) + device:set_field(fields.PRESET_LEVEL_KEY, preset_position, {persist = true}) + end + device:extend_device("subscribe", closure_utils.subscribe) + device:subscribe() +end + +function ClosureLifecycleHandlers.device_added(driver, device) + if device:supports_capability_by_id(capabilities.windowShade.ID) then + device:emit_event( + capabilities.windowShade.supportedWindowShadeCommands( + {"open", "close", "pause"}, {visibility = {displayed = false}} + ) + ) + end + device:set_field(fields.REVERSE_POLARITY, false, {persist = true}) +end + +function ClosureLifecycleHandlers.do_configure(driver, device) + if #embedded_cluster_utils.get_endpoints(device, clusters.Descriptor.ID) == 0 then + log.warn( + "Descriptor cluster not implemented on ClosureControl endpoint, " .. + "cannot read TagList to determine closure type" + ) + device:set_field(fields.CLOSURE_TAG, fields.closure_tag_list.NA, {persist = true}) + end + + local battery_feature_eps = device:get_endpoints( + clusters.PowerSource.ID, + {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + ) + if #battery_feature_eps == 0 then + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + closure_utils.match_profile(device) + end +end + +function ClosureLifecycleHandlers.info_changed(driver, device, event, args) + if not closure_utils.deep_equals( + device.profile, args.old_st_store.profile, {ignore_functions = true} + ) then + device:subscribe() + elseif args.old_st_store.preferences.reverse ~= device.preferences.reverse then + if device.preferences.reverse then + device:set_field(fields.REVERSE_POLARITY, true, {persist = true}) + else + device:set_field(fields.REVERSE_POLARITY, false, {persist = true}) + end + end +end + +-- --------------------------------------------------------------------------- +-- Subdriver template +-- --------------------------------------------------------------------------- + +local closure_handler = { + NAME = "Closure Handler", + lifecycle_handlers = { + init = ClosureLifecycleHandlers.device_init, + added = ClosureLifecycleHandlers.device_added, + doConfigure = ClosureLifecycleHandlers.do_configure, + infoChanged = ClosureLifecycleHandlers.info_changed, + }, + matter_handlers = { + attr = { + [clusters.ClosureControl.ID] = { + [clusters.ClosureControl.attributes.MainState.ID] = attribute_handlers.main_state_attr_handler, + [clusters.ClosureControl.attributes.OverallCurrentState.ID] = attribute_handlers.overall_current_state_attr_handler, + [clusters.ClosureControl.attributes.OverallTargetState.ID] = attribute_handlers.overall_target_state_attr_handler, + }, + [clusters.ClosureDimension.ID] = { + [clusters.ClosureDimension.attributes.CurrentState.ID] = attribute_handlers.closure_dimension_current_state_handler, + }, + [clusters.Descriptor.ID] = { + [clusters.Descriptor.attributes.TagList.ID] = attribute_handlers.tag_list_handler, + }, + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler, + }, + }, + }, + capability_handlers = { + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.close.NAME] = capability_handlers.handle_close, + [capabilities.windowShade.commands.open.NAME] = capability_handlers.handle_open, + [capabilities.windowShade.commands.pause.NAME] = capability_handlers.handle_pause, + }, + [capabilities.doorControl.ID] = { + [capabilities.doorControl.commands.open.NAME] = capability_handlers.handle_open, + [capabilities.doorControl.commands.close.NAME] = capability_handlers.handle_close, + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = capability_handlers.handle_shade_level, + }, + [capabilities.level.ID] = { + [capabilities.level.commands.setLevel.NAME] = capability_handlers.handle_level, + }, + }, + can_handle = require("sub_drivers.closure.can_handle"), +} + +return closure_handler diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_closure.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_closure.lua new file mode 100644 index 0000000000..e39cb428c8 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_closure.lua @@ -0,0 +1,790 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" + clusters.Global = require "embedded_clusters.Global" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +-- --------------------------------------------------------------------------- +-- Mock device: Covering type (windowShade / windowShadeLevel) +-- --------------------------------------------------------------------------- + +local mock_device = test.mock_device.build_test_matter_device( + { + label = "Matter Closure", + profile = t_utils.get_profile_definition("covering.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.ClosureControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + device_types = { + {device_type_id = 0x0230, device_type_revision = 1} -- Closure + } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + }, + } +) + +-- --------------------------------------------------------------------------- +-- Mock device: Door type (doorControl / level) +-- --------------------------------------------------------------------------- + +local mock_door_device = test.mock_device.build_test_matter_device( + { + label = "Matter Door", + profile = t_utils.get_profile_definition("door.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.ClosureControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + device_types = { + {device_type_id = 0x0230, device_type_revision = 1} -- Closure + } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + }, + } +) + +local CLUSTER_SUBSCRIBE_LIST = { + clusters.ClosureControl.attributes.MainState, + clusters.ClosureControl.attributes.OverallCurrentState, + clusters.ClosureControl.attributes.OverallTargetState, +} + +-- additional clusters that will be subscribed to initially but not after the profile is matched. +local ADDITIONAL_SUBSCRIBE_LIST = { + clusters.PowerSource.attributes.AttributeList, + clusters.Descriptor.attributes.TagList, +} + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + for _, clus in ipairs(ADDITIONAL_SUBSCRIBE_LIST) do + subscribe_request:merge(clus:subscribe(mock_device)) + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +local function update_profile() + test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + test.socket.matter:__queue_receive({mock_device.id, clusters.Descriptor.attributes.TagList:build_test_report_data( + mock_device, 10, {clusters.Global.types.SemanticTagStruct({mfg_code = 0x00, namespace_id = 0x44, tag = 0x00, name = "Covering"}) } + )}) + mock_device:expect_metadata_update({ + profile = "covering", + optional_component_capabilities = { + {"main", {"battery"}}, + {"windowShade1", {"windowShadeLevel"}}, + {"windowShade2", {"windowShadeLevel"}}, + } + }) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition("covering.yml", { + enabled_optional_capabilities = { + {"main", {"battery"}}, + {"windowShade1", {"windowShadeLevel"}}, + {"windowShade2", {"windowShadeLevel"}}, + } + }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + subscribe_request:merge(clusters.PowerSource.server.attributes.BatPercentRemaining:subscribe(mock_device)) + subscribe_request:merge(clusters.ClosureDimension.attributes.CurrentState:subscribe(mock_device)) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) +end + +local function test_init_door() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_door_device) + test.socket.device_lifecycle:__queue_receive({ mock_door_device.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_door_device.id, "init" }) + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_door_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_door_device)) end + end + for _, clus in ipairs(ADDITIONAL_SUBSCRIBE_LIST) do + subscribe_request:merge(clus:subscribe(mock_door_device)) + end + test.socket.matter:__expect_send({mock_door_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_door_device.id, "doConfigure" }) + mock_door_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function update_profile_door() + test.socket.matter:__queue_receive({mock_door_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_door_device, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + test.socket.matter:__queue_receive({mock_door_device.id, clusters.Descriptor.attributes.TagList:build_test_report_data( + mock_door_device, 10, {clusters.Global.types.SemanticTagStruct({mfg_code = 0x00, namespace_id = 0x44, tag = 0x06, name = "Door"})} + )}) + mock_door_device:expect_metadata_update({ + profile = "door", + optional_component_capabilities = { + {"main", {"battery"}}, + {"door1", {"level"}}, + {"door2", {"level"}}, + } + }) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition("door.yml", { + enabled_optional_capabilities = { + {"main", {"battery"}}, + {"door1", {"level"}}, + {"door2", {"level"}}, + } + }) + test.socket.device_lifecycle:__queue_receive(mock_door_device:generate_info_changed({ profile = updated_device_profile })) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_door_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_door_device)) end + end + subscribe_request:merge(clusters.PowerSource.server.attributes.BatPercentRemaining:subscribe(mock_door_device)) + subscribe_request:merge(clusters.ClosureDimension.attributes.CurrentState:subscribe(mock_door_device)) + test.socket.matter:__expect_send({mock_door_device.id, subscribe_request}) +end + +test.register_coroutine_test( + "windowShade closed following MainState and OverallTargetState update", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + end +) + +test.register_coroutine_test( + "windowShade opening following MainState and OverallTargetState update", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + end +) + +test.register_coroutine_test( + "windowShade closed following OverallCurrentState FULLY_CLOSED", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + end +) + +test.register_coroutine_test( + "windowShade open following OverallCurrentState FULLY_OPENED", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_OPENED, + latch = true, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + end +) + +test.register_coroutine_test( + "windowShade partially_open following OverallCurrentState PARTIALLY_OPENED", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.PARTIALLY_OPENED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + end +) + +test.register_coroutine_test( + "windowShade state transitions from closing to closed", function() + update_profile() + test.wait_for_events() + -- device starts moving toward closed + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.wait_for_events() + -- device stops and reports fully closed + -- MainState STOPPED with no current position cached yet. no capability event emitted + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.STOPPED), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + end +) + +test.register_coroutine_test( + "windowShade close command sends ClosureControl MoveTo FULLY_CLOSED", function() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShade", component = "main", command = "close", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) + }) + end +) + +test.register_coroutine_test( + "windowShade open command sends ClosureControl MoveTo FULLY_OPEN", function() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) + }) + end +) + +test.register_coroutine_test( + "windowShade pause command sends ClosureControl Stop", function() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShade", component = "main", command = "pause", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureControl.server.commands.Stop(mock_device, 10) + }) + end +) + +test.register_coroutine_test( + "Battery percentage reported correctly for closure device", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(mock_device, 10, 150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) + ) + end +) + +test.register_coroutine_test( + "setShadeLevel on windowShade1 sends SetTarget to endpoint 11", function() + update_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "windowShade1", command = "setShadeLevel", args = {75}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureDimension.server.commands.SetTarget(mock_device, 11, 75 * 100) + }) + end +) + +test.register_coroutine_test( + "setShadeLevel on windowShade2 sends SetTarget to endpoint 12", function() + update_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "windowShade2", command = "setShadeLevel", args = {40}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureDimension.server.commands.SetTarget(mock_device, 12, 40 * 100) + }) + end +) + +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 11 emits shadeLevel on windowShade1", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 6000, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade1", capabilities.windowShadeLevel.shadeLevel(60)) + ) + end +) + +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 12 emits shadeLevel on windowShade2", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 12, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 2500, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade2", capabilities.windowShadeLevel.shadeLevel(25)) + ) + end +) + +test.register_coroutine_test( + "ClosureDimension CurrentState with closed position emits shadeLevel 0", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 0, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade1", capabilities.windowShadeLevel.shadeLevel(0)) + ) + end +) + +test.register_coroutine_test( + "ClosureDimension CurrentState with full-open position emits shadeLevel 100", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 10000, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade1", capabilities.windowShadeLevel.shadeLevel(100)) + ) + end +) + +-- --------------------------------------------------------------------------- +-- Door / garage-door / gate type tests +-- --------------------------------------------------------------------------- + +test.register_coroutine_test( + "doorControl closed following MainState and OverallTargetState update", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_door_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.closing()) + ) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "doorControl opening following MainState and OverallTargetState update", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_door_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.opening()) + ) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "doorControl closed following OverallCurrentState FULLY_CLOSED", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.closed()) + ) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "doorControl open following OverallCurrentState FULLY_OPENED", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_OPENED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.open()) + ) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "doorControl open following OverallCurrentState PARTIALLY_OPENED (doorControl has no partially_open)", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.PARTIALLY_OPENED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + -- doorControl has no partially_open state; any non-fully-closed position maps to open + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.open()) + ) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "doorControl close command sends ClosureControl MoveTo FULLY_CLOSED", function() + test.socket.capability:__queue_receive({ + mock_door_device.id, + {capability = "doorControl", component = "main", command = "close", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_door_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_door_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) + }) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "doorControl open command sends ClosureControl MoveTo FULLY_OPEN", function() + test.socket.capability:__queue_receive({ + mock_door_device.id, + {capability = "doorControl", component = "main", command = "open", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_door_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_door_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) + }) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 11 emits level on door1 for door device", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_door_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 7500, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("door1", capabilities.level.level(75)) + ) + end, + {test_init = test_init_door} +) + +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 12 emits level on door2 for door device", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_door_device, 12, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 3000, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("door2", capabilities.level.level(30)) + ) + end, + {test_init = test_init_door} +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua index 2ed20e159b..8376e3a6d4 100644 --- a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua @@ -1,29 +1,27 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" - +local uint32 = require "st.matter.data_types.Uint32" local clusters = require "st.matter.clusters" + +local version = require "version" +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + local WindowCovering = clusters.WindowCovering +test.disable_startup_messages() + local mock_device = test.mock_device.build_test_matter_device( { - profile = t_utils.get_profile_definition("window-covering-battery.yml"), + profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, - preferences = { presetPosition = 30 }, + matter_version = {software = 1}, endpoints = { { endpoint_id = 2, @@ -36,44 +34,12 @@ local mock_device = test.mock_device.build_test_matter_device( }, { endpoint_id = 10, - clusters = { -- list the clusters - { - cluster_id = clusters.WindowCovering.ID, - cluster_type = "SERVER", - cluster_revision = 1, - feature_map = 0, - }, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} - }, - }, - }, - } -) - -local mock_device_switch_to_battery = test.mock_device.build_test_matter_device( - { - profile = t_utils.get_profile_definition("window-covering.yml"), - manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, - preferences = { presetPosition = 30 }, - endpoints = { - { - endpoint_id = 2, clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode - } - }, - { - endpoint_id = 10, - clusters = { -- list the clusters { cluster_id = clusters.WindowCovering.ID, cluster_type = "SERVER", cluster_revision = 1, - feature_map = 0, + feature_map = 3, }, {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} @@ -87,7 +53,6 @@ local mock_device_mains_powered = test.mock_device.build_test_matter_device( { profile = t_utils.get_profile_definition("window-covering.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, - preferences = { presetPosition = 30 }, endpoints = { { endpoint_id = 2, @@ -100,12 +65,12 @@ local mock_device_mains_powered = test.mock_device.build_test_matter_device( }, { endpoint_id = 10, - clusters = { -- list the clusters + clusters = { { cluster_id = clusters.WindowCovering.ID, cluster_type = "SERVER", cluster_revision = 1, - feature_map = 0, + feature_map = 1, }, {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0001} @@ -118,6 +83,9 @@ local mock_device_mains_powered = test.mock_device.build_test_matter_device( local CLUSTER_SUBSCRIBE_LIST = { clusters.LevelControl.server.attributes.CurrentLevel, WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, + WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, + WindowCovering.server.attributes.TargetPositionLiftPercent100ths, + WindowCovering.server.attributes.TargetPositionTiltPercent100ths, WindowCovering.server.attributes.OperationalStatus, clusters.PowerSource.server.attributes.BatPercentRemaining } @@ -126,51 +94,86 @@ local CLUSTER_SUBSCRIBE_LIST_NO_BATTERY = { clusters.LevelControl.server.attributes.CurrentLevel, WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, WindowCovering.server.attributes.OperationalStatus, + WindowCovering.server.attributes.TargetPositionLiftPercent100ths, } +local function set_preset(device) + test.socket.capability:__expect_send( + device:generate_test_message( + "main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + device:generate_test_message( + "main", capabilities.windowShadePreset.position(50, {visibility = {displayed = false}}) + ) + ) +end + local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + set_preset(mock_device) local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) - mock_device:expect_metadata_update({ profile = "window-covering-battery" }) -end -local function test_init_switch_to_battery() - local subscribe_request = CLUSTER_SUBSCRIBE_LIST_NO_BATTERY[1]:subscribe(mock_device_switch_to_battery) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_NO_BATTERY) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_switch_to_battery)) end - end - test.socket.matter:__expect_send({mock_device_switch_to_battery.id, subscribe_request}) - test.mock_device.add_test_device(mock_device_switch_to_battery) - mock_device_switch_to_battery:expect_metadata_update({ profile = "window-covering-battery" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) end local function test_init_mains_powered() + test.mock_device.add_test_device(mock_device_mains_powered) + test.socket.device_lifecycle:__queue_receive({ mock_device_mains_powered.id, "added" }) + test.socket.capability:__expect_send( + mock_device_mains_powered:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mains_powered.id, "init" }) + set_preset(mock_device_mains_powered) local subscribe_request = CLUSTER_SUBSCRIBE_LIST_NO_BATTERY[1]:subscribe(mock_device_mains_powered) for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_NO_BATTERY) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_mains_powered)) end end test.socket.matter:__expect_send({mock_device_mains_powered.id, subscribe_request}) - test.mock_device.add_test_device(mock_device_mains_powered) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mains_powered.id, "doConfigure" }) mock_device_mains_powered:expect_metadata_update({ profile = "window-covering" }) + mock_device_mains_powered:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.set_test_init_function(test_init) test.register_coroutine_test( - "WindowCovering OperationalStatus state closed", function() - test.socket.capability:__set_channel_ordering("relaxed") + "Idle WindowCovering OperationalStatus state triggers 'open' capability emission if lift position is 100", function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 10000 + mock_device, 10, 0 ), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) test.socket.matter:__queue_receive( { mock_device.id, @@ -179,58 +182,95 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closed() + "main", capabilities.windowShade.windowShade.open() ) ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Idle WindowCovering OperationalStatus state triggers 'closed' capability emission if lift position is 0", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(0) ) ) - end -) - -test.register_coroutine_test( - "WindowCovering OperationalStatus state closed before position 0", function() - test.socket.capability:__set_channel_ordering("relaxed") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if lift position is 50", function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 10000 + mock_device, 10, 5000 ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closed() + "main", capabilities.windowShadeLevel.shadeLevel(50) ) ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(0) + "main", capabilities.windowShade.windowShade.partially_open() ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus state open", function() + "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if tilt position is between 1-100", function() test.socket.capability:__set_channel_ordering("relaxed") test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 0 + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 15) *100) ), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) + ) + ) test.socket.matter:__queue_receive( { mock_device.id, @@ -239,193 +279,287 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(100) + "main", capabilities.windowShade.windowShade.partially_open() ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus state open before position event", function() - test.socket.capability:__set_channel_ordering("relaxed") + "Unknown WindowCovering OperationalStatus state triggers 'unknown' capability emission", function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 99), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.unknown() + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Opening WindowCovering OperationalStatus state triggers 'closing' capability emission", function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 0 - ), + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 2), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() + "main", capabilities.windowShade.windowShade.closing() ) ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Closing WindowCovering OperationalStatus state triggers 'opening' capability emission", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 1), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(100) + "main", capabilities.windowShade.windowShade.opening() ) ) - end + end, + { + min_api_version = 17 + } ) + test.register_coroutine_test( - "WindowCovering OperationalStatus partially open", function() - test.socket.capability:__set_channel_ordering("relaxed") + "WindowCovering CurrentPositionLiftPercent100ths triggers appropriate capability emission", function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) + mock_device, 10, 10000 ), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShadeLevel.shadeLevel(100) ) ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 25) *100) + ), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(25) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus partially open before position event", function() - test.socket.capability:__set_channel_ordering("relaxed") + "WindowCovering CurrentPositionTiltPercent100ths triggers appropriate capability emission", function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) ) ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 15) *100) + ), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) ) ) - end -) - -test.register_coroutine_test("WindowCovering OperationalStatus opening", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ), - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 1), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.opening() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 65) *100) + ), + } ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) + ) ) - ) -end) + end, + { + min_api_version = 17 + } +) -test.register_coroutine_test("WindowCovering OperationalStatus closing", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ), - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 2), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closing() +test.register_coroutine_test( + "WindowCovering TargetPositionLiftPercent100ths sets field appropriately", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.TargetPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) + test.wait_for_events() + assert(mock_device:get_field("__target_lift_percent") == 50) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering TargetPositionTiltPercent100ths sets field appropriately", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.TargetPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } ) - ) -end) + test.wait_for_events() + assert(mock_device:get_field("__target_tilt_percent") == 50) + end, + { + min_api_version = 17 + } +) -test.register_coroutine_test("WindowCovering OperationalStatus unknown", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ), - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 3), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.unknown() +test.register_coroutine_test( + "WindowCovering TargetPositionLiftPercent100ths triggers operational status update when appropriate", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 65) *100) + ), + } ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(65) + ) ) - ) -end) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end +) + +test.register_coroutine_test( + "WindowCovering TargetPositionTiltPercent100ths triggers operational status update when appropriate", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 65) *100) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end, + { + min_api_version = 17 + } +) test.register_coroutine_test( - "WindowShade open cmd handler", function() + "Open capability command triggers appropriate Matter command", function() test.socket.capability:__queue_receive( { mock_device.id, @@ -435,12 +569,14 @@ test.register_coroutine_test( test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 10)} ) - test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowShade close cmd handler", function() + "Close capability command triggers appropriate Matter command", function() test.socket.capability:__queue_receive( { mock_device.id, @@ -450,12 +586,14 @@ test.register_coroutine_test( test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.DownOrClose(mock_device, 10)} ) - test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowShade pause cmd handler", function() + "Pause capability command triggers appropriate Matter command", function() test.socket.capability:__queue_receive( { mock_device.id, @@ -465,8 +603,144 @@ test.register_coroutine_test( test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.StopMotion(mock_device, 10)} ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cached target lift/tilt position timeouts trigger one operational status update", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 50 * 100 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + } + ) + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 10)} + ) + test.wait_for_events() - end + test.mock_time.advance_time(120) + + test.socket.capability:__expect_send({ + mock_device.id, { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } }, + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cached target lift timeout triggers one operational status update if tilt target and idle state are first reached appropriately", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + } + ) + -- two timers are made when open is called + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 10)} + ) + + -- ensure target state is cached before queueing responses + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + + -- advance time to trigger the single remaining timer + test.wait_for_events() + test.mock_time.advance_time(120) + test.socket.capability:__expect_send({ + mock_device.id, { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } }, + } + ) + + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -495,10 +769,13 @@ test.register_coroutine_test( end test.socket.matter:__expect_send({mock_device.id, read_request}) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) -test.register_coroutine_test("WindowShade setShadeLevel cmd handler", function() +test.register_coroutine_test("SetShadeLevel capability command triggers appropriate Matter command", function() test.socket.capability:__queue_receive( { mock_device.id, @@ -508,21 +785,27 @@ test.register_coroutine_test("WindowShade setShadeLevel cmd handler", function() test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 8000)} ) -end) +end, +{ + min_api_version = 17 +} +) -test.register_coroutine_test("LevelControl CurrentLevel handler", function() - test.socket.matter:__queue_receive( +test.register_coroutine_test("SetShadeTiltLevel capability command triggers appropriate Matter command", function() + test.socket.capability:__queue_receive( { mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 100), + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 60 }}, } ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(math.floor((100 / 254.0 * 100) + .5)) - ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 4000)} ) -end) +end, +{ + min_api_version = 17 +} +) --test battery test.register_coroutine_test( @@ -540,62 +823,436 @@ test.register_coroutine_test( "main", capabilities.battery.battery(math.floor(150/2.0+0.5)) ) ) - end + end, + { + min_api_version = 17 + } ) -test.register_coroutine_test("OperationalStatus report contains current position report", function() - test.socket.capability:__set_channel_ordering("relaxed") - local report = WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ) - table.insert(report.info_blocks, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0).info_blocks[1]) - test.socket.matter:__queue_receive({ mock_device.id, report}) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) +test.register_coroutine_test( + "Handle preset commands", + function() + local PRESET_LEVEL = 30 + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = { PRESET_LEVEL }}, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadePreset.position(PRESET_LEVEL) + ) ) - ) -end) - -test.register_coroutine_test("Handle windowcoveringPreset", function() - test.socket.capability:__queue_receive( - { + test.socket.capability:__queue_receive({ mock_device.id, {capability = "windowShadePreset", component = "main", command = "presetPosition", args = {}}, - } - ) - test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 7000)} - ) -end) - -test.register_coroutine_test( - "Test profile change on init for window-covering to window-covering-battery", - function() + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, (100 - PRESET_LEVEL) * 100)} + ) end, - { test_init = test_init_switch_to_battery } + { + min_api_version = 17 + } ) test.register_coroutine_test( - "Test mains powered device does not switch to battery profile", + "Test profile change to window-covering-battery when battery percent remaining attribute (attribute ID 12) is available", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(12)}) + } + ) + mock_device:expect_metadata_update({ profile = "window-covering-tilt-battery" }) end, - { test_init = test_init_mains_powered } + { + min_api_version = 17 + } ) test.register_coroutine_test( - "InfoChanged event checks for new profile match if device has changed (i.e. through reinterview or SW update)", + "Test that profile is not changed to window-covering-battery when battery percent remaining attribute (attribute ID 12) is not available", function() - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) - mock_device:expect_metadata_update({ - profile = "window-covering-battery", + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(10)}) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test mains powered device does not switch to battery profile", + function() + end, + { + test_init = test_init_mains_powered, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "InfoChanged event updates new profile if sw updated has occurred", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = {software = 2} })) + local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() + test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) + test.wait_for_events() + test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(0x0C)})}) + mock_device:expect_metadata_update({profile = "window-covering-tilt-battery"}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to reverse polarity after being set to true and that the shade lift operates as expected when opening and closing", function() + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 100 * 100 + ) }) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 1500)} + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 0)} + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to reverse polarity after being set to true and that the shade tilt operates as expected when opening and closing", function() + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 100 * 100 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 15 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 8500)} + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 0 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 10000)} + ) + end, + { + min_api_version = 17 + } +) + +-- stepShadeLevel tests + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step up", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 9000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step down", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 7000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - continuous step with target tracking", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 7000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(30) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 6000)} + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 5000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - target reached clears target marker", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 4000)} + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 4000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(60) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - step up to maximum (100)", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 500 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(95) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 0)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - step down to minimum (0)", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 9500 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(5) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 10000)} + ) + end, + { + min_api_version = 19 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/profiles/4-button-remote.yml b/drivers/SmartThings/philips-hue/profiles/4-button-remote.yml new file mode 100644 index 0000000000..e5beb3872e --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/4-button-remote.yml @@ -0,0 +1,30 @@ +name: 4-button-remote +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/philips-hue/profiles/contact-sensor.yml b/drivers/SmartThings/philips-hue/profiles/contact-sensor.yml new file mode 100644 index 0000000000..2cc339f10b --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/contact-sensor.yml @@ -0,0 +1,14 @@ +name: contact-sensor +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: tamperAlert + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/philips-hue/profiles/legacy-color.yml b/drivers/SmartThings/philips-hue/profiles/legacy-color.yml index 9d5a467d86..2d906dab24 100644 --- a/drivers/SmartThings/philips-hue/profiles/legacy-color.yml +++ b/drivers/SmartThings/philips-hue/profiles/legacy-color.yml @@ -6,10 +6,8 @@ components: version: 1 - id: switchLevel version: 1 - config: - values: - - key: "level.value" - range: [ 1, 100 ] + - id: statelessSwitchLevelStep + version: 1 - id: colorControl version: 1 - id: samsungim.hueSyncMode @@ -18,3 +16,6 @@ components: version: 1 categories: - name: Light +metadata: + mnmn: SmartThingsEdge + vid: philips-legacy-color diff --git a/drivers/SmartThings/philips-hue/profiles/motion-sensor.yml b/drivers/SmartThings/philips-hue/profiles/motion-sensor.yml new file mode 100644 index 0000000000..c8067e0e89 --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/motion-sensor.yml @@ -0,0 +1,16 @@ +name: motion-sensor +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/SmartThings/philips-hue/profiles/plug.yml b/drivers/SmartThings/philips-hue/profiles/plug.yml new file mode 100644 index 0000000000..e5901cff1b --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/plug.yml @@ -0,0 +1,12 @@ +name: plug +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: samsungim.hueSyncMode + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/philips-hue/profiles/single-button.yml b/drivers/SmartThings/philips-hue/profiles/single-button.yml new file mode 100644 index 0000000000..49003127c4 --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/single-button.yml @@ -0,0 +1,12 @@ +name: single-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/philips-hue/profiles/two-button.yml b/drivers/SmartThings/philips-hue/profiles/two-button.yml new file mode 100644 index 0000000000..f5fb34ff54 --- /dev/null +++ b/drivers/SmartThings/philips-hue/profiles/two-button.yml @@ -0,0 +1,18 @@ +name: two-button +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml b/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml index ce3534bc14..354b8bbc2e 100644 --- a/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml +++ b/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml @@ -6,19 +6,18 @@ components: version: 1 - id: switchLevel version: 1 - config: - values: - - key: "level.value" - range: [ 1, 100 ] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 - config: - values: - - key: "colorTemperature.value" - range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: samsungim.hueSyncMode version: 1 - id: refresh version: 1 categories: - name: Light +metadata: + mnmn: SmartThingsEdge + vid: philips-white-ambiance diff --git a/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml b/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml index d5b13ba2bc..7fe8797be2 100644 --- a/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml +++ b/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml @@ -6,21 +6,20 @@ components: version: 1 - id: switchLevel version: 1 - config: - values: - - key: "level.value" - range: [ 1, 100 ] + - id: statelessSwitchLevelStep + version: 1 - id: colorControl version: 1 - id: colorTemperature version: 1 - config: - values: - - key: "colorTemperature.value" - range: [ 2000, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: samsungim.hueSyncMode version: 1 - id: refresh version: 1 categories: - name: Light +metadata: + mnmn: SmartThingsEdge + vid: philips-white-and-color-ambiance diff --git a/drivers/SmartThings/philips-hue/profiles/white.yml b/drivers/SmartThings/philips-hue/profiles/white.yml index 6a72bd8288..18312c48e2 100644 --- a/drivers/SmartThings/philips-hue/profiles/white.yml +++ b/drivers/SmartThings/philips-hue/profiles/white.yml @@ -6,13 +6,14 @@ components: version: 1 - id: switchLevel version: 1 - config: - values: - - key: "level.value" - range: [ 1, 100 ] + - id: statelessSwitchLevelStep + version: 1 - id: samsungim.hueSyncMode version: 1 - id: refresh version: 1 categories: - name: Light +metadata: + mnmn: SmartThingsEdge + vid: philips-white diff --git a/drivers/SmartThings/philips-hue/run_specs.sh b/drivers/SmartThings/philips-hue/run_specs.sh new file mode 100755 index 0000000000..3e00bdb3ef --- /dev/null +++ b/drivers/SmartThings/philips-hue/run_specs.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +export LUA_PATH="./test/spec/?.lua;./test/spec/?/init.lua;./src/?.lua;./src/?/init.lua;$LUA_LIBS/?.lua;$LUA_LIBS/?/init.lua;;" + +~/.luarocks/bin/busted -C 'src' -m "$LUA_PATH" -k -v './test/spec' diff --git a/drivers/SmartThings/philips-hue/src/consts.lua b/drivers/SmartThings/philips-hue/src/consts.lua new file mode 100644 index 0000000000..1f59a90de7 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/consts.lua @@ -0,0 +1,11 @@ +local Consts = {} + +--- minimum possible colortemp value for Hue bulbs. +Consts.DEFAULT_MIN_MIREK = 153 +Consts.DEFAULT_MAX_MIREK = 500 +Consts.MIN_TEMP_KELVIN_COLOR_AMBIANCE = 2000 +Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE = 2200 +Consts.MAX_TEMP_KELVIN = 6500 +Consts.KELVIN_STEP_SIZE = 100 + +return Consts diff --git a/drivers/SmartThings/philips-hue/src/disco.lua b/drivers/SmartThings/philips-hue/src/disco.lua deleted file mode 100644 index a433e77c6e..0000000000 --- a/drivers/SmartThings/philips-hue/src/disco.lua +++ /dev/null @@ -1,465 +0,0 @@ -local log = require "log" -local socket = require "cosock.socket" - -local mdns = require "st.mdns" -local net_utils = require "st.net_utils" -local st_utils = require "st.utils" - -local Fields = require "fields" -local HueApi = require "hue.api" -local utils = require "utils" - -local SERVICE_TYPE = "_hue._tcp" -local DOMAIN = "local" - --- This `api_keys` table is an in-memory fall-back table. It gets overwritten --- with a reference to a driver datastore table before the Driver's `run` loop --- can get spun up in `init.lua`. -local HueDiscovery = { - api_keys = {}, - disco_api_instances = {}, - light_state_disco_cache = {}, - ServiceType = SERVICE_TYPE, - Domain = DOMAIN, - discovery_active = false -} - -local supported_resource_types = { - light = true -} - --- "forward declarations" -local discovered_bridge_callback, discovered_device_callback, is_device_service_supported, -process_discovered_light - ----comment ----@param driver HueDriver ----@param _ table ----@param should_continue function -function HueDiscovery.discover(driver, _, should_continue) - if HueDiscovery.discovery_active then - log.info("Hue discovery already in progress, ignoring new discovery request") - return - end - - log.info_with({ hub_logs = true }, "Starting Hue discovery") - HueDiscovery.discovery_active = true - - while should_continue() do - local known_identifier_to_device_map = {} - local computed_mac_addresses = {} - for _, device in ipairs(driver:get_devices()) do - -- the bridge won't have a parent assigned key so we give that boolean short circuit preference - local dni = device.parent_assigned_child_key or device.device_network_id - known_identifier_to_device_map[dni] = device - local ipv4 = device:get_field(Fields.IPV4); - if ipv4 then - computed_mac_addresses[ipv4] = dni - end - end - - HueDiscovery.do_mdns_scan(driver) - HueDiscovery.search_for_bridges(driver, computed_mac_addresses, function(hue_driver, bridge_ip, bridge_id) - discovered_bridge_callback(hue_driver, bridge_ip, bridge_id, known_identifier_to_device_map) - end) - socket.sleep(1.0) - end - HueDiscovery.discovery_active = false - log.info_with({ hub_logs = true }, "Ending Hue discovery") -end - ----comment ----@param driver HueDriver ----@param computed_mac_addresses table ----@param callback fun(driver: HueDriver, ip: string, id: string) -function HueDiscovery.search_for_bridges(driver, computed_mac_addresses, callback) - local scanned_bridges = driver.datastore.bridge_netinfo or {} - for bridge_id, bridge_info in pairs(scanned_bridges) do - if type(callback) == "function" and bridge_info ~= nil then - callback(driver, bridge_info.ip, bridge_id) - else - log.warn( - "Argument passed in `callback` position for " - .. "`HueDiscovery.search_for_bridges` is not a function" - ) - end - end -end - -function HueDiscovery.scan_bridge_and_update_devices(driver, bridge_id) - if driver.ignored_bridges[bridge_id] then return end - - local known_identifier_to_device_map = {} - for _, device in ipairs(driver:get_devices()) do - -- the bridge won't have a parent assigned key so we give that boolean short circuit preference - local dni = device.parent_assigned_child_key or device.device_network_id - known_identifier_to_device_map[dni] = device - end - - local known_bridge_device = known_identifier_to_device_map[bridge_id] - if known_bridge_device and known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) then - HueDiscovery.api_keys[bridge_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) - end - - HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, HueDiscovery.disco_api_instances[bridge_id], - function(hue_driver, svc_info, device_info) - discovered_device_callback(hue_driver, bridge_id, svc_info, device_info, known_identifier_to_device_map) - end, - "[Discovery: " .. - (known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge") .. " bridge re-scan]", - true - ) -end - ----@param driver HueDriver ----@param bridge_ip string ----@param bridge_id string ----@param known_identifier_to_device_map table -discovered_bridge_callback = function(driver, bridge_ip, bridge_id, known_identifier_to_device_map) - if driver.ignored_bridges[bridge_id] then return end - - local known_bridge_device = known_identifier_to_device_map[bridge_id] - if known_bridge_device and known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) then - HueDiscovery.api_keys[bridge_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) - end - - if known_bridge_device ~= nil - and driver.joined_bridges[bridge_id] - and HueDiscovery.api_keys[bridge_id] - and known_bridge_device:get_field(Fields._INIT) - then - log.info_with({ hub_logs = true }, string.format("Scanning bridge %s for devices...", bridge_id)) - - HueDiscovery.disco_api_instances[bridge_id] = HueDiscovery.disco_api_instances[bridge_id] - or HueApi.new_bridge_manager( - "https://" .. bridge_ip, - HueDiscovery.api_keys[bridge_id], - utils.labeled_socket_builder((known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge")) - ) - - HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, HueDiscovery.disco_api_instances[bridge_id], - function(hue_driver, svc_info, device_info) - discovered_device_callback(hue_driver, bridge_id, svc_info, device_info, known_identifier_to_device_map) - end, - "[Discovery: " .. - (known_bridge_device.label or bridge_id or known_bridge_device.id or "unknown bridge") .. " bridge scan]" - ) - return - end - - if not HueDiscovery.api_keys[bridge_id] then - local socket_builder = utils.labeled_socket_builder(bridge_id) - local api_key_response, err, _ = HueApi.request_api_key(bridge_ip, socket_builder) - - if err ~= nil or not api_key_response then - log.warn(string.format( - "Error while trying to request Bridge API Key for %s: %s", - bridge_id, - err - ) - ) - return - end - - for _, item in ipairs(api_key_response) do - if item.error ~= nil then - log.warn(string.format("Error payload in bridge %s API key response: %s", bridge_id, item.error.description)) - elseif item.success and item.success.username then - log.info(string.format("API key received for Hue Bridge %s", bridge_id)) - - local api_key = item.success.username - local bridge_base_url = "https://" .. bridge_ip - local api_instance = HueApi.new_bridge_manager(bridge_base_url, api_key, socket_builder) - - HueDiscovery.api_keys[bridge_id] = api_key - HueDiscovery.disco_api_instances[bridge_id] = api_instance - end - end - end - - if HueDiscovery.api_keys[bridge_id] and not driver.joined_bridges[bridge_id] then - local bridge_info = driver.datastore.bridge_netinfo[bridge_id] - - if not bridge_info then - log.debug(string.format("Bridge info for %s not yet available", bridge_id)) - return - end - - if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then - log.warn(string.format("Found bridge %s that does not support CLIP v2 API, ignoring", bridge_info.name)) - driver.ignored_bridges[bridge_id] = true - return - end - - driver.joined_bridges[bridge_id] = true - - if not known_bridge_device then - local create_device_msg = { - type = "LAN", - device_network_id = bridge_id, - label = (bridge_info.name or "Philips Hue Bridge"), - profile = "hue-bridge", - manufacturer = "Signify Netherlands B.V.", - model = bridge_info.modelid or "BSB002", - vendor_provided_label = (bridge_info.name or "Philips Hue Bridge"), - } - - driver:try_create_device(create_device_msg) - end - end -end - ----@param driver HueDriver ----@param bridge_id string ----@param api_instance PhilipsHueApi ----@param callback fun(driver: HueDriver, svc_info: table, device_data: table) -function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_id, api_instance, callback, log_prefix, do_delete) - local prefix = "" - if type(log_prefix) == "string" and #log_prefix > 0 then prefix = log_prefix .. " " end - - local devices, err, _ = api_instance:get_devices() - if err ~= nil or not devices then - log.error_with({ hub_logs = true }, - prefix .. "Error querying bridge for devices: " .. (err or "unexpected nil in error position")) - return - end - - if devices.errors and #devices.errors > 0 then - log.error_with({ hub_logs = true }, prefix .. "Errors found in API response:") - for idx, err in ipairs(devices.errors) do - log.error_with({ hub_logs = true }, st_utils.stringify_table(err, "Error number " .. idx, true)) - end - return - end - - local device_is_joined_to_bridge = {} - for _, device_data in ipairs(devices.data or {}) do - for _, svc_info in ipairs(device_data.services or {}) do - if is_device_service_supported(svc_info) then - device_is_joined_to_bridge[device_data.id] = true - log.info_with({ hub_logs = true }, string.format( - prefix .. - "Processing supported svc [rid: %s | type: %s] for Hue device [v2_id: %s | v1_id: %s], with Hue provided name: %s", - svc_info.rid, svc_info.rtype, device_data.id, device_data.id_v1, device_data.metadata.name - )) - if type(callback) == "function" then - callback(driver, svc_info, device_data) - else - log.warn( - prefix .. "Argument passed in `callback` position for " - .. "`HueDiscovery.search_bridge_for_supported_devices` is not a function" - ) - end - end - end - end - - if do_delete then - for _, device in ipairs(driver:get_devices()) do ---@cast device HueDevice - -- We're only interested in processing child/non-bridge devices here. - if utils.is_bridge(driver, device) then goto continue end - local not_known_to_bridge = device_is_joined_to_bridge[device:get_field(Fields.HUE_DEVICE_ID) or ""] - local parent_device_id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) or "" - local parent_bridge_device = driver:get_device_info(parent_device_id) - local is_child_of_bridge = parent_bridge_device and (parent_bridge_device:get_field(Fields.BRIDGE_ID) == bridge_id) - if parent_bridge_device and is_child_of_bridge and not not_known_to_bridge and device.id then - device.log.info(string.format("Device is no longer joined to Hue Bridge %q, deleting", parent_bridge_device.label)) - driver:do_hue_light_delete(device) - end - ::continue:: - end - end -end - ----@param driver HueDriver ----@param bridge_id string ----@param svc_info table ----@param device_info table ----@param known_identifier_to_device_map table -discovered_device_callback = function(driver, bridge_id, svc_info, device_info, known_identifier_to_device_map) - local v1_dni = bridge_id .. "/" .. (device_info.id_v1 or "UNKNOWN"):gsub("/lights/", "") - local edge_dni = svc_info.rid or "" - if known_identifier_to_device_map[v1_dni] or known_identifier_to_device_map[edge_dni] then return end - if svc_info.rtype == "light" then - process_discovered_light(driver, bridge_id, svc_info.rid, device_info, known_identifier_to_device_map) - end -end - ----@param driver HueDriver ----@param bridge_id string ----@param resource_id string ----@param device_info table ----@param known_identifier_to_device_map table -process_discovered_light = function(driver, bridge_id, resource_id, device_info, known_identifier_to_device_map) - local api_instance = HueDiscovery.disco_api_instances[bridge_id] - if not api_instance then - log.warn("No API instance for bridge_id ", bridge_id) - return - end - - local light_resource, err, _ = api_instance:get_light_by_id(resource_id) - if err ~= nil or not light_resource then - log.error_with({ hub_logs = true }, - string.format("Error getting light info for %s: %s", device_info.product_data.product_name, - (err or "unexpected nil in error position"))) - return - end - - if light_resource.errors and #light_resource.errors > 0 then - log.error_with({ hub_logs = true }, "Errors found in API response:") - for idx, err in ipairs(light_resource.errors) do - log.error_with({ hub_logs = true }, st_utils.stringify_table(err, "Error " .. idx, true)) - end - return - end - - for _, light in ipairs(light_resource.data or {}) do - local profile_ref - - if light.color then - if light.color_temperature then - profile_ref = "white-and-color-ambiance" - else - profile_ref = "legacy-color" - end - elseif light.color_temperature then - profile_ref = "white-ambiance" -- all color temp products support `white` (dimming) - elseif light.dimming then - profile_ref = "white" -- `white` refers to dimmable and includes filament bulbs - else - log.warn( - string.format( - "Light resource [%s] does not seem to be A White/White-Ambiance/White-Color-Ambiance device, currently unsupported" - , - resource_id - ) - ) - goto continue - end - - local bridge_device = known_identifier_to_device_map[bridge_id] - - local create_device_msg = { - type = "EDGE_CHILD", - label = light.metadata.name, - vendor_provided_label = device_info.product_data.product_name, - profile = profile_ref, - manufacturer = device_info.product_data.manufacturer_name, - model = device_info.product_data.model_id, - parent_device_id = bridge_device.id, - parent_assigned_child_key = light.id, - } - - HueDiscovery.light_state_disco_cache[light.id] = { - hue_provided_name = light.metadata.name, - id = light.id, - on = light.on, - color = light.color, - dimming = light.dimming, - color_temperature = light.color_temperature, - mode = light.mode, - parent_device_id = bridge_device.id, - hue_device_id = light.owner.rid, - hue_device_data = device_info - } - - driver:try_create_device(create_device_msg) - -- rate limit ourself. - socket.sleep(0.1) - ::continue:: - end -end - -is_device_service_supported = function(svc_info) - return supported_resource_types[svc_info.rtype or ""] -end - -function HueDiscovery.do_mdns_scan(driver) - local bridge_netinfo = driver.datastore.bridge_netinfo - local mdns_responses, err = mdns.discover(HueDiscovery.ServiceType, HueDiscovery.Domain) - - if err ~= nil then - log.error_with({ hub_logs = true }, "Error during service discovery: ", err) - return - end - - if not (mdns_responses and mdns_responses.found and #mdns_responses.found > 0) then - log.warn("No mdns responses for Hue service this attempt, continuing...") - return - end - - for _, info in ipairs(mdns_responses.found) do - if not net_utils.validate_ipv4_string(info.host_info.address) then -- we only care about the ipV4 types here. - log.trace("Invalid IPv4 address: " .. info.host_info.address) - goto continue - end - - if info.service_info.service_type ~= HueDiscovery.ServiceType then -- response for a different service type. Shouldn't happen. - log.warn("Unexpected service type response: " .. info.service_info.service_type) - goto continue - end - - if info.service_info.domain ~= HueDiscovery.Domain then -- response for a different domain. Shouldn't happen. - log.warn("Unexpected domain response: " .. info.service_info.domain) - goto continue - end - - -- Hue *typically* formats the BridgeID as the uppercase MAC address, minus separators. - -- However, it can be user overriden, set by a user, and may not be unique, so we want - -- to stick to that format but make sure it's actually the mac address. Instead of pulling - -- the bridge id out of the bridge info, we'll extract the MAC address and apply the above mentioned - -- formatting rules. We also prefer the MAC because it makes for a good Device Network ID. - local ip_addr = info.host_info.address; - local bridge_info, rest_err, _ = HueApi.get_bridge_info( - ip_addr, - utils.labeled_socket_builder("[mDNS Scan: " .. ip_addr .. " Bridge Info Request]") - ) - if rest_err ~= nil or not bridge_info then - log.error_with({ hub_logs = true }, - string.format( - "Error querying bridge info for discovered IP address %s: %s", - ip_addr, - (rest_err or "unexpected nil in error position") - ) - ) - return - end - -- '-' and ':' or '::' are the accepted separators used for MAC address segments, so - -- we strip thoes out and make the string uppercase - if not bridge_info.mac then - log.warn_with({ hub_logs = true }, - string.format( - "No MAC address in returned Bridge Info for IP %s.", ip_addr - ) - ) - return - end - - bridge_info.ip = ip_addr - local bridge_id = bridge_info.mac:gsub("-", ""):gsub(":", ""):upper() - - -- sanitize userdata nulls from JSON decode - for k, v in pairs(bridge_info) do - if type(v) == "userdata" then - bridge_info[k] = nil - end - end - - local update_needed = false - if not utils.deep_table_eq((bridge_netinfo[bridge_id] or {}), bridge_info) then - bridge_netinfo[bridge_id] = bridge_info - update_needed = true - end - - if driver.joined_bridges[bridge_id] and not driver.ignored_bridges[bridge_id] then - local bridge_device = driver:get_device_by_dni(bridge_id, true) - update_needed = update_needed or (bridge_device and (bridge_device:get_field(Fields.IPV4) ~= bridge_info.ip)) - if update_needed then - driver:update_bridge_netinfo(bridge_id, bridge_info) - end - end - ::continue:: - end -end - -return HueDiscovery diff --git a/drivers/SmartThings/philips-hue/src/disco/button.lua b/drivers/SmartThings/philips-hue/src/disco/button.lua new file mode 100644 index 0000000000..4fcb40ed84 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/disco/button.lua @@ -0,0 +1,173 @@ +local log = require "log" +local socket = require "cosock".socket +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local HueDeviceTypes = require "hue_device_types" + +---@class DiscoveredButtonHandler: DiscoveredChildDeviceHandler +local M = {} + +-- TODO This should be generalizable to all "sensors", including buttons. +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_info HueDeviceInfo +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +local function _do_update(driver, api_instance, device_service_info, bridge_network_id, cache) + log.debug("------------ _do_update") + local rid_by_rtype = {} + local button_services = {} + local num_buttons = 0 + + for _, svc in ipairs(device_service_info.services) do + if svc.rtype == HueDeviceTypes.BUTTON then + num_buttons = num_buttons + 1 + table.insert(button_services, svc.rid) + else + rid_by_rtype[svc.rtype] = svc.rid + end + end + + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + local button_remote_description = { + hue_provided_name = device_service_info.metadata.name, + parent_device_id = bridge_device.id, + hue_device_id = device_service_info.id, + hue_device_data = device_service_info, + num_buttons = num_buttons, + sensor_list = { power_id = HueDeviceTypes.DEVICE_POWER } + } + + for _, button_rid in ipairs(button_services) do + local button_repr, err = api_instance:get_button_by_id(button_rid) + if err or not button_repr then + log.error("Error getting button representation: " .. tostring(err or "unknown error")) + else + local control_id = button_repr.data[1].metadata.control_id + local button_key = string.format("button%s", control_id) + local button_id_key = string.format("%s_id", button_key) + button_remote_description[button_key] = button_repr.data[1].button + button_remote_description[button_id_key] = button_repr.data[1].id + + if control_id == 1 then + button_remote_description.id = button_repr.data[1].id + end + + button_remote_description.sensor_list[button_id_key] = HueDeviceTypes.BUTTON + end + end + + local battery, battery_err = api_instance:get_device_power_by_id(rid_by_rtype[HueDeviceTypes.DEVICE_POWER]) + if battery_err then return nil, battery_err end + + if battery and battery.data and battery.data[1] then + button_remote_description.power_id = battery.data[1].id + button_remote_description.power_state = battery.data[1].power_state + end + + if type(cache) == "table" and button_remote_description and button_remote_description.id then + cache[button_remote_description.id] = button_remote_description + if device_service_info.id_v1 then + cache[device_service_info.id_v1] = button_remote_description + end + end + + return button_remote_description +end + +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_id string +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +function M.update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id, cache) + log.debug("----------- Calling REST API") + local device_service_info, err = api_instance:get_device_by_id(device_service_id) + if err or not (device_service_info and device_service_info.data) then + log.error("Couldn't get device info for button, error: " .. st_utils.stringify_table(err)) + return + end + + return _do_update(driver, api_instance, device_service_info.data[1], bridge_network_id, cache) +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services table +---@param device_service_info HueDeviceInfo +---@param device_state_disco_cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +function M.handle_discovered_device( + driver, bridge_network_id, api_instance, + primary_services, device_service_info, + device_state_disco_cache, st_metadata_callback +) + local button_description, err = _do_update( + driver, api_instance, device_service_info, bridge_network_id, device_state_disco_cache + ) + if err then + log.error("Error updating contact button initial state: " .. st_utils.stringify_table(err)) + return + end + + if type(st_metadata_callback) == "function" then + if not (button_description and HueDeviceTypes.supports_button_configuration(button_description)) then + button_description = button_description or {num_buttons = "unknown"} + log.error( + string.format( + "Driver does not currently support remotes with %s buttons, cannot create device", button_description.num_buttons + ) + ) + return + end + + local button_profile_ref + -- For Philips Hue Smart Button or single switch In-Wall Switch module which contains only 1 button + if button_description.num_buttons == 1 then + button_profile_ref = "single-button" + -- For double switch In-Wall Switch module + elseif button_description.num_buttons == 2 then + button_profile_ref = "two-button" + -- For Philips Hue Dimmer Remote and Tap Dial, which contains 4 buttons + elseif button_description.num_buttons == 4 then + button_profile_ref = "4-button-remote" + else + log.error( + string.format( + "Do not currently have a profile for device %s with %s buttons, skipping device create", + device_service_info.metadata.name, + button_description.num_buttons + ) + ) + return + end + + local bridge_device = driver:get_device_by_dni(bridge_network_id) or {} + local st_metadata = { + type = "EDGE_CHILD", + label = device_service_info.metadata.name, + vendor_provided_label = device_service_info.product_data.product_name, + profile = button_profile_ref, + manufacturer = device_service_info.product_data.manufacturer_name, + model = device_service_info.product_data.model_id, + parent_device_id = bridge_device.id, + parent_assigned_child_key = string.format("%s:%s", HueDeviceTypes.BUTTON, button_description.id) + } + + log.debug(st_utils.stringify_table(st_metadata, "button create", true)) + + st_metadata_callback(driver, st_metadata) + -- rate limit ourself. + socket.sleep(0.1) + end +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/disco/contact.lua b/drivers/SmartThings/philips-hue/src/disco/contact.lua new file mode 100644 index 0000000000..217e16d0b9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/disco/contact.lua @@ -0,0 +1,141 @@ +local log = require "log" +local socket = require "cosock".socket +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local HueDeviceTypes = require "hue_device_types" + +---@class DiscoveredContactSensorHandler: DiscoveredChildDeviceHandler +local M = {} + +-- TODO This should be generalizable to all "sensors", including buttons. +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_info HueDeviceInfo +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +local function _do_update(driver, api_instance, device_service_info, bridge_network_id, cache) + log.debug("------------ _do_update") + local rid_by_rtype = {} + for _, svc in ipairs(device_service_info.services) do + rid_by_rtype[svc.rtype] = svc.rid + end + + local contact, contact_err = api_instance:get_contact_by_id(rid_by_rtype[HueDeviceTypes.CONTACT]) + if contact_err then return nil, contact_err end + + local tamper, tamper_err = api_instance:get_tamper_by_id(rid_by_rtype[HueDeviceTypes.TAMPER]) + if tamper_err then return nil, tamper_err end + + local battery, battery_err = api_instance:get_device_power_by_id(rid_by_rtype[HueDeviceTypes.DEVICE_POWER]) + if battery_err then return nil, battery_err end + + local resource_id = rid_by_rtype[HueDeviceTypes.CONTACT] + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + local contact_sensor_description = { + hue_provided_name = device_service_info.metadata.name, + id = resource_id, + parent_device_id = bridge_device.id, + hue_device_id = device_service_info.id, + hue_device_data = device_service_info, + } + + if contact and contact.data and contact.data[1] then + contact_sensor_description.contact_report = contact.data[1].contact_report + contact_sensor_description.contact_enabled = contact.data[1].enabled + + end + + if battery and battery.data and battery.data[1] then + contact_sensor_description.power_id = battery.data[1].id + contact_sensor_description.power_state = battery.data[1].power_state + end + + if tamper and tamper.data and tamper.data[1] then + contact_sensor_description.tamper_id = tamper.data[1].id + contact_sensor_description.tamper_reports = tamper.data[1].tamper_reports + end + + contact_sensor_description.sensor_list = { + id = HueDeviceTypes.CONTACT, + power_id = HueDeviceTypes.DEVICE_POWER, + tamper_id = HueDeviceTypes.TAMPER + } + + if type(cache) == "table" then + cache[resource_id] = contact_sensor_description + if device_service_info.id_v1 then + cache[device_service_info.id_v1] = contact_sensor_description + end + end + + return contact_sensor_description +end + +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_id string +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +function M.update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id, cache) + log.debug("----------- Calling REST API") + local device_service_info, err = api_instance:get_device_by_id(device_service_id) + if err or not (device_service_info and device_service_info.data) then + log.error("Couldn't get device info for sensor, error: " .. st_utils.stringify_table(err)) + return + end + + return _do_update(driver, api_instance, device_service_info.data[1], bridge_network_id, cache) +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services table +---@param device_service_info HueDeviceInfo +---@param device_state_disco_cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +function M.handle_discovered_device( + driver, bridge_network_id, api_instance, + primary_services, device_service_info, + device_state_disco_cache, st_metadata_callback +) + local err = select(2, + _do_update( + driver, api_instance, device_service_info, bridge_network_id, device_state_disco_cache + ) + ) + if err then + log.error("Error updating contact sensor initial state: " .. st_utils.stringify_table(err)) + return + end + + if type(st_metadata_callback) == "function" then + local resource_id = primary_services[HueDeviceTypes.CONTACT][1].rid + local bridge_device = driver:get_device_by_dni(bridge_network_id) or {} + local st_metadata = { + type = "EDGE_CHILD", + label = device_service_info.metadata.name, + vendor_provided_label = device_service_info.product_data.product_name, + profile = "contact-sensor", + manufacturer = device_service_info.product_data.manufacturer_name, + model = device_service_info.product_data.model_id, + parent_device_id = bridge_device.id, + parent_assigned_child_key = string.format("%s:%s", HueDeviceTypes.CONTACT, resource_id) + } + + log.debug(st_utils.stringify_table(st_metadata, "contact sensor create", true)) + + st_metadata_callback(driver, st_metadata) + -- rate limit ourself. + socket.sleep(0.1) + end +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/disco/init.lua b/drivers/SmartThings/philips-hue/src/disco/init.lua new file mode 100644 index 0000000000..81b74ae9ef --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/disco/init.lua @@ -0,0 +1,455 @@ +local log = require "log" +local socket = require "cosock.socket" + +local mdns = require "st.mdns" +local net_utils = require "st.net_utils" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Fields = require "fields" +local HueApi = require "hue.api" +local HueDeviceTypes = require "hue_device_types" + +local utils = require "utils" + +local SERVICE_TYPE = "_hue._tcp" +local DOMAIN = "local" + +---@class DiscoveredChildDeviceHandler +---@field public handle_discovered_device fun(driver: HueDriver, bridge_network_id: string, api_instance: PhilipsHueApi, primary_services: { [HueDeviceTypes]: HueServiceInfo[] }, device_service_info: table, device_state_disco_cache: table, st_metadata_callback: fun(driver: HueDriver, metadata: table)?) + +-- This `api_keys` table is an in-memory fall-back table. It gets overwritten +-- with a reference to a driver datastore table before the Driver's `run` loop +-- can get spun up in `init.lua`. +---@class HueDiscovery +---@field public api_keys table +---@field public disco_api_instances table +---@field public device_state_disco_cache table> +---@field public ServiceType string +---@field public Domain string +---@field public discovery_active boolean +local HueDiscovery = { + api_keys = {}, + disco_api_instances = {}, + device_state_disco_cache = {}, + ServiceType = SERVICE_TYPE, + Domain = DOMAIN, + discovery_active = false +} + +-- Lazy-load the discovered device handlers so we only load the code we need +---@type table +local discovered_device_handlers = utils.lazy_handler_loader("disco") + +---comment +---@param svc_info HueServiceInfo +---@return boolean +local function is_device_service_supported(svc_info) + return discovered_device_handlers[svc_info.rtype or ""] ~= nil +end + +-- "forward declarations" +---@param driver HueDriver +---@param bridge_ip string +---@param bridge_network_id string +local function discovered_bridge_callback(driver, bridge_ip, bridge_network_id) + if driver.ignored_bridges[bridge_network_id] then return end + + local known_bridge_device = driver:get_device_by_dni(bridge_network_id) + if known_bridge_device and known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) then + HueDiscovery.api_keys[bridge_network_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) + end + + if known_bridge_device ~= nil + and driver.joined_bridges[bridge_network_id] + and HueDiscovery.api_keys[bridge_network_id] + then + log.info_with({ hub_logs = true }, string.format("Scanning bridge %s for devices...", bridge_network_id)) + + HueDiscovery.disco_api_instances[bridge_network_id] = HueDiscovery.disco_api_instances[bridge_network_id] + or HueApi.new_bridge_manager( + "https://" .. bridge_ip, + HueDiscovery.api_keys[bridge_network_id], + utils.labeled_socket_builder((known_bridge_device.label or bridge_network_id or known_bridge_device.id or "unknown bridge")) + ) + + HueDiscovery.search_bridge_for_supported_devices( + driver, + bridge_network_id, + HueDiscovery.disco_api_instances[bridge_network_id], + HueDiscovery.handle_discovered_child_device, + "[Discovery: " .. + (known_bridge_device.label or bridge_network_id or known_bridge_device.id or "unknown bridge") .. + " bridge scan]" + ) + return + end + + if not HueDiscovery.api_keys[bridge_network_id] then + local socket_builder = utils.labeled_socket_builder(bridge_network_id) + local api_key_response, err, _ = HueApi.request_api_key(bridge_ip, socket_builder) + + if err ~= nil or not api_key_response then + log.warn(string.format( + "Error while trying to request Bridge API Key for %s: %s", + bridge_network_id, + err + ) + ) + return + end + + for _, item in ipairs(api_key_response) do + if item.error ~= nil then + log.warn(string.format("Error payload in bridge %s API key response: %s", bridge_network_id, item.error.description)) + elseif item.success and item.success.username then + log.info(string.format("API key received for Hue Bridge %s", bridge_network_id)) + + local api_key = item.success.username + local bridge_base_url = "https://" .. bridge_ip + local api_instance = HueApi.new_bridge_manager(bridge_base_url, api_key, socket_builder) + + HueDiscovery.api_keys[bridge_network_id] = api_key + HueDiscovery.disco_api_instances[bridge_network_id] = api_instance + end + end + end + + if HueDiscovery.api_keys[bridge_network_id] and not driver.joined_bridges[bridge_network_id] then + local bridge_info = driver.datastore.bridge_netinfo[bridge_network_id] + + if not bridge_info then + log.debug(string.format("Bridge info for %s not yet available", bridge_network_id)) + return + end + + if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then + log.warn(string.format("Found bridge %s that does not support CLIP v2 API, ignoring", bridge_info.name)) + driver.ignored_bridges[bridge_network_id] = true + return + end + + driver.joined_bridges[bridge_network_id] = true + + if not known_bridge_device then + local create_device_msg = { + type = "LAN", + device_network_id = bridge_network_id, + label = (bridge_info.name or "Philips Hue Bridge"), + profile = "hue-bridge", + manufacturer = "Signify Netherlands B.V.", + model = bridge_info.modelid or "BSB002", + vendor_provided_label = (bridge_info.name or "Philips Hue Bridge"), + } + + driver:try_create_device(create_device_msg) + end + end +end + +---comment +---@param driver HueDriver +---@param _ table +---@param should_continue function +function HueDiscovery.discover(driver, _, should_continue) + if HueDiscovery.discovery_active then + log.info("Hue discovery already in progress, ignoring new discovery request") + return + end + + log.info_with({ hub_logs = true }, "Starting Hue discovery") + HueDiscovery.discovery_active = true + + while should_continue() do + HueDiscovery.do_mdns_scan(driver) + HueDiscovery.search_for_bridges( + driver, + function(hue_driver, bridge_ip, bridge_network_id) + discovered_bridge_callback(hue_driver, bridge_ip, bridge_network_id) + end + ) + socket.sleep(1.0) + end + HueDiscovery.discovery_active = false + log.info_with({ hub_logs = true }, "Ending Hue discovery") +end + +---@param driver HueDriver +---@param callback fun(driver: HueDriver, ip: string, id: string) +function HueDiscovery.search_for_bridges(driver, callback) + local scanned_bridges = driver.datastore.bridge_netinfo or {} + for bridge_network_id, bridge_info in pairs(scanned_bridges) do + if type(callback) == "function" and bridge_info ~= nil then + callback(driver, bridge_info.ip, bridge_network_id) + else + log.warn( + "Argument passed in `callback` position for " + .. "`HueDiscovery.search_for_bridges` is not a function" + ) + end + end +end + +---@param driver HueDriver +---@param bridge_network_id string +function HueDiscovery.scan_bridge_and_update_devices(driver, bridge_network_id) + if driver.ignored_bridges[bridge_network_id] then return end + + local known_bridge_device = driver:get_device_by_dni(bridge_network_id) + if known_bridge_device then + if known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) then + HueDiscovery.api_keys[bridge_network_id] = known_bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) + end + + HueDiscovery.search_bridge_for_supported_devices( + driver, + bridge_network_id, + HueDiscovery.disco_api_instances[bridge_network_id], + HueDiscovery.handle_discovered_child_device, + "[Discovery: " .. + (known_bridge_device.label or bridge_network_id or known_bridge_device.id or "unknown bridge") .. + " bridge re-scan]", + true + ) + end +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param callback fun(driver: HueDriver, bridge_network_id: string, primary_services: table, device_data: table) +---@param log_prefix string? +---@param do_delete boolean? +function HueDiscovery.search_bridge_for_supported_devices(driver, bridge_network_id, api_instance, callback, log_prefix, do_delete) + local prefix = "" + if type(log_prefix) == "string" and #log_prefix > 0 then prefix = log_prefix .. " " end + + local devices, err, _ = api_instance:get_devices() + if err ~= nil or not devices then + log.error_with({ hub_logs = true }, + prefix .. "Error querying bridge for devices: " .. (err or "unexpected nil in error position")) + return + end + + if devices.errors and #devices.errors > 0 then + log.error_with({ hub_logs = true }, prefix .. "Errors found in API response:") + for idx, err in ipairs(devices.errors) do + log.error_with({ hub_logs = true }, st_utils.stringify_table(err, "Error number " .. idx, true)) + end + return + end + + local device_is_joined_to_bridge = {} + for _, device_data in ipairs(devices.data or {}) do + device_is_joined_to_bridge[device_data.id] = + HueDiscovery.process_device_service(driver, bridge_network_id, device_data, callback, log_prefix) + end + + if do_delete then + for _, device in ipairs(driver:get_devices()) do + -- We're only interested in processing child/non-bridge devices here. + if utils.is_bridge(driver, device) then goto continue end + local not_known_to_bridge = device_is_joined_to_bridge[device:get_field(Fields.HUE_DEVICE_ID) or ""] + local parent_device_id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) or "" + local parent_bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_device_id) + local is_child_of_bridge = parent_bridge_device and (parent_bridge_device:get_field(Fields.BRIDGE_ID) == bridge_network_id) + if parent_bridge_device and is_child_of_bridge and not not_known_to_bridge and device.id then + device.log.info(string.format("Device is no longer joined to Hue Bridge %q, deleting", parent_bridge_device.label)) + driver:do_hue_child_delete(device) + end + ::continue:: + end + end +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param device_data HueDeviceInfo +---@param callback fun(driver: HueDriver, bridge_network_id: string, primary_services: table, device_data: table, bridge_device: HueBridgeDevice?)? +---@param log_prefix string? +---@param bridge_device HueBridgeDevice? +---@return boolean device_joined_to_bridge true if device was sent through the join process +function HueDiscovery.process_device_service(driver, bridge_network_id, device_data, callback, log_prefix, bridge_device) + local prefix = "" + if type(log_prefix) == "string" and #log_prefix > 0 then prefix = log_prefix .. " " end + local primary_device_services = {} + + local device_joined_to_bridge = false + for _, + svc_info in ipairs(device_data.services or {}) do + if is_device_service_supported(svc_info) then + driver.services_for_device_rid[device_data.id] = driver.services_for_device_rid[device_data.id] or {} + driver.services_for_device_rid[device_data.id][svc_info.rid] = svc_info.rtype + if HueDeviceTypes.can_join_device_for_service(svc_info.rtype) then + local services_for_type = primary_device_services[svc_info.rtype] or {} + table.insert(services_for_type, svc_info) + primary_device_services[svc_info.rtype] = services_for_type + device_joined_to_bridge = true + end + end + end + if next(primary_device_services) then + if type(callback) == "function" then + log.info_with( + { hub_logs = true }, + string.format( + prefix .. + "Processing supported services [%s] for Hue device [v2_id: %s | v1_id: %s], with Hue provided name: %s", + st_utils.stringify_table(primary_device_services), device_data.id, device_data.id_v1, device_data.metadata.name + ) + ) + callback(driver, bridge_network_id, primary_device_services, device_data, bridge_device) + else + log.warn( + prefix .. "Argument passed in `callback` position for " + .. "`HueDiscovery.search_bridge_for_supported_devices` is not a function" + ) + end + else + log.warn(string.format("No primary services for %s", device_data.metadata.name)) + log.warn(st_utils.stringify_table(device_data.services, "services", true)) + end + + return device_joined_to_bridge +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param primary_services table array of services that *can* map to device records. Multi-buttons wouldn't do so, but compound lights would. +---@param device_info table +---@param bridge_device HueBridgeDevice? If the parent bridge is known, it can be passed in here +function HueDiscovery.handle_discovered_child_device(driver, bridge_network_id, primary_services, device_info, bridge_device) + local v1_dni = bridge_network_id .. "/" .. (device_info.id_v1 or "UNKNOWN"):gsub("/lights/", "") + local primary_service_type = HueDeviceTypes.determine_main_service_rtype(device_info, primary_services) + if not primary_service_type then + log.error( + string.format( + "Couldn't determine primary service type for device %s, unable to join", + (device_info.metadata.name) + ) + ) + return + end + + for _, svc_info in ipairs(primary_services[primary_service_type]) do + if driver:get_device_by_dni(v1_dni) then + return + end + local v2_resource_id = svc_info.rid or "" + if (driver.hue_identifier_to_device_record_by_bridge[bridge_network_id] or {})[v2_resource_id] then + return + end + end + + local api_instance = + (bridge_device and bridge_device:get_field(Fields.BRIDGE_API)) or + HueDiscovery.disco_api_instances[bridge_network_id] + if not api_instance then + log.warn("No API instance for bridge_network_id ", bridge_network_id) + return + end + + discovered_device_handlers[primary_service_type].handle_discovered_device( + driver, + bridge_network_id, + api_instance, + primary_services, + device_info, + HueDiscovery.device_state_disco_cache, + driver.try_create_device + ) +end + +---@param driver HueDriver +function HueDiscovery.do_mdns_scan(driver) + local bridge_netinfo = driver.datastore.bridge_netinfo + local mdns_responses, err = mdns.discover(HueDiscovery.ServiceType, HueDiscovery.Domain) + + if err ~= nil then + log.error_with({ hub_logs = true }, "Error during service discovery: ", err) + return + end + + if not (mdns_responses and mdns_responses.found and #mdns_responses.found > 0) then + log.warn("No mdns responses for Hue service this attempt, continuing...") + return + end + + for _, info in ipairs(mdns_responses.found) do + if not net_utils.validate_ipv4_string(info.host_info.address) then -- we only care about the ipV4 types here. + log.trace("Invalid IPv4 address: " .. info.host_info.address) + goto continue + end + + if info.service_info.service_type ~= HueDiscovery.ServiceType then -- response for a different service type. Shouldn't happen. + log.warn("Unexpected service type response: " .. info.service_info.service_type) + goto continue + end + + if info.service_info.domain ~= HueDiscovery.Domain then -- response for a different domain. Shouldn't happen. + log.warn("Unexpected domain response: " .. info.service_info.domain) + goto continue + end + + -- Hue *typically* formats the BridgeID as the uppercase MAC address, minus separators. + -- However, it can be user overriden, set by a user, and may not be unique, so we want + -- to stick to that format but make sure it's actually the mac address. Instead of pulling + -- the bridge id out of the bridge info, we'll extract the MAC address and apply the above mentioned + -- formatting rules. We also prefer the MAC because it makes for a good Device Network ID. + local ip_addr = info.host_info.address; + local bridge_info, rest_err, _ = HueApi.get_bridge_info( + ip_addr, + utils.labeled_socket_builder("[mDNS Scan: " .. ip_addr .. " Bridge Info Request]") + ) + if rest_err ~= nil or not bridge_info then + log.error_with({ hub_logs = true }, + string.format( + "Error querying bridge info for discovered IP address %s: %s", + ip_addr, + (rest_err or "unexpected nil in error position") + ) + ) + return + end + -- '-' and ':' or '::' are the accepted separators used for MAC address segments, so + -- we strip thoes out and make the string uppercase + if not bridge_info.mac then + log.warn_with({ hub_logs = true }, + string.format( + "No MAC address in returned Bridge Info for IP %s.", ip_addr + ) + ) + return + end + + bridge_info.ip = ip_addr + local bridge_network_id = bridge_info.mac:gsub("-", ""):gsub(":", ""):upper() + + -- sanitize userdata nulls from JSON decode + for k, v in pairs(bridge_info) do + if type(v) == "userdata" then + bridge_info[k] = nil + end + end + + ---@type boolean? + local update_needed = false + if not utils.deep_table_eq((bridge_netinfo[bridge_network_id] or {}), bridge_info) then + bridge_netinfo[bridge_network_id] = bridge_info + update_needed = true + end + + if driver.joined_bridges[bridge_network_id] and not driver.ignored_bridges[bridge_network_id] then + local bridge_device = driver:get_device_by_dni(bridge_network_id, true) + update_needed = update_needed or (bridge_device and (bridge_device:get_field(Fields.IPV4) ~= bridge_info.ip)) + if update_needed then + driver:update_bridge_netinfo(bridge_network_id, bridge_info) + end + end + ::continue:: + end +end + +return HueDiscovery diff --git a/drivers/SmartThings/philips-hue/src/disco/light.lua b/drivers/SmartThings/philips-hue/src/disco/light.lua new file mode 100644 index 0000000000..ef5f9d9371 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/disco/light.lua @@ -0,0 +1,216 @@ +local log = require "log" +local socket = require "cosock".socket +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local HueDeviceTypes = require "hue_device_types" + +local function join_light(driver, light, device_service_info, parent_device_id, st_metadata_callback) + local profile_ref + if light.color then + if light.color_temperature then + profile_ref = "white-and-color-ambiance" + else + profile_ref = "legacy-color" + end + elseif light.color_temperature then + profile_ref = "white-ambiance" -- all color temp products support `white` (dimming) + elseif light.dimming then + profile_ref = "white" -- `white` refers to dimmable and includes filament bulbs + elseif light.on then -- Case for plug which uses same category as 'light' + profile_ref = "plug" + else + log.warn( + string.format( + "Light resource [%s] does not seem to be A White/White-Ambiance/White-Color-Ambiance/Plug device, currently unsupported" + , + light.id + ) + ) + return + end + + local device_name + if light.metadata.name == device_service_info.metadata.name then + device_name = device_service_info.metadata.name + else + device_name = string.format("%s %s", device_service_info.metadata.name, light.metadata.name) + end + + local parent_assigned_child_key = string.format("%s:%s", light.type, light.id) + + local st_metadata = { + type = "EDGE_CHILD", + label = device_name, + vendor_provided_label = device_service_info.product_data.product_name, + profile = profile_ref, + manufacturer = device_service_info.product_data.manufacturer_name, + model = device_service_info.product_data.model_id, + parent_device_id = parent_device_id, + parent_assigned_child_key = parent_assigned_child_key + } + + log.debug(st_utils.stringify_table(st_metadata, "light create", true)) + st_metadata_callback(driver, st_metadata) + -- rate limit ourself. + socket.sleep(0.1) +end + +local function get_light_state_table_and_update_cache(light, parent_device_id, device_service_info, cache) + local light_resource_description = { + hue_provided_name = light.metadata.name, + id = light.id, + on = light.on, + color = light.color, + dimming = light.dimming, + color_temperature = light.color_temperature, + mode = light.mode, + parent_device_id = parent_device_id, + hue_device_id = light.owner.rid, + hue_device_data = device_service_info + } + + if type(cache) == "table" then + cache[light.id] = light_resource_description + if device_service_info.id_v1 then + cache[device_service_info.id_v1] = light_resource_description + end + end + return light_resource_description +end + +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param services HueServiceInfo[] +---@param device_service_info HueDeviceInfo +---@param bridge_network_id string +---@param cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +local function handle_compound_light( + driver, api_instance, services, + device_service_info, bridge_network_id, cache, st_metadata_callback +) + ---@type HueLightInfo[] + local all_lights = {} + local main_light_resource_id + for _, svc in ipairs(services) do + local light_resource, err, _ = api_instance:get_light_by_id(svc.rid) + if not light_resource or (light_resource and #light_resource.errors > 0) or err then + log.error(string.format("Couldn't get light resource for rid %s, skipping", svc.rid)) + goto continue + end + table.insert(all_lights, light_resource.data[1]) + if light_resource.data[1].service_id and light_resource.data[1].service_id == 1 then + main_light_resource_id = light_resource.data[1].id + end + ::continue:: + end + + if type(main_light_resource_id) ~= "string" then + log.warn( + string.format( + "Couldn't determine the primary light for compound light [%s] from V1 ID, picking the first light service", + device_service_info.metadata.name + ) + ) + main_light_resource_id = services[1].rid + end + + ---@type HueLightInfo[] + local grandchild_lights = {} + for _, light in pairs(all_lights) do + if light.id == main_light_resource_id then + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + get_light_state_table_and_update_cache(light, bridge_device.id, device_service_info, cache) + if type(st_metadata_callback) == "function" then + join_light( + driver, light, device_service_info, bridge_device.id, st_metadata_callback + ) + end + else + table.insert(grandchild_lights, { + waiting_resource_info = light, + join_callback = function(driver, waiting_info, parent_device) + get_light_state_table_and_update_cache(waiting_info, parent_device.id, device_service_info, cache) + join_light( + driver, waiting_info, device_service_info, parent_device.id, st_metadata_callback + ) + end + }) + end + end + + driver:queue_grandchild_device_for_join(grandchild_lights, main_light_resource_id) +end + +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param resource_id string +---@param device_service_info HueDeviceInfo +---@param bridge_network_id string +---@param cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +local function handle_simple_light( + driver, api_instance, resource_id, + device_service_info, bridge_network_id, cache, st_metadata_callback +) + local light_resource, err, _ = api_instance:get_light_by_id(resource_id) + if err ~= nil or not light_resource then + log.error_with({ hub_logs = true }, + string.format("Error getting light info for %s: %s", device_service_info.product_data.product_name, + (err or "unexpected nil in error position"))) + return + end + + if light_resource.errors and #light_resource.errors > 0 then + log.error_with({ hub_logs = true }, "Errors found in API response:") + for idx, rest_err in ipairs(light_resource.errors) do + log.error_with({ hub_logs = true }, st_utils.stringify_table(rest_err, "Error " .. idx, true)) + end + return + end + + local light = light_resource.data[1] + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + get_light_state_table_and_update_cache(light, bridge_device.id, device_service_info, cache) + + if type(st_metadata_callback) == "function" then + join_light( + driver, light, device_service_info, bridge_device.id, st_metadata_callback + ) + end +end + +---@class DiscoveredLightHandler: DiscoveredChildDeviceHandler +local M = {} + +---@param driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services table +---@param device_service_info HueDeviceInfo +---@param device_state_disco_cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +function M.handle_discovered_device( + driver, bridge_network_id, api_instance, + primary_services, device_service_info, + device_state_disco_cache, st_metadata_callback +) + local light_services = primary_services[HueDeviceTypes.LIGHT] + local is_compound_light = #light_services > 1 + if is_compound_light then + handle_compound_light( + driver, api_instance, light_services, device_service_info, + bridge_network_id, device_state_disco_cache, st_metadata_callback + ) + else + handle_simple_light( + driver, api_instance, light_services[1].rid, device_service_info, + bridge_network_id, device_state_disco_cache, st_metadata_callback + ) + end +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/disco/motion.lua b/drivers/SmartThings/philips-hue/src/disco/motion.lua new file mode 100644 index 0000000000..e4267a38d1 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/disco/motion.lua @@ -0,0 +1,152 @@ +local log = require "log" +local socket = require "cosock".socket +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local HueDeviceTypes = require "hue_device_types" + +---@class DiscoveredMotionSensorHandler: DiscoveredChildDeviceHandler +local M = {} + +-- TODO This should be generalizable to all "sensors", including buttons. +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_info HueDeviceInfo +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +local function _do_update(driver, api_instance, device_service_info, bridge_network_id, cache) + log.debug("------------ _do_update") + local rid_by_rtype = {} + for _, svc in ipairs(device_service_info.services) do + rid_by_rtype[svc.rtype] = svc.rid + end + + local motion, motion_err = api_instance:get_motion_by_id(rid_by_rtype[HueDeviceTypes.MOTION]) + if motion_err then return nil, motion_err end + + local temperature, temp_err = api_instance:get_temperature_by_id(rid_by_rtype[HueDeviceTypes.TEMPERATURE]) + if temp_err then return nil, temp_err end + + local illuminance, illuminance_err = api_instance:get_light_level_by_id(rid_by_rtype[HueDeviceTypes.LIGHT_LEVEL]) + if illuminance_err then return nil, illuminance_err end + + local battery, battery_err = api_instance:get_device_power_by_id(rid_by_rtype[HueDeviceTypes.DEVICE_POWER]) + if battery_err then return nil, battery_err end + + local resource_id = rid_by_rtype[HueDeviceTypes.MOTION] + local bridge_device = driver:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + local motion_sensor_description = { + hue_provided_name = device_service_info.metadata.name, + id = resource_id, + parent_device_id = bridge_device.id, + hue_device_id = device_service_info.id, + hue_device_data = device_service_info, + } + + if motion and motion.data and motion.data[1] then + motion_sensor_description.motion = motion.data[1].motion + motion_sensor_description.motion_enabled = motion.data[1].enabled + + end + + if battery and battery.data and battery.data[1] then + motion_sensor_description.power_id = battery.data[1].id + motion_sensor_description.power_state = battery.data[1].power_state + end + + if temperature and temperature.data and temperature.data[1] then + motion_sensor_description.temperature_id = temperature.data[1].id + motion_sensor_description.temperature = temperature.data[1].temperature + motion_sensor_description.temperature_enabled = temperature.data[1].enabled + end + + if illuminance and illuminance.data and illuminance.data[1] then + motion_sensor_description.light_level_id = illuminance.data[1].id + motion_sensor_description.light = illuminance.data[1].light + motion_sensor_description.light_level_enabled = illuminance.data[1].enabled + end + + motion_sensor_description.sensor_list = { + id = HueDeviceTypes.MOTION, + power_id = HueDeviceTypes.DEVICE_POWER, + temperature_id = HueDeviceTypes.TEMPERATURE, + light_level_id = HueDeviceTypes.LIGHT_LEVEL + } + + if type(cache) == "table" then + cache[resource_id] = motion_sensor_description + if device_service_info.id_v1 then + cache[device_service_info.id_v1] = motion_sensor_description + end + end + + return motion_sensor_description +end + +---@param driver HueDriver +---@param api_instance PhilipsHueApi +---@param device_service_id string +---@param bridge_network_id string +---@param cache table? +---@return table? description nil on error +---@return string? err nil on success +function M.update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id, cache) + log.debug("----------- Calling REST API") + local device_service_info, err = api_instance:get_device_by_id(device_service_id) + if err or not (device_service_info and device_service_info.data) then + log.error("Couldn't get device info for sensor, error: " .. st_utils.stringify_table(err)) + return + end + + return _do_update(driver, api_instance, device_service_info.data[1], bridge_network_id, cache) +end + +---@param driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services table +---@param device_service_info HueDeviceInfo +---@param device_state_disco_cache table +---@param st_metadata_callback fun(driver: HueDriver, metadata: table)? +function M.handle_discovered_device( + driver, bridge_network_id, api_instance, + primary_services, device_service_info, + device_state_disco_cache, st_metadata_callback +) + local err = select(2, + _do_update( + driver, api_instance, device_service_info, bridge_network_id, device_state_disco_cache + ) + ) + if err then + log.error("Error updating motion sensor initial state: " .. st_utils.stringify_table(err)) + return + end + + if type(st_metadata_callback) == "function" then + local resource_id = primary_services[HueDeviceTypes.MOTION][1].rid + local bridge_device = driver:get_device_by_dni(bridge_network_id) or {} + local st_metadata = { + type = "EDGE_CHILD", + label = device_service_info.metadata.name, + vendor_provided_label = device_service_info.product_data.product_name, + profile = "motion-sensor", + manufacturer = device_service_info.product_data.manufacturer_name, + model = device_service_info.product_data.model_id, + parent_device_id = bridge_device.id, + parent_assigned_child_key = string.format("%s:%s", HueDeviceTypes.MOTION, resource_id) + } + + log.debug(st_utils.stringify_table(st_metadata, "motion sensor create", true)) + + st_metadata_callback(driver, st_metadata) + -- rate limit ourself. + socket.sleep(0.1) + end +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/fields.lua b/drivers/SmartThings/philips-hue/src/fields.lua index 05e0b8c4f5..08979059bf 100644 --- a/drivers/SmartThings/philips-hue/src/fields.lua +++ b/drivers/SmartThings/philips-hue/src/fields.lua @@ -5,9 +5,8 @@ --- @field MODEL_ID string Bridge model ID --- @field BRIDGE_ID string The unique identifier for the bridge (found during discovery) --- @field BRIDGE_SW_VERSION string The SW Version of the bridge to determine if it supports CLIP V2 ---- @field DEVICE_TYPE string Field on all Hue devices that indicates type (currently either "brige" or "light") +--- @field DEVICE_TYPE string Field on all Hue devices that indicates type, maps to a Hue service rtype. --- @field BRIDGE_API string Transient field that holds the HueAPI instance for the bridge ---- @field MIN_DIMMING string Minimum dimming/brightness value accepted by a light --- @field EVENT_SOURCE string Field on a bridge that stores a handle to the SSE EventSource client. local Fields = { _ADDED = "added", @@ -16,18 +15,27 @@ local Fields = { BRIDGE_API = "bridge_api", BRIDGE_ID = "bridgeid", BRIDGE_SW_VERSION = "swversion", + BUTTON_INDEX_MAP = "button_rid_to_index", DEVICE_TYPE = "devicetype", EVENT_SOURCE = "eventsource", GAMUT = "gamut", + GROUPS = "groups", + GROUPS_SCAN_QUEUE = "groups_scan_queue", HUE_DEVICE_ID = "hue_device_id", IPV4 = "ipv4", - MIN_DIMMING = "mindim", + IS_ONLINE = "is_online", + IS_MULTI_SERVICE = "is_multi_service", MIN_KELVIN = "mintemp", + MAX_KELVIN = "maxtemp", MODEL_ID = "modelid", - IS_ONLINE = "is_online", PARENT_DEVICE_ID = "parent_device_id_local", RESOURCE_ID = "rid", - WRAPPED_HUE = "_wrapped_hue" + RETRY_MIGRATION = "retry_migration", + WRAPPED_HUE = "_wrapped_hue", + COLOR_SATURATION = "color_saturation", + COLOR_HUE = "color_hue", + SWITCH_STATE = "switch_state_cache", + COLOR_TEMP_SETPOINT = "ct_setpoint" } return Fields diff --git a/drivers/SmartThings/philips-hue/src/handlers.lua b/drivers/SmartThings/philips-hue/src/handlers.lua deleted file mode 100644 index c00f74c86e..0000000000 --- a/drivers/SmartThings/philips-hue/src/handlers.lua +++ /dev/null @@ -1,533 +0,0 @@ -local Fields = require "fields" -local HueApi = require "hue.api" -local HueColorUtils = require "hue.cie_utils" -local log = require "log" -local utils = require "utils" - -local cosock = require "cosock" -local capabilities = require "st.capabilities" -local st_utils = require "st.utils" --- trick to fix the VS Code Lua Language Server typechecking ----@type fun(val: table, name: string?, multi_line: boolean?): string -st_utils.stringify_table = st_utils.stringify_table - -local handlers = {} - ----@param driver HueDriver ----@param device HueChildDevice -local function do_switch_action(driver, device, args) - local on = args.command == "on" - local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) - - if not bridge_device then - log.warn( - "Couldn't get a bridge for light with Child Key " .. - (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) - return - end - - local light_id = device:get_field(Fields.RESOURCE_ID) - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) - - if not (light_id or hue_api) then - log.warn( - string.format( - "Could not get a proper light resource ID or API instance for %s" .. - "\n\tLight Resource ID: %s" .. - "\n\tHue API nil? %s", - (device.label or device.id or "unknown device"), - light_id, - (hue_api == nil) - ) - ) - return - end - - local resp, err = hue_api:set_light_on_state(light_id, on) - - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end -end - ----@param driver HueDriver ----@param device HueChildDevice -local function do_switch_level_action(driver, device, args) - local level = st_utils.clamp_value(args.args.level, 1, 100) - local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) - - if not bridge_device then - log.warn( - "Couldn't get a bridge for light with Child Key " .. - (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) - return - end - - local light_id = device:get_field(Fields.RESOURCE_ID) - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) - - if not (light_id or hue_api) then - log.warn( - string.format( - "Could not get a proper light resource ID or API instance for %s" .. - "\n\tLight Resource ID: %s" .. - "\n\tHue API nil? %s", - (device.label or device.id or "unknown device"), - light_id, - (hue_api == nil) - ) - ) - return - end - - local is_off = device:get_latest_state( - "main", capabilities.switch.ID, capabilities.switch.switch.NAME) == "off" - - if is_off then - local resp, err = hue_api:set_light_on_state(light_id, true) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end - end - - local resp, err = hue_api:set_light_level(light_id, level) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing switch level action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end -end - ----@param driver HueDriver ----@param device HueChildDevice -local function do_color_action(driver, device, args) - local hue, sat = (args.args.color.hue / 100), (args.args.color.saturation / 100) - if hue == 1 then -- 0 and 360 degrees are equivalent in HSV, but not in our conversion function - hue = 0 - device:set_field(Fields.WRAPPED_HUE, true) - end - local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) - - if not bridge_device then - log.warn( - "Couldn't get a bridge for light with Child Key " .. - (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) - return - end - - local light_id = device:get_field(Fields.RESOURCE_ID) - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) - - if not (light_id or hue_api) then - log.warn( - string.format( - "Could not get a proper light resource ID or API instance for %s" .. - "\n\tLight Resource ID: %s" .. - "\n\tHue API nil? %s", - (device.label or device.id or "unknown device"), - light_id, - (hue_api == nil) - ) - ) - return - end - - local red, green, blue = st_utils.hsv_to_rgb(hue, sat) - local xy = HueColorUtils.safe_rgb_to_xy(red, green, blue, device:get_field(Fields.GAMUT)) - - local resp, err = hue_api:set_light_color_xy(light_id, xy) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing color action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end -end - --- Function to allow changes to "setHue" attribute to Philips Hue light devices ----@param driver HueDriver ----@param device HueChildDevice -local function do_setHue_action(driver, device, args) - - -- Use existing 'saturation' value for device or set to 0 and pass arg values to function 'do_color_action' - local currentSaturation = device:get_latest_state("main", capabilities.colorControl.ID, capabilities.colorControl.saturation.NAME, 0) - args.args.color = { - hue = args.args.hue, - saturation = currentSaturation - } - do_color_action(driver, device, args) -end - --- Function to allow changes to "setSaturation" attribute to Philips Hue light devices ----@param driver HueDriver ----@param device HueChildDevice -local function do_setSaturation_action(driver, device, args) - - -- Use existing 'hue' value for device or set to 0 and pass arg values to function 'do_color_action' - local currentHue = device:get_latest_state("main", capabilities.colorControl.ID, capabilities.colorControl.hue.NAME, 0) - args.args.color = { - hue = currentHue, - saturation = args.args.saturation - } - do_color_action(driver, device, args) -end - -function handlers.kelvin_to_mirek(kelvin) return 1000000 / kelvin end - -function handlers.mirek_to_kelvin(mirek) return 1000000 / mirek end - ----@param driver HueDriver ----@param device HueChildDevice -local function do_color_temp_action(driver, device, args) - local kelvin = args.args.temperature - local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(id) - - if not bridge_device then - log.warn( - "Couldn't get a bridge for light with Child Key " .. - (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) - return - end - - local light_id = device:get_field(Fields.RESOURCE_ID) - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) - - if not (light_id or hue_api) then - log.warn( - string.format( - "Could not get a proper light resource ID or API instance for %s" .. - "\n\tLight Resource ID: %s" .. - "\n\tHue API nil? %s", - (device.label or device.id or "unknown device"), - light_id, - (hue_api == nil) - ) - ) - return - end - - local min = device:get_field(Fields.MIN_KELVIN) or HueApi.MIN_TEMP_KELVIN_WHITE_AMBIANCE - local clamped_kelvin = st_utils.clamp_value(kelvin, min, HueApi.MAX_TEMP_KELVIN) - local mirek = math.floor(handlers.kelvin_to_mirek(clamped_kelvin)) - - local resp, err = hue_api:set_light_color_temp(light_id, mirek) - - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing color temp action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end -end - ----@param driver HueDriver ----@param device HueChildDevice -function handlers.switch_on_handler(driver, device, args) - do_switch_action(driver, device, args) -end - ----@param driver HueDriver ----@param device HueChildDevice -function handlers.switch_off_handler(driver, device, args) - do_switch_action(driver, device, args) -end - ----@param driver HueDriver ----@param device HueChildDevice -function handlers.switch_level_handler(driver, device, args) - do_switch_level_action(driver, device, args) -end - ----@param driver HueDriver ----@param device HueChildDevice -function handlers.set_color_handler(driver, device, args) - do_color_action(driver, device, args) -end - ----@param driver HueDriver ----@param device HueChildDevice -function handlers.set_color_temp_handler(driver, device, args) - do_color_temp_action(driver, device, args) -end - ----@param driver HueDriver ----@param device HueChildDevice -function handlers.set_hue_handler(driver, device, args) - do_setHue_action(driver, device, args) -end - - ----@param driver HueDriver ----@param device HueChildDevice -function handlers.set_saturation_handler(driver, device, args) - do_setSaturation_action(driver, device, args) -end - - ----@param driver HueDriver ----@param light_device HueChildDevice ----@param conn_status_cache table|nil ----@param light_status_cache table|nil -local function do_refresh_light(driver, light_device, conn_status_cache, light_status_cache) - local light_resource_id = light_device:get_field(Fields.RESOURCE_ID) - local hue_device_id = light_device:get_field(Fields.HUE_DEVICE_ID) - - local do_zigbee_request = true - local do_light_request = true - - if type(conn_status_cache) == "table" then - local zigbee_status = conn_status_cache[hue_device_id] - if zigbee_status ~= nil and zigbee_status.status ~= nil then - do_zigbee_request = false - if zigbee_status.status == "connected" then - light_device.log.debug(string.format("Zigbee Status for %s is connected", light_device.label)) - light_device:online() - light_device:set_field(Fields.IS_ONLINE, true) - else - light_device.log.debug(string.format("Zigbee Status for %s is not connected", light_device.label)) - light_device:set_field(Fields.IS_ONLINE, false) - light_device:offline() - end - end - end - - if type(light_status_cache) == "table" then - local light_info = light_status_cache[hue_device_id] - if light_info ~= nil then - if light_info.id == light_resource_id then - if light_info.color ~= nil and light_info.color.gamut then - light_device:set_field(Fields.GAMUT, light_info.color.gamut_type, { persist = true }) - end - driver.emit_light_status_events(light_device, light_info) - do_light_request = false - end - end - end - - local bridge_id = light_device.parent_device_id or light_device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = driver:get_device_info(bridge_id) - - if not bridge_device then - log.warn("Couldn't get Hue bridge for light " .. (light_device.label or light_device.id or "unknown device")) - return - end - - if not bridge_device:get_field(Fields._INIT) then - log.warn("Bridge for light not yet initialized, can't refresh yet.") - driver._lights_pending_refresh[light_device.id] = light_device - return - end - - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) - local success = not (do_light_request or do_zigbee_request) - local count = 0 - local num_attempts = 3 - local zigbee_resource_id - local rest_resp, rest_err - local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) - --- this loop is a rate-limit dodge. - --- - --- One of the various symptoms of hitting the Hue Bridge's rate limit is that you'll get a silent - --- failure that takes the form of the bridge returning the last valid response it replied with. - --- So we hit the bridge 2-3 times and check the IDs in the responses to verify that we're getting - --- the information for the light that we expect to getting the info for. - repeat - count = count + 1 - if do_zigbee_request then - rest_resp, rest_err = hue_api:get_device_by_id(hue_device_id) - if rest_err ~= nil then - log.error_with({ hub_logs = true }, rest_err) - goto continue - end - - if rest_resp ~= nil then - if #rest_resp.errors > 0 then - for _, err in ipairs(rest_resp.errors) do - log.error_with({ hub_logs = true }, "Error in Hue API response: " .. err.description) - end - goto continue - end - - for _, hue_device in ipairs(rest_resp.data) do - for _, svc_info in ipairs(hue_device.services or {}) do - if svc_info.rtype == "zigbee_connectivity" then - zigbee_resource_id = svc_info.rid - end - end - end - end - - if zigbee_resource_id ~= nil then - rest_resp, rest_err = hue_api:get_zigbee_connectivity_by_id(zigbee_resource_id) - if rest_err ~= nil then - log.error_with({ hub_logs = true }, rest_err) - goto continue - end - - if rest_resp ~= nil then - if #rest_resp.errors > 0 then - for _, err in ipairs(rest_resp.errors) do - log.error_with({ hub_logs = true }, "Error in Hue API response: " .. err.description) - end - goto continue - end - - for _, zigbee_svc in ipairs(rest_resp.data) do - if zigbee_svc.owner and zigbee_svc.owner.rid == hue_device_id then - if zigbee_svc.status and zigbee_svc.status == "connected" then - light_device.log.debug(string.format("Zigbee Status for %s is connected", light_device.label)) - light_device:online() - light_device:set_field(Fields.IS_ONLINE, true) - else - light_device.log.debug(string.format("Zigbee Status for %s is not connected", light_device.label)) - light_device:set_field(Fields.IS_ONLINE, false) - light_device:offline() - end - end - end - end - end - end - - if do_light_request and light_device:get_field(Fields.IS_ONLINE) then - rest_resp, rest_err = hue_api:get_light_by_id(light_resource_id) - if rest_err ~= nil then - log.error_with({ hub_logs = true }, rest_err) - goto continue - end - - if rest_resp ~= nil then - if #rest_resp.errors > 0 then - for _, err in ipairs(rest_resp.errors) do - log.error_with({ hub_logs = true }, "Error in Hue API response: " .. err.description) - end - goto continue - end - - for _, light_info in ipairs(rest_resp.data) do - if light_info.id == light_resource_id then - if light_info.color ~= nil and light_info.color.gamut then - light_device:set_field(Fields.GAMUT, light_info.color.gamut_type, { persist = true }) - end - driver.emit_light_status_events(light_device, light_info) - success = true - end - end - end - end - ::continue:: - if not success then - cosock.socket.sleep(backoff_generator()) - end - until success or count >= num_attempts -end - ----@param driver HueDriver ----@param bridge_device HueBridgeDevice -local function do_refresh_all_for_bridge(driver, bridge_device) - local child_devices = bridge_device:get_child_list() --[=[@as HueChildDevice[]]=] - - if not bridge_device:get_field(Fields._INIT) then - log.warn("Bridge for lights not yet initialized, can't refresh yet.") - return - end - - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] - - local conn_status, conn_rest_err = hue_api:get_connectivity_status() - local light_status, light_rest_err = hue_api:get_lights() - - if conn_rest_err ~= nil or light_rest_err ~= nil then - bridge_device.log.error( - string.format( - "Couldn't refresh devices connected to bridge.\n" .. - "get_connectivity_status error? %s\n" .. - "get_lights error? %s\n", - conn_rest_err, - light_rest_err - ) - ) - return - end - - if (not conn_status) or (not light_status) then - bridge_device.log.warn( - string.format( - "Received empty status payloads with no errors while refreshing, aborting refresh handler.\n" .. - "Connectivity status nil? %s\n" .. - "Light status nil? %s\n", - (conn_status == nil), - (light_status == nil) - ) - ) - return - end - - if conn_status.errors and #conn_status.errors > 0 then - bridge_device.log.error("Errors in connectivity status payload: " .. st_utils.stringify_table(conn_status.errors)) - return - end - - if light_status.errors and #light_status.errors > 0 then - bridge_device.log.error("Errors in light status payload: " .. st_utils.stringify_table(light_status.errors)) - return - end - - local conn_status_cache = {} - local light_status_cache = {} - - for _, zigbee_status in ipairs(conn_status.data) do - conn_status_cache[zigbee_status.owner.rid] = zigbee_status - end - - for _, light_status in ipairs(light_status.data) do - light_status_cache[light_status.owner.rid] = light_status - end - - for _, device in ipairs(child_devices) do - local device_type = device:get_field(Fields.DEVICE_TYPE) - if device_type == "light" then - do_refresh_light(driver, device, conn_status_cache, light_status_cache) - end - end -end - ----@param driver HueDriver ----@param device HueDevice -function handlers.refresh_handler(driver, device, cmd) - if device:get_field(Fields.DEVICE_TYPE) == "bridge" then - cosock.spawn(function() - do_refresh_all_for_bridge(driver, device --[[@as HueBridgeDevice]]) - end, string.format("Refresh All Lights On Hue Bridge [%s] Task", device.label)) - elseif device:get_field(Fields.DEVICE_TYPE) == "light" then - do_refresh_light(driver, device --[[@as HueChildDevice]]) - end -end - -return handlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua new file mode 100644 index 0000000000..05c9dbb7e9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua @@ -0,0 +1,391 @@ +local capabilities = require "st.capabilities" +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Consts = require "consts" +local Fields = require "fields" +local HueColorUtils = require "utils.cie_utils" +local HueDeviceTypes = require "hue_device_types" + +local utils = require "utils" + +local syncCapabilityId = "samsungim.hueSyncMode" +local hueSyncMode = capabilities[syncCapabilityId] + +---@class AttributeEmitters +local AttributeEmitters = {} + +---@type { [HueDeviceTypes]: fun(device: HueDevice, ...)} +local device_type_emitter_map = {} + +---@param light_device HueChildDevice +---@param light_repr HueLightInfo +local function _emit_light_events_inner(light_device, light_repr) + if light_device ~= nil then + if light_device:get_field(Fields.IS_ONLINE) ~= true then + return + end + + if light_repr.mode then + light_device:emit_event(hueSyncMode.mode(light_repr.mode)) + end + + if light_repr.on and light_repr.on.on then + light_device:emit_event(capabilities.switch.switch.on()) + light_device:set_field(Fields.SWITCH_STATE, "on", {persist = true}) + elseif light_repr.on and not light_repr.on.on then + light_device:emit_event(capabilities.switch.switch.off()) + light_device:set_field(Fields.SWITCH_STATE, "off", {persist = true}) + end + + if light_repr.dimming then + local adjusted_level = st_utils.round(st_utils.clamp_value(light_repr.dimming.brightness, 1, 100)) + if utils.is_nan(adjusted_level) then + light_device.log.warn( + string.format( + "Non numeric value %s computed for switchLevel Attribute Event, ignoring.", + adjusted_level + ) + ) + else + light_device:emit_event(capabilities.switchLevel.level(adjusted_level)) + end + end + + if light_repr.color_temperature then + local mirek = Consts.DEFAULT_MIN_MIREK + if light_repr.color_temperature.mirek_valid then + mirek = light_repr.color_temperature.mirek + end + local mirek_schema = light_repr.color_temperature.mirek_schema or { + mirek_minimum = Consts.DEFAULT_MIN_MIREK, + mirek_maximum = Consts.DEFAULT_MAX_MIREK + } + + -- See note in `src/handlers/lifecycle_handlers/light.lua` about min/max relationship + -- if the below is not intuitive. + local color_temp_range = light_device:get_latest_state("main", capabilities.colorTemperature.ID, capabilities.colorTemperature.colorTemperatureRange.NAME); + local min_kelvin = (color_temp_range and color_temp_range.minimum) + local api_min_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_maximum, Consts.KELVIN_STEP_SIZE) or Consts.MIN_TEMP_KELVIN_COLOR_AMBIANCE) + local max_kelvin = (color_temp_range and color_temp_range.maximum) + local api_max_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_minimum, Consts.KELVIN_STEP_SIZE) or Consts.MAX_TEMP_KELVIN) + + local update_range = false + if min_kelvin ~= api_min_kelvin then + update_range = true + min_kelvin = api_min_kelvin + light_device:set_field(Fields.MIN_KELVIN, min_kelvin, { persist = true }) + end + + if max_kelvin ~= api_max_kelvin then + update_range = true + max_kelvin = api_max_kelvin + light_device:set_field(Fields.MAX_KELVIN, max_kelvin, { persist = true }) + end + + if update_range then + light_device.log.debug(st_utils.stringify_table({ minimum = min_kelvin, maximum = max_kelvin }, "updating color temp range")); + light_device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ minimum = min_kelvin, maximum = max_kelvin })) + else + light_device.log.debug(st_utils.stringify_table(color_temp_range, "color temp range unchanged")); + end + + -- local min = or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE + local kelvin = math.floor( + st_utils.clamp_value(utils.mirek_to_kelvin(mirek), min_kelvin, max_kelvin) + ) + if utils.is_nan(kelvin) then + light_device.log.warn( + string.format( + "Non numeric value %s computed for colorTemperature Attribute Event, ignoring.", + kelvin + ) + ) + else + local last_kelvin_setpoint = light_device:get_field(Fields.COLOR_TEMP_SETPOINT) + if + last_kelvin_setpoint ~= nil and + last_kelvin_setpoint >= utils.mirek_to_kelvin(mirek + 1) and + last_kelvin_setpoint <= utils.mirek_to_kelvin(mirek - 1) + then + kelvin = last_kelvin_setpoint; + end + light_device:emit_event(capabilities.colorTemperature.colorTemperature(kelvin)) + end + light_device:set_field(Fields.COLOR_TEMP_SETPOINT, nil); + end + + if light_repr.color then + light_device:set_field(Fields.GAMUT, light_repr.color.gamut, { persist = true }) + local r, g, b = HueColorUtils.safe_xy_to_rgb(light_repr.color.xy, light_repr.color.gamut) + local hue, sat, _ = st_utils.rgb_to_hsv(r, g, b) + -- We sent a command where hue == 100 and wrapped the value to 0, reverse that here + if light_device:get_field(Fields.WRAPPED_HUE) == true and (hue + .05 >= 1 or hue - .05 <= 0) then + hue = 1 + light_device:set_field(Fields.WRAPPED_HUE, false) + end + + local adjusted_hue = st_utils.clamp_value(st_utils.round(hue * 100), 0, 100) + local adjusted_sat = st_utils.clamp_value(st_utils.round(sat * 100), 0, 100) + + if utils.is_nan(adjusted_hue) then + light_device.log.warn( + string.format( + "Non numeric value %s computed for colorControl.hue Attribute Event, ignoring.", + adjusted_hue + ) + ) + else + light_device:emit_event(capabilities.colorControl.hue(adjusted_hue)) + light_device:set_field(Fields.COLOR_HUE, adjusted_hue, {persist = true}) + end + + if utils.is_nan(adjusted_sat) then + light_device.log.warn( + string.format( + "Non numeric value %s computed for colorControl.saturation Attribute Event, ignoring.", + adjusted_sat + ) + ) + else + light_device:emit_event(capabilities.colorControl.saturation(adjusted_sat)) + light_device:set_field(Fields.COLOR_SATURATION, adjusted_sat, {persist = true}) + end + end + end +end + +-- A sufficiently small color temp command can transform to the same value (in mireks) that a device already has, so the bridge +-- may not respond to said command. To avoid any odd behaviors in this case, we should emit a kelvin value change event immediately +function AttributeEmitters.emit_color_temp_when_mirek_unchanged(device, clamped_kelvin, mirek) + local current_color_temp = device:get_latest_state("main", capabilities.colorTemperature.ID, capabilities.colorTemperature.colorTemperature.NAME) + if current_color_temp then + local current_mirek = math.floor(utils.kelvin_to_mirek(current_color_temp)) + if current_mirek == mirek then + log.debug(string.format("Color temp change from %dK to %dK results in same mirek value (%d), emitting event directly", current_color_temp, clamped_kelvin, mirek)) + device:emit_event(capabilities.colorTemperature.colorTemperature(clamped_kelvin)) + end + end +end + +function AttributeEmitters.connectivity_update(child_device, zigbee_status) + if child_device == nil or (child_device and child_device.id == nil) then + log.warn("Tried to emit attribute events for a device that has been deleted") + return + end + + if zigbee_status == nil then + log.error_with({ hub_logs = true }, + string.format("nil zigbee_status sent to connectivity_update for %s", + (child_device and (child_device.label or child_device.id)) or "unknown device")) + return + end + + if zigbee_status.status == "connected" then + child_device.log.info_with({hub_logs=true}, "Device zigbee status event, marking device online") + child_device:online() + child_device:set_field(Fields.IS_ONLINE, true) + child_device.driver:inject_capability_command(child_device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + elseif zigbee_status.status == "connectivity_issue" then + child_device.log.info_with({hub_logs=true}, "Device zigbee status event, marking device offline") + child_device:set_field(Fields.IS_ONLINE, false) + child_device:offline() + end +end + +function AttributeEmitters.emit_button_attribute_events(button_device, button_info) + if button_device == nil or (button_device and button_device.id == nil) then + log.warn("Tried to emit attribute events for a device that has been deleted") + return + end + + if button_info == nil then + log.error_with({ hub_logs = true }, + string.format("nil button info sent to emit_button_attribute_events for %s", + (button_device and (button_device.label or button_device.id)) or "unknown device")) + return + end + + if button_info.power_state and type(button_info.power_state.battery_level) == "number" then + log.debug("emit power") + button_device:emit_event( + capabilities.battery.battery( + st_utils.clamp_value(button_info.power_state.battery_level, 0, 100) + ) + ) + end + + local button_idx_map = button_device:get_field(Fields.BUTTON_INDEX_MAP) + if not button_idx_map then + log.error( + string.format( + "Button ID to Button Index map lost, " .. + "cannot find componenet to emit attribute event on for button [%s]", + (button_device and button_device.lable) or "unknown button" + ) + ) + return + end + + local idx = button_idx_map[button_info.id] or 1 + local component_idx + if idx == 1 then + component_idx = "main" + else + component_idx = string.format("button%s", idx) + end + + local button_report = (button_info.button and button_info.button.button_report) or { event = "" } + + if button_report.event == "long_press" and not button_device:get_field("button_held") then + button_device:set_field("button_held", true) + button_device.profile.components[component_idx]:emit_event( + capabilities.button.button.held({state_change = true}) + ) + end + + if button_report.event == "long_release" and button_device:get_field("button_held") then + button_device:set_field("button_held", false) + end + + if button_report.event == "short_release" and not button_device:get_field("button_held") then + button_device.profile.components[component_idx]:emit_event( + capabilities.button.button.pushed({state_change = true}) + ) + end +end + +function AttributeEmitters.emit_contact_sensor_attribute_events(sensor_device, sensor_info) + if sensor_device == nil or (sensor_device and sensor_device.id == nil) then + log.warn("Tried to emit attribute events for a device that has been deleted") + return + end + + if sensor_info == nil then + log.error_with({ hub_logs = true }, + string.format("nil sensor_info sent to emit_contact_sensor_attribute_events for %s", + (sensor_device and (sensor_device.label or sensor_device.id)) or "unknown device")) + return + end + + if sensor_info.power_state and type(sensor_info.power_state.battery_level) == "number" then + log.debug("emit power") + sensor_device:emit_event(capabilities.battery.battery(st_utils.clamp_value(sensor_info.power_state.battery_level, 0, 100))) + end + + if sensor_info.tamper_reports then + log.debug("emit tamper") + local tampered = false + for _, tamper in ipairs(sensor_info.tamper_reports) do + if tamper.state == "tampered" then + tampered = true + break + end + end + + if tampered then + sensor_device:emit_event(capabilities.tamperAlert.tamper.detected()) + else + sensor_device:emit_event(capabilities.tamperAlert.tamper.clear()) + end + end + + if sensor_info.contact_report then + log.debug("emit contact") + if sensor_info.contact_report.state == "contact" then + sensor_device:emit_event(capabilities.contactSensor.contact.closed()) + else + sensor_device:emit_event(capabilities.contactSensor.contact.open()) + end + end +end + +function AttributeEmitters.emit_motion_sensor_attribute_events(sensor_device, sensor_info) + if sensor_device == nil or (sensor_device and sensor_device.id == nil) then + log.warn("Tried to emit attribute events for a device that has been deleted") + return + end + + if sensor_info == nil then + log.error_with({ hub_logs = true }, + string.format("nil sensor_info sent to emit_motion_sensor_attribute_events for %s", + (sensor_device and (sensor_device.label or sensor_device.id)) or "unknown device")) + return + end + + if sensor_info.power_state and type(sensor_info.power_state.battery_level) == "number" then + log.debug("emit power") + sensor_device:emit_event(capabilities.battery.battery(st_utils.clamp_value(sensor_info.power_state.battery_level, 0, 100))) + end + + if sensor_info.temperature and sensor_info.temperature.temperature_valid then + log.debug("emit temp") + sensor_device:emit_event(capabilities.temperatureMeasurement.temperature({ + value = sensor_info.temperature.temperature, + unit = "C" + })) + end + + if sensor_info.light and sensor_info.light.light_level_valid then + log.debug("emit light") + -- From the Hue docs: Light level in 10000*log10(lux) +1 + local raw_light_level = sensor_info.light.light_level + -- Convert from the Hue value to lux + local lux = st_utils.round(10^((raw_light_level - 1) / 10000)) + sensor_device:emit_event(capabilities.illuminanceMeasurement.illuminance(lux)) + end + + if sensor_info.motion and sensor_info.motion.motion_valid then + log.debug("emit motion") + if sensor_info.motion.motion then + sensor_device:emit_event(capabilities.motionSensor.motion.active()) + else + sensor_device:emit_event(capabilities.motionSensor.motion.inactive()) + end + end +end + +---@param light_device HueChildDevice +---@param light_repr table +function AttributeEmitters.emit_light_attribute_events(light_device, light_repr) + if light_device == nil or (light_device and light_device.id == nil) then + log.warn("Tried to emit light status event for device that has been deleted") + return + end + + if light_repr == nil then + log.error_with({ hub_logs = true }, + string.format("nil light_repr sent to emit_light_attribute_events for %s", + (light_device and (light_device.label or light_device.id)) or "unknown device")) + return + end + local success, maybe_err = pcall(_emit_light_events_inner, light_device, light_repr) + if not success then + log.error_with({ hub_logs = true }, string.format("Failed to invoke emit light status handler. Reason: %s", maybe_err)) + end +end + +local function noop_event_emitter(device, ...) + local label = (device and device.label) or "Unknown Device Name" + local device_type = (device and utils.determine_device_type(device)) or "Unknown Device Type" + log.warn(string.format("Tried to find attribute event emitter for device [%s] of unsupported type [%s], ignoring", label, device_type)) +end + +function AttributeEmitters.emitter_for_device_type(device_type) + return device_type_emitter_map[device_type] or noop_event_emitter +end + +-- TODO: Generalize this like the other handlers, and maybe even separate out non-primary services +device_type_emitter_map[HueDeviceTypes.BUTTON] = AttributeEmitters.emit_button_attribute_events +device_type_emitter_map[HueDeviceTypes.CONTACT] = AttributeEmitters.emit_contact_sensor_attribute_events +device_type_emitter_map[HueDeviceTypes.LIGHT] = AttributeEmitters.emit_light_attribute_events +device_type_emitter_map[HueDeviceTypes.MOTION] = AttributeEmitters.emit_motion_sensor_attribute_events + +return AttributeEmitters diff --git a/drivers/SmartThings/philips-hue/src/handlers/commands.lua b/drivers/SmartThings/philips-hue/src/handlers/commands.lua new file mode 100644 index 0000000000..480f003769 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/commands.lua @@ -0,0 +1,278 @@ +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Consts = require "consts" +local Fields = require "fields" +local HueColorUtils = require "utils.cie_utils" +local attribute_emitters = require "handlers.attribute_emitters" + +local utils = require "utils" + +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +---@class CommandHandlers +local CommandHandlers = {} + +---@param driver HueDriver +---@param device HueChildDevice +local function get_light_device_id_and_hue_api_module(driver, device) + local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) + + if not bridge_device then + log.warn( + "Couldn't get a bridge for light with Child Key " .. + (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) + return + end + + local light_id = utils.get_hue_rid(device) + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + + if not (light_id and hue_api) then + log.warn( + string.format( + "Could not get a proper light resource ID or API instance for %s" .. + "\n\tLight Resource ID: %s" .. + "\n\tHue API nil? %s", + (device.label or device.id or "unknown device"), + light_id, + (hue_api == nil) + ) + ) + return + end + + return light_id, hue_api +end + +---@param response table? Command response from the Hue API, expected to have an 'errors' field if there were issues +---@param err string? Error message returned from the Hue API call, if any +---@param action_desc string Description of the action being performed, for logging purposes +local function log_command_response_errors(response, err, action_desc) + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing " .. action_desc .. ": " .. err) + end + + if response and response.errors and #response.errors > 0 then + for _, error in ipairs(response.errors) do + log.error_with({ hub_logs = true }, + "Error returned in Hue response for " .. action_desc .. ": " .. error.description) + end + end +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_switch_action(driver, device, args) + local on = args.command == "on" + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + local resp, err = hue_api:set_light_on_state(light_id, on) + log_command_response_errors(resp, err, "on/off action") +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_switch_level_action(driver, device, args) + local level = st_utils.clamp_value(args.args.level, 1, 100) + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + local is_off = device:get_field(Fields.SWITCH_STATE) == "off" + if is_off then + local resp, err = hue_api:set_light_on_state(light_id, true) + log_command_response_errors(resp, err, "on/off action") + end + local resp, err = hue_api:set_light_level(light_id, level) + log_command_response_errors(resp, err, "switch level action") +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_color_action(driver, device, args) + local hue, sat = (args.args.color.hue / 100), (args.args.color.saturation / 100) + if hue == 1 then -- 0 and 360 degrees are equivalent in HSV, but not in our conversion function + hue = 0 + device:set_field(Fields.WRAPPED_HUE, true) + end + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + local red, green, blue = st_utils.hsv_to_rgb(hue, sat) + local xy = HueColorUtils.safe_rgb_to_xy(red, green, blue, device:get_field(Fields.GAMUT)) + local resp, err = hue_api:set_light_color_xy(light_id, xy) + log_command_response_errors(resp, err, "color action") +end + +-- Function to allow changes to "setHue" attribute to Philips Hue light devices +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_setHue_action(driver, device, args) + + -- Use existing 'saturation' value for device or set to 0 and pass arg values to function 'do_color_action' + local currentSaturation = device:get_field(Fields.COLOR_SATURATION) or 0 + args.args.color = { + hue = args.args.hue, + saturation = currentSaturation + } + do_color_action(driver, device, args) +end + +-- Function to allow changes to "setSaturation" attribute to Philips Hue light devices +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_setSaturation_action(driver, device, args) + + -- Use existing 'hue' value for device or set to 0 and pass arg values to function 'do_color_action' + local currentHue = device:get_field(Fields.COLOR_HUE) or 0 + args.args.color = { + hue = currentHue, + saturation = args.args.saturation + } + do_color_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_color_temp_action(driver, device, args) + local kelvin = args.args.temperature + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + local min = device:get_field(Fields.MIN_KELVIN) or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE + local clamped_kelvin = st_utils.clamp_value(kelvin, min, Consts.MAX_TEMP_KELVIN) + local mirek = math.floor(utils.kelvin_to_mirek(clamped_kelvin)) + attribute_emitters.emit_color_temp_when_mirek_unchanged(device, clamped_kelvin, mirek) + + local resp, err = hue_api:set_light_color_temp(light_id, mirek) + log_command_response_errors(resp, err, "color temp action") + device:set_field(Fields.COLOR_TEMP_SETPOINT, clamped_kelvin); +end + + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_step_level_action(driver, device, args) + local step_percent = args.args and args.args.stepSize or 0 + if step_percent == 0 then return end + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + -- stepSize is already in percent; Hue brightness_delta is also in percent + local action = (step_percent > 0) and "up" or "down" + local brightness_delta = math.abs(step_percent) + local resp, err = hue_api:set_light_level_delta(light_id, brightness_delta, action) + log_command_response_errors(resp, err, "step level action") +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_step_color_temp_action(driver, device, args) + local step_percent = args.args and args.args.stepSize or 0 + if step_percent == 0 then return end + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + -- Reminder, stepSize > 0 == Kelvin UP == Mireds DOWN. stepSize < 0 == Kelvin DOWN == Mireds UP + local action = (step_percent > 0) and "down" or "up" + + -- Derive the mirek range from stored Kelvin bounds (note: higher Kelvin = lower mirek) + local min_kelvin = device:get_field(Fields.MIN_KELVIN) or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE + local max_kelvin = device:get_field(Fields.MAX_KELVIN) or Consts.MAX_TEMP_KELVIN + local min_mirek = math.floor(utils.kelvin_to_mirek(max_kelvin)) + local max_mirek = math.ceil(utils.kelvin_to_mirek(min_kelvin)) + local mirek_delta = st_utils.round((max_mirek - min_mirek) * (math.abs(step_percent) / 100.0)) + + local resp, err = hue_api:set_light_color_temp_delta(light_id, mirek_delta, action) + log_command_response_errors(resp, err, "step color temp action") +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.switch_on_handler(driver, device, args) + do_switch_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.switch_off_handler(driver, device, args) + do_switch_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.switch_level_handler(driver, device, args) + do_switch_level_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.set_color_handler(driver, device, args) + do_color_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.set_hue_handler(driver, device, args) + do_setHue_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.set_saturation_handler(driver, device, args) + do_setSaturation_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.set_color_temp_handler(driver, device, args) + do_color_temp_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.step_level_handler(driver, device, args) + do_step_level_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.step_color_temp_handler(driver, device, args) + do_step_color_temp_action(driver, device, args) +end + +local refresh_handlers = require "handlers.refresh_handlers" + +---@param driver HueDriver +---@param device HueDevice +---@param cmd table? +---@return table? refreshed_device_info +function CommandHandlers.refresh_handler(driver, device, cmd) + return refresh_handlers.handler_for_device_type(utils.determine_device_type(device))(driver, device, cmd) +end + +return CommandHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua new file mode 100644 index 0000000000..266971b99c --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua @@ -0,0 +1,271 @@ +local log = require "log" +local st_utils = require "st.utils" +local Consts = require "consts" +local Fields = require "fields" +local HueColorUtils = require "utils.cie_utils" +local grouped_utils = require "utils.grouped_utils" +local attribute_emitters = require "handlers.attribute_emitters" +local utils = require "utils" + + +---@class GroupedLightCommandHandlers +local GroupedLightCommandHandlers = {} + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +local function do_switch_action(driver, bridge_device, group, args) + local on = args.command == "on" + + local grouped_light_id = group.grouped_light_rid + if not grouped_light_id then + log.error(string.format("Couldn't find grouped light id for group %s", + group.id or "unknown group id")) + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if not hue_api then + log.error(string.format("Couldn't find api instance for bridge %s", + bridge_device.label or bridge_device.id or "unknown bridge")) + return + end + + local resp, err = hue_api:set_grouped_light_on_state(grouped_light_id, on) + if not resp or (resp.errors and #resp.errors == 0) then + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) + elseif resp and #resp.errors > 0 then + for _, error in ipairs(resp.errors) do + log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) + end + end + end +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +local function do_switch_level_action(driver, bridge_device, group, args) + local level = st_utils.clamp_value(args.args.level, 1, 100) + + local grouped_light_id = group.grouped_light_rid + if not grouped_light_id then + log.error(string.format("Couldn't find grouped light id for group %s", + group.id or "unknown group id")) + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if not hue_api then + log.error(string.format("Couldn't find api instance for bridge %s", + bridge_device.label or bridge_device.id or "unknown bridge")) + return + end + + -- An individual command checks the state of the device before doing this. + -- It is probably not worth iterating through all the devices to check their state. + local resp, err = hue_api:set_grouped_light_on_state(grouped_light_id, true) + if not resp or (resp.errors and #resp.errors == 0) then + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) + elseif resp and #resp.errors > 0 then + for _, error in ipairs(resp.errors) do + log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) + end + end + end + + local resp, err = hue_api:set_grouped_light_level(grouped_light_id, level) + if not resp or (resp.errors and #resp.errors == 0) then + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing switch level action: " .. err) + elseif resp and #resp.errors > 0 then + for _, error in ipairs(resp.errors) do + log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) + end + end + end +end + + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +local function do_color_action(driver, bridge_device, group, args, aux) + local hue, sat = (args.args.color.hue / 100), (args.args.color.saturation / 100) + if hue == 1 then -- 0 and 360 degrees are equivalent in HSV, but not in our conversion function + hue = 0 + grouped_utils.set_field_on_group_devices(group, Fields.WRAPPED_HUE, true) + end + + local grouped_light_id = group.grouped_light_rid + if not grouped_light_id then + log.error(string.format("Couldn't find grouped light id for group %s", + group.id or "unknown group id")) + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if not hue_api then + log.error(string.format("Couldn't find api instance for bridge %s", + bridge_device.label or bridge_device.id or "unknown bridge")) + return + end + + local red, green, blue = st_utils.hsv_to_rgb(hue, sat) + local xy = HueColorUtils.safe_rgb_to_xy(red, green, blue, aux[Fields.GAMUT]) + + local resp, err = hue_api:set_grouped_light_color_xy(grouped_light_id, xy) + if not resp or (resp.errors and #resp.errors == 0) then + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing color action: " .. err) + elseif resp and #resp.errors > 0 then + for _, error in ipairs(resp.errors) do + log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) + end + end + end +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +local function do_setHue_action(driver, bridge_device, group, args, aux) + local currentSaturation = aux[Fields.COLOR_SATURATION] or 0 + args.args.color = { + hue = args.args.hue, + saturation = currentSaturation + } + do_color_action(driver, bridge_device, group, args, aux) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +local function do_setSaturation_action(driver, bridge_device, group, args, aux) + local currentHue = aux[Fields.COLOR_HUE] or 0 + args.args.color = { + hue = currentHue, + saturation = args.args.saturation + } + do_color_action(driver, bridge_device, group, args, aux) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +local function do_color_temp_action(driver, bridge_device, group, args, aux) + local kelvin = args.args.temperature + + local grouped_light_id = group.grouped_light_rid + if not grouped_light_id then + log.error(string.format("Couldn't find grouped light id for group %s", + group.id or "unknown group id")) + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if not hue_api then + log.error(string.format("Couldn't find api instance for bridge %s", + bridge_device.label or bridge_device.id or "unknown bridge")) + return + end + + local min = aux[Fields.MIN_KELVIN] or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE + local clamped_kelvin = st_utils.clamp_value(kelvin, min, Consts.MAX_TEMP_KELVIN) + local mirek = math.floor(utils.kelvin_to_mirek(clamped_kelvin)) + + for _, device in ipairs(group.devices) do + attribute_emitters.emit_color_temp_when_mirek_unchanged(device, clamped_kelvin, mirek) + end + + local resp, err = hue_api:set_grouped_light_color_temp(grouped_light_id, mirek) + + if not resp or (resp.errors and #resp.errors == 0) then + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing color temp action: " .. err) + elseif resp and #resp.errors > 0 then + for _, error in ipairs(resp.errors) do + log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) + end + end + end + grouped_utils.set_field_on_group_devices(group, Fields.COLOR_TEMP_SETPOINT, clamped_kelvin); +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +function GroupedLightCommandHandlers.switch_on_handler(driver, bridge_device, group, args, aux) + do_switch_action(driver, bridge_device, group, args) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +function GroupedLightCommandHandlers.switch_off_handler(driver, bridge_device, group, args, aux) + do_switch_action(driver, bridge_device, group, args) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +function GroupedLightCommandHandlers.switch_level_handler(driver, bridge_device, group, args, aux) + do_switch_level_action(driver, bridge_device, group, args) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +function GroupedLightCommandHandlers.set_color_handler(driver, bridge_device, group, args, aux) + do_color_action(driver, bridge_device, group, args, aux) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +function GroupedLightCommandHandlers.set_hue_handler(driver, bridge_device, group, args, aux) + do_setHue_action(driver, bridge_device, group, args, aux) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +function GroupedLightCommandHandlers.set_saturation_handler(driver, bridge_device, group, args, aux) + do_setSaturation_action(driver, bridge_device, group, args, aux) +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param group table +---@param args table +---@param aux table auxiliary data needed for the command that the devices all had in common +function GroupedLightCommandHandlers.set_color_temp_handler(driver, bridge_device, group, args, aux) + do_color_temp_action(driver, bridge_device, group, args, aux) +end + + +return GroupedLightCommandHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/bridge.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/bridge.lua new file mode 100644 index 0000000000..68973a4cb2 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/bridge.lua @@ -0,0 +1,194 @@ +local cosock = require "cosock" +local log = require "log" + +local Discovery = require "disco" +local Fields = require "fields" +local HueApi = require "hue.api" +local bridge_utils = require "utils.hue_bridge_utils" +local utils = require "utils" + +local BridgeLifecycleHandlers = {} + +---@param driver HueDriver +---@param device HueBridgeDevice +function BridgeLifecycleHandlers.added(driver, device) + log.info_with({ hub_logs = true }, + string.format("Bridge Added for device %s", (device.label or device.id or "unknown device"))) + local device_bridge_id = device.device_network_id + local bridge_info = driver.datastore.bridge_netinfo[device_bridge_id] + + if not bridge_info then + cosock.spawn( + function() + local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) + local sleep_time = 0.1 + local bridge_found = false + + while true do + log.info( + string.format( + "[BridgeAdded] Scanning the network for Hue Bridge matching device %s", + (device.label or device.id or "unknown device") + ) + ) + Discovery.search_for_bridges(driver, function(driver, bridge_ip, bridge_id) + if bridge_id ~= device_bridge_id then return end + + log.info(string.format( + "[BridgeAdded] Found Hue Bridge on the network for %s, querying configuration values", + (device.label or device.id or "unknown device") + ) + ) + bridge_info = driver.datastore.bridge_netinfo[bridge_id] + + if not bridge_info then + log.debug(string.format("Bridge info for %s not yet available", bridge_id)) + return + end + + if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then + log.warn("Found bridge that does not support CLIP v2 API, ignoring") + driver.ignored_bridges[bridge_id] = true + return + end + + driver.joined_bridges[bridge_id] = true + bridge_utils.update_bridge_fields_from_info(driver, bridge_info, device) + device:set_field(Fields._ADDED, true, { persist = true }) + bridge_found = true + end) + if bridge_found then return end + + if sleep_time < 10 then + sleep_time = backoff_generator() + end + log.warn( + string.format( + "[BridgeAdded] Failed to find bridge info for device %s, waiting %s seconds then trying again", + (device.label or device.id or "unknown device"), + sleep_time + ) + ) + cosock.socket.sleep(sleep_time) + end + end, + "bridge added re-scan" + ) + else + bridge_utils.update_bridge_fields_from_info(driver, bridge_info, device) + device:set_field(Fields._ADDED, true, { persist = true }) + end + + if not Discovery.api_keys[device_bridge_id] then + log.error_with({ hub_logs = true }, + string.format( + "Received `added` lifecycle event for bridge %s with unknown API key, " .. + "please press the Link Button on your Hue Bridge(s).", + (device.label or device.device_network_id or device.id or "unknown bridge") + ) + ) + -- we have to do a long poll here to give the user a chance to hit the link button on the + -- hue bridge, so in this specific pathological case we start a new coroutine and complete + -- the handling of the device add on a different task to free up the driver task + bridge_utils.spawn_bridge_add_api_key_task(driver, device) + return + end +end + +---@param driver HueDriver +---@param device HueBridgeDevice +function BridgeLifecycleHandlers.init(driver, device) + log.info( + string.format("Init Bridge for device %s", (device.label or device.id or "unknown device"))) + local device_bridge_id = device:get_field(Fields.BRIDGE_ID) + ---@type PhilipsHueApi + local bridge_manager = device:get_field(Fields.BRIDGE_API) or Discovery.disco_api_instances[device_bridge_id] + + local ip = device:get_field(Fields.IPV4) + local api_key = device:get_field(HueApi.APPLICATION_KEY_HEADER) + local bridge_url = "https://" .. ip + + if not Discovery.api_keys[device_bridge_id] then + log.debug(string.format( + "init_bridge for %s, caching API key", (device.label or device.id or "unknown device") + )) + Discovery.api_keys[device_bridge_id] = api_key + end + + if not bridge_manager then + log.debug(string.format( + "init_bridge for %s, creating bridge manager", (device.label or device.id or "unknown device") + )) + bridge_manager = HueApi.new_bridge_manager( + bridge_url, + api_key, + utils.labeled_socket_builder((device.label or device_bridge_id or device.id or "unknown bridge")) + ) + Discovery.disco_api_instances[device_bridge_id] = bridge_manager + end + device:set_field(Fields.BRIDGE_API, bridge_manager, { persist = false }) + + if not driver.api_key_to_bridge_id[api_key] then + log.debug(string.format( + "init_bridge for %s, mapping API key to Bridge DNI", (device.label or device.id or "unknown device") + )) + driver.api_key_to_bridge_id[api_key] = device_bridge_id + end + + if not driver.joined_bridges[device_bridge_id] then + log.debug(string.format( + "init_bridge for %s, cacheing bridge info", (device.label or device.id or "unknown device") + )) + cosock.spawn( + function() + local bridge_info + -- create a very slow backoff + local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) + local sleep_time = 0.1 + while true do + log.info( + string.format( + "[BridgeInit] Querying bridge %s for configuration values", + (device.label or device.id or "unknown device") + ) + ) + bridge_info = driver.datastore.bridge_netinfo[device_bridge_id] + + if not bridge_info then + log.debug(string.format("Bridge info for %s not yet available", device_bridge_id)) + goto continue + else + if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then + log.warn("Found bridge that does not support CLIP v2 API, ignoring") + driver.ignored_bridges[device_bridge_id] = true + return + end + + log.info(string.format("Bridge info for %s received, initializing network configuration", device_bridge_id)) + driver.joined_bridges[device_bridge_id] = true + bridge_utils.do_bridge_network_init(driver, device, bridge_url, api_key) + return + end + + ::continue:: + if sleep_time < 10 then + sleep_time = backoff_generator() + end + log.warn( + string.format( + "[BridgeInit] Failed to find bridge info for device %s, waiting %s seconds then trying again", + (device.label or device.id or "unknown device"), + sleep_time + ) + ) + cosock.socket.sleep(sleep_time) + end + end, + "bridge init" + ) + else + bridge_utils.do_bridge_network_init(driver, device, bridge_url, api_key) + end +end + +return BridgeLifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/button.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/button.lua new file mode 100644 index 0000000000..d10ad5ee65 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/button.lua @@ -0,0 +1,191 @@ +local capabilities = require "st.capabilities" +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Discovery = require "disco" +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" +local StrayDeviceHelper = require "stray_device_helper" + +local button_disco = require "disco.button" +local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" +local utils = require "utils" + +---@class ButtonLifecycleHandlers +local ButtonLifecycleHandlers = {} + +---@param driver HueDriver +---@param device HueChildDevice +---@param parent_device_id string? +---@param resource_id string? +function ButtonLifecycleHandlers.added(driver, device, parent_device_id, resource_id) + log.info( + string.format("Button Added for device %s", (device.label or device.id or "unknown device"))) + local device_button_resource_id = resource_id or utils.get_hue_rid(device) + if not device_button_resource_id then + log.error( + string.format( + "Could not determine the Hue Resource ID for added button %s", + (device and device.label) or "unknown button" + ) + ) + return + end + + local button_info = Discovery.device_state_disco_cache[device_button_resource_id] + if not button_info then + log.error( + string.format( + "Expected button info to be cached, sending button %s to stray resolver", + (device and device.label) or "unknown button" + ) + ) + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + + local button_rid_to_index_map = {} + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + if button_info.button then + hue_id_to_device[button_info.id] = device + button_rid_to_index_map[button_info.id] = 1 + end + + if button_info.num_buttons then + for var = 1, button_info.num_buttons do + local button_key = string.format("button%s", var) + local button_id_key = string.format("%s_id", button_key) + local button = button_info[button_key] + local button_id = button_info[button_id_key] + + if button and button_id then + hue_id_to_device[button_id] = device + button_rid_to_index_map[button_id] = var + + local supported_button_values = utils.get_supported_button_values(button.event_values) + local component + if var == 1 then + component = "main" + else + component = button_key + end + device.profile.components[component]:emit_event( + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + end + end + end + + if button_info.power_id then + hue_id_to_device[button_info.power_id] = device + end + + log.debug(st_utils.stringify_table(button_rid_to_index_map, "button index map", true)) + device:set_field(Fields.BUTTON_INDEX_MAP, button_rid_to_index_map, { persist = true }) + device:set_field(Fields.DEVICE_TYPE, HueDeviceTypes.BUTTON, { persist = true }) + device:set_field(Fields.HUE_DEVICE_ID, button_info.hue_device_id, { persist = true }) + device:set_field(Fields.PARENT_DEVICE_ID, button_info.parent_device_id, { persist = true }) + device:set_field(Fields.RESOURCE_ID, device_button_resource_id, { persist = true }) + device:set_field(Fields._ADDED, true, { persist = true }) + device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) + + hue_id_to_device[device_button_resource_id] = device +end + +---@param driver HueDriver +---@param device HueChildDevice +function ButtonLifecycleHandlers.init(driver, device) + log.info( + string.format("Init Button for device %s", (device and device.label or device.id or "unknown button"))) + device:set_field(Fields.IS_MULTI_SERVICE, true, { persist = true }) + local device_button_resource_id = + utils.get_hue_rid(device) or + device.device_network_id + + log.debug("resource id " .. tostring(device_button_resource_id)) + + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + if not hue_id_to_device[device_button_resource_id] then + hue_id_to_device[device_button_resource_id] = device + end + + local maybe_idx_map = device:get_field(Fields.BUTTON_INDEX_MAP) + local svc_rids_for_device = driver.services_for_device_rid[hue_device_id] or {} + + if not svc_rids_for_device[device_button_resource_id] then + svc_rids_for_device[device_button_resource_id] = HueDeviceTypes.BUTTON + end + + for resource_id, _ in pairs(maybe_idx_map or {}) do + if not hue_id_to_device[resource_id] then + hue_id_to_device[resource_id] = device + end + + if not svc_rids_for_device[resource_id] then + svc_rids_for_device[resource_id] = HueDeviceTypes.BUTTON + end + end + driver.services_for_device_rid[hue_device_id] = svc_rids_for_device + + local button_info, err + button_info = Discovery.device_state_disco_cache[device_button_resource_id] + if not button_info then + log.debug("no button info") + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) + local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) + + if parent_bridge and api_instance then + button_info, err = button_disco.update_state_for_all_device_services( + driver, + api_instance, + hue_device_id, + parent_bridge.device_network_id, + Discovery.device_state_disco_cache + ) + if err then + log.error( + st_utils.stringify_table( + err, + string.format( + "Error populating initial state for button %s", + (device and device.label) or "unknown button" + ), + true + ) + ) + end + end + end + if not button_info then + log.warn(string.format("Button %s parent bridge not ready, queuing refresh", device and device.label)) + driver._devices_pending_refresh[device.id] = device + else + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, button_info, HueDeviceTypes.BUTTON + ) + end + device:set_field(Fields._INIT, true, { persist = false }) + if device:get_field(Fields._REFRESH_AFTER_INIT) then + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) + end +end + +return ButtonLifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua new file mode 100644 index 0000000000..eaef1a2ccd --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/contact.lua @@ -0,0 +1,135 @@ +local capabilities = require "st.capabilities" +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Discovery = require "disco" +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" +local StrayDeviceHelper = require "stray_device_helper" + +local contact_sensor_disco = require "disco.contact" +local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" +local utils = require "utils" + +---@class ContactLifecycleHandlers +local ContactLifecycleHandlers = {} + +---@param driver HueDriver +---@param device HueChildDevice +---@param parent_device_id string? +---@param resource_id string? +function ContactLifecycleHandlers.added(driver, device, parent_device_id, resource_id) + log.info( + string.format("Contact Sensor Added for device %s", (device.label or device.id or "unknown device"))) + local device_sensor_resource_id = resource_id or utils.get_hue_rid(device) + if not device_sensor_resource_id then + log.error( + string.format( + "Could not determine the Hue Resource ID for added contact sensor %s", + (device and device.label) or "unknown sensor" + ) + ) + return + end + + local sensor_info = Discovery.device_state_disco_cache[device_sensor_resource_id] + if not sensor_info then + log.error( + string.format( + "Expected sensor info to be cached, sending contact sensor %s to stray resolver", + (device and device.label) or "unknown sensor" + ) + ) + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + hue_id_to_device[sensor_info.power_id] = device + hue_id_to_device[sensor_info.tamper_id] = device + + device:set_field(Fields.DEVICE_TYPE, HueDeviceTypes.CONTACT, { persist = true }) + device:set_field(Fields.HUE_DEVICE_ID, sensor_info.hue_device_id, { persist = true }) + device:set_field(Fields.PARENT_DEVICE_ID, sensor_info.parent_device_id, { persist = true }) + device:set_field(Fields.RESOURCE_ID, device_sensor_resource_id, { persist = true }) + device:set_field(Fields._ADDED, true, { persist = true }) + device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) + + hue_id_to_device[device_sensor_resource_id] = device +end + +---@param driver HueDriver +---@param device HueChildDevice +function ContactLifecycleHandlers.init(driver, device) + log.info( + string.format("Init Contact Sensor for device %s", (device and device.label or device.id or "unknown sensor"))) + device:set_field(Fields.IS_MULTI_SERVICE, true, { persist = true }) + local device_sensor_resource_id = + utils.get_hue_rid(device) or + device.device_network_id + + log.debug("resource id " .. tostring(device_sensor_resource_id)) + + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + if not hue_id_to_device[device_sensor_resource_id] then + hue_id_to_device[device_sensor_resource_id] = device + end + local sensor_info, err + sensor_info = Discovery.device_state_disco_cache[device_sensor_resource_id] + if not sensor_info then + log.debug("no sensor info") + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) + local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) + + if parent_bridge and api_instance then + sensor_info, err = contact_sensor_disco.update_state_for_all_device_services( + driver, + api_instance, + hue_device_id, + parent_bridge.device_network_id, + Discovery.device_state_disco_cache + ) + if err then + log.error( + st_utils.stringify_table( + err, + string.format( + "Error populating initial state for sensor %s", + (device and device.label) or "unknown sensor" + ), + true + ) + ) + end + end + end + if not sensor_info then + log.warn(string.format("Contact Sensor %s parent bridge not ready, queuing refresh", device and device.label)) + driver._devices_pending_refresh[device.id] = device + else + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, sensor_info, HueDeviceTypes.CONTACT + ) + end + device:set_field(Fields._INIT, true, { persist = false }) + if device:get_field(Fields._REFRESH_AFTER_INIT) then + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) + end +end + +return ContactLifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/init.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/init.lua new file mode 100644 index 0000000000..7724246595 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/init.lua @@ -0,0 +1,205 @@ +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local capabilities = require "st.capabilities" + +local Discovery = require "disco" +local Fields = require "fields" +local HueApi = require "hue.api" +local HueDeviceTypes = require "hue_device_types" +local StrayDeviceHelper = require "stray_device_helper" + +local utils = require "utils" + +local function check_parent_assigned_child_key(device) + local device_type = utils.determine_device_type(device) + local device_rid = utils.get_hue_rid(device) + + if type(device_type) == "string" and type(device_rid) == "string" then + local expected_parent_assigned_child_key = string.format("%s:%s", device_type, device_rid) + if expected_parent_assigned_child_key ~= device.parent_assigned_child_key then + log.debug( + string.format( + "\n\nDevice [%s] had parent-assigned child key of %s but expected %s, requesting metadata update\n\n", + (device and device.label) or "unknown device", + device.parent_assigned_child_key, + expected_parent_assigned_child_key + ) + ) + -- updating parent_assigned_child_key in metadata isn't supported + -- on Lua Libs API versions before 11. + if require("version").api <= 10 then + return + end + device:try_update_metadata({ parent_assigned_child_key = expected_parent_assigned_child_key }) + end + end +end + +-- Lazy-load the lifecycle handlers so we only load the code we need +local inner_handlers = utils.lazy_handler_loader("handlers.lifecycle_handlers") + +-- Lazy-load the migration handlers so we only load the code we need +local migration_handlers = utils.lazy_handler_loader("handlers.migration_handlers") + +---@class LifecycleHandlers +local LifecycleHandlers = {} + +local function emit_component_event_no_cache(device, component, capability_event) + if not device:supports_capability(capability_event.capability, component.id) then + local err_msg = string.format("Attempted to generate event for %s.%s but it does not support capability %s", device.id, component.id, capability_event.capability.NAME) + log.warn_with({ hub_logs = true }, err_msg) + return false, err_msg + end + local event, err = capabilities.emit_event(device, component.id, device.capability_channel, capability_event) + if err ~= nil then + log.warn_with({ hub_logs = true }, err) + end + return event, err +end + +---@param driver HueDriver +---@param device HueDevice +---@param ... any arguments for device specific handler +function LifecycleHandlers.device_init(driver, device, ...) + local device_type = utils.determine_device_type(device) + log.info( + string.format + ("device_init for device %s, device_type: %s", (device.label or device.id or "unknown device"), + device_type + ) + ) + inner_handlers[device_type].init(driver, device, ...) + + -- Remove usage of the state cache for hue devices to avoid large datastores + device:set_field("__state_cache", nil, {persist = true}) + device:extend_device("emit_component_event", emit_component_event_no_cache) +end + +---@param driver HueDriver +---@param device HueDevice +---@param ... any arguments for device specific handler +function LifecycleHandlers.device_added(driver, device, ...) + log.info( + string.format("device_added for device %s", (device.label or device.id or "unknown device")) + ) + if utils.is_dth_bridge(device) or utils.is_dth_light(device) then + LifecycleHandlers.migrate_device(driver, device, ...) + else + local device_type = utils.determine_device_type(device) + if device_type ~= HueDeviceTypes.BRIDGE then + ---@cast device HueChildDevice + local resource_id = utils.get_hue_rid(device) + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + if resource_id then + hue_id_to_device[resource_id] = device + end + + local resource_state_known = (Discovery.device_state_disco_cache[resource_id] ~= nil) + log.info( + string.format("Querying device info for parent of %s", (device.label or device.id or "unknown device"))) + + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) + + local key = parent_bridge and parent_bridge:get_field(HueApi.APPLICATION_KEY_HEADER) + local bridge_ip = parent_bridge and parent_bridge:get_field(Fields.IPV4) + local bridge_id = parent_bridge and parent_bridge:get_field(Fields.BRIDGE_ID) + + if not (bridge_ip and bridge_id and resource_state_known and (Discovery.api_keys[bridge_id or {}] or key)) then + log.warn( + "Found \"stray\" bulb without associated Hue Bridge. Waiting to see if a bridge becomes available.") + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + end + if not inner_handlers[device_type] then + log.warn( + st_utils.stringify_table(device, + string.format("Device Added %s does not appear to be a bridge or bulb", + device.label or device.id or "unknown device"), true) + ) + end + inner_handlers[device_type].added(driver, device, ...) + end +end + +local valid_migration_kwargs = { + "force_migrate_type" +} +function LifecycleHandlers.migrate_device(driver, device, ...) + local migration_kwargs = select(-1, ...) + local opts = {} + if type(migration_kwargs) == "table" then + for _, key in ipairs(valid_migration_kwargs) do + if migration_kwargs[key] then + opts[key] = migration_kwargs[key] + end + end + end + if utils.is_dth_bridge(device) or opts.force_migrate_type == HueDeviceTypes.BRIDGE then + migration_handlers.bridge.migrate(driver, device, LifecycleHandlers, ...) + elseif utils.is_dth_light(device) or opts.force_migrate_type == HueDeviceTypes.LIGHT then + migration_handlers.light.migrate(driver, device, LifecycleHandlers, ...) + -- Don't do a refresh if it's a migration + device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) + end +end + +--- Callback that is used for both `init` and `added` events. It will properly dispatch +--- to the correct handler based on the state of the device record. +--- +--- This is currently in place as a workaround for situations where init will sometimes +--- be missed when onboarding lots of child devices at once. +---@param driver HueDriver +---@param device HueDevice +---@param event string? +---@param _args table? unused +---@param ... any additional arguments +function LifecycleHandlers.initialize_device(driver, device, event, _args, ...) + local maybe_device = driver:get_device_by_dni(device.device_network_id) + if not ( + maybe_device + and maybe_device.id == device.id + ) + then + driver.datastore.dni_to_device_id[device.device_network_id] = device.id + end + + if device:get_field(Fields.RETRY_MIGRATION) then + LifecycleHandlers.migrate_device(driver, device, ...) + return + end + + log.info( + string.format("_initialize handling event %s for device %s", event, (device.label or device.id or "unknown device"))) + + if not device:get_field(Fields._ADDED) then + log.debug( + string.format( + "_ADDED for device %s not set while _initialize is handling %s, performing added lifecycle operations", + (device.label or device.id or "unknown device"), event)) + LifecycleHandlers.device_added(driver, device, ...) + end + + if not device:get_field(Fields._INIT) then + log.debug( + string.format( + "_INIT for device %s not set while _initialize is handling %s, performing device init lifecycle operations", + (device.label or device.id or "unknown device"), event)) + LifecycleHandlers.device_init(driver, device, ...) + if not utils.is_bridge(driver, device) then + check_parent_assigned_child_key(device) + end + end +end + +return LifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua new file mode 100644 index 0000000000..edbfb02944 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua @@ -0,0 +1,244 @@ +local log = require "log" +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Consts = require "consts" +local Discovery = require "disco" +local Fields = require "fields" +local HueApi = require "hue.api" +local HueDeviceTypes = require "hue_device_types" +local StrayDeviceHelper = require "stray_device_helper" + +local utils = require "utils" +local grouped_utils = require "utils.grouped_utils" + +---@class LightLifecycleHandlers +local LightLifecycleHandlers = {} + +---@param driver HueDriver +---@param device HueChildDevice +---@param parent_device_id nil|string +---@param resource_id nil|string +function LightLifecycleHandlers.added(driver, device, parent_device_id, resource_id) + log.info( + string.format("Light Added for device %s", (device.label or device.id or "unknown device"))) + local device_light_resource_id = resource_id or utils.get_hue_rid(device) + if not device_light_resource_id then + log.error( + string.format( + "Could not determine the Hue Resource ID for added light %s", + (device and device.label) or "unknown light" + ) + ) + return + end + + local light_info_known = (Discovery.device_state_disco_cache[device_light_resource_id] ~= nil) + if not light_info_known then + log.info( + string.format("Querying device info for parent of %s", (device.label or device.id or "unknown device"))) + local parent_bridge = utils.get_hue_bridge_for_device(driver, device, parent_device_id) + if not parent_bridge then + log.error_with({ hub_logs = true }, string.format( + "Device %s added with parent UUID of %s but could not find a device with that UUID in the driver", + (device.label or device.id or "unknown device"), + (device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)))) + return + end + + log.info( + string.format( + "Found parent bridge device %s info for %s", + (parent_bridge.label or parent_bridge.device_network_id or parent_bridge.id or "unknown bridge"), + (device.label or device.id or "unknown device") + ) + ) + + local key = parent_bridge:get_field(HueApi.APPLICATION_KEY_HEADER) + local bridge_ip = parent_bridge:get_field(Fields.IPV4) + local bridge_id = parent_bridge:get_field(Fields.BRIDGE_ID) + if not (Discovery.api_keys[bridge_id or {}] or key) then + log.warn( + "Found \"stray\" bulb without associated Hue Bridge. Waiting to see if a bridge becomes available.") + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + + ---@type PhilipsHueApi + local api_instance = + parent_bridge:get_field(Fields.BRIDGE_API) or Discovery.disco_api_instances[bridge_id] + + if not api_instance then + api_instance = HueApi.new_bridge_manager( + "https://" .. bridge_ip, + (parent_bridge:get_field(HueApi.APPLICATION_KEY_HEADER) or Discovery.api_keys[bridge_id] or key), + utils.labeled_socket_builder( + (parent_bridge.label or bridge_id or parent_bridge.id or "unknown bridge") + ) + ) + Discovery.disco_api_instances[parent_bridge.device_network_id] = api_instance + end + + local light_resource, err, _ = api_instance:get_light_by_id(device_light_resource_id) + if err ~= nil or not light_resource then + log.error_with({ hub_logs = true }, "Error getting light info: ", error) + return + end + + if light_resource.errors and #light_resource.errors > 0 then + log.error_with({ hub_logs = true }, "Errors found in API response:") + for idx, err in ipairs(light_resource.errors) do + log.error_with({ hub_logs = true }, st_utils.stringify_table(err, "Error " .. idx, true)) + end + return + end + + for _, light in ipairs(light_resource.data or {}) do + if device_light_resource_id == light.id then + Discovery.device_state_disco_cache[light.id] = { + hue_provided_name = light.metadata.name, + id = light.id, + on = light.on, + color = light.color, + dimming = light.dimming, + color_temperature = light.color_temperature, + mode = light.mode, + parent_device_id = parent_device_id or device.parent_device_id, + hue_device_id = light.owner.rid, + hue_device_data = { + product_data = { + manufacturer_name = device.manufacturer, + model_id = device.model, + product_name = device.vendor_provided_label + } + } + } + light_info_known = true + break + end + end + end + + -- still unable to get information about the bulb over REST API, bailing + if not light_info_known then + log.warn(string.format( + "Couldn't get light info for %s, marking as \"stray\"", (device.label or device.id or "unknown device") + )) + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + + ---@type HueLightInfo + local light_info = Discovery.device_state_disco_cache[device_light_resource_id] + + -- Remembering that mirek are reciprocal to kelvin, note the following: + -- ** Minimum Mirek -> _Maximum_ Kelvin + -- ** Maximum Mirek -> _Minimum_ Kelvin + -- + -- Not necessarily obvious/intuitive, but easy enough to validate with the math: + -- + -- Given that `kelvin = 1000000 / mirek`, and `mirek = 1000000 / kelvin`, pick + -- one of our minimum consts: `Consts.MIN_TEMP_KELVIN_COLOR_AMBIANCE = 2000` + -- + -- `floor(1000000 / 2000) = 500` and 500 is the max mirek we see on almost all Hue lights. + -- Similarly: + -- `floor(1000000 / 6500) = 153`, which is the minimum we see from the API for color control lights. + if light_info.color_temperature and light_info.color_temperature.mirek_schema then + local caps = device.profile.components.main.capabilities + if caps.colorTemperature then + local min_ct_kelvin, max_ct_kelvin = nil, nil + if type(light_info.color_temperature.mirek_schema.mirek_maximum) == "number" then + min_ct_kelvin = math.floor(utils.mirek_to_kelvin(light_info.color_temperature.mirek_schema.mirek_maximum, Consts.KELVIN_STEP_SIZE)) + end + if type(light_info.color_temperature.mirek_schema.mirek_minimum) == "number" then + max_ct_kelvin = math.floor(utils.mirek_to_kelvin(light_info.color_temperature.mirek_schema.mirek_minimum, Consts.KELVIN_STEP_SIZE)) + end + if not min_ct_kelvin then + if caps.colorControl then + min_ct_kelvin = Consts.MIN_TEMP_KELVIN_COLOR_AMBIANCE + else + min_ct_kelvin = Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE + end + end + if not max_ct_kelvin then + max_ct_kelvin = Consts.MAX_TEMP_KELVIN + end + device:set_field(Fields.MIN_KELVIN, min_ct_kelvin, { persist = true }) + device:set_field(Fields.MAX_KELVIN, max_ct_kelvin, { persist = true }) + end + end + + -- persistent fields + device:set_field(Fields.DEVICE_TYPE, "light", { persist = true }) + if light_info.color ~= nil and light_info.color.gamut then + device:set_field(Fields.GAMUT, light_info.color.gamut, { persist = true }) + end + device:set_field(Fields.HUE_DEVICE_ID, light_info.hue_device_id, { persist = true }) + device:set_field(Fields.PARENT_DEVICE_ID, light_info.parent_device_id, { persist = true }) + device:set_field(Fields.RESOURCE_ID, device_light_resource_id, { persist = true }) + device:set_field(Fields._ADDED, true, { persist = true }) + device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) + + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + hue_id_to_device[device_light_resource_id] = device + + -- the refresh handler adds lights that don't have a fully initialized bridge to a queue. + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + + local bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_device_id) + if bridge_device then + grouped_utils.queue_group_scan(driver, bridge_device) + else + log.warn("Unable to queue group scan on device added, missing bridge device") + end +end + +---@param driver HueDriver +---@param device HueChildDevice +function LightLifecycleHandlers.init(driver, device) + log.info( + string.format("Init Light for device %s", (device.label or device.id or "unknown device"))) + + local device_light_resource_id = + utils.get_hue_rid(device) or + device.device_network_id + + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + if not hue_id_to_device[device_light_resource_id] then + hue_id_to_device[device_light_resource_id] = device + end + local svc_rids_for_device = driver.services_for_device_rid[hue_device_id] or {} + if not svc_rids_for_device[device_light_resource_id] then + svc_rids_for_device[device_light_resource_id] = HueDeviceTypes.LIGHT + end + device:set_field(Fields._INIT, true, { persist = false }) + device:emit_event(capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 })) + + if device:get_field(Fields._REFRESH_AFTER_INIT) then + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) + end + driver:check_waiting_grandchildren_for_device(device) +end + +return LightLifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua new file mode 100644 index 0000000000..90b070ff3b --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/motion.lua @@ -0,0 +1,138 @@ +local capabilities = require "st.capabilities" +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Discovery = require "disco" +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" +local StrayDeviceHelper = require "stray_device_helper" + +local motion_sensor_disco = require "disco.motion" +local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" +local utils = require "utils" + +---@class MotionLifecycleHandlers +local MotionLifecycleHandlers = {} + +---@param driver HueDriver +---@param device HueChildDevice +---@param parent_device_id string? +---@param resource_id string? +function MotionLifecycleHandlers.added(driver, device, parent_device_id, resource_id) + log.info( + string.format("Motion Sensor Added for device %s", (device.label or device.id or "unknown device"))) + local device_sensor_resource_id = resource_id or utils.get_hue_rid(device) + if not device_sensor_resource_id then + log.error( + string.format( + "Could not determine the Hue Resource ID for added motion sensor %s", + (device and device.label) or "unknown sensor" + ) + ) + return + end + + local sensor_info = Discovery.device_state_disco_cache[device_sensor_resource_id] + if not sensor_info then + log.error( + string.format( + "Expected sensor info to be cached, sending motion sensor %s to stray resolver", + (device and device.label) or "unknown sensor" + ) + ) + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + + hue_id_to_device[sensor_info.power_id] = device + hue_id_to_device[sensor_info.temperature_id] = device + hue_id_to_device[sensor_info.light_level_id] = device + + device:set_field(Fields.DEVICE_TYPE, HueDeviceTypes.MOTION, { persist = true }) + device:set_field(Fields.HUE_DEVICE_ID, sensor_info.hue_device_id, { persist = true }) + device:set_field(Fields.PARENT_DEVICE_ID, sensor_info.parent_device_id, { persist = true }) + device:set_field(Fields.RESOURCE_ID, device_sensor_resource_id, { persist = true }) + device:set_field(Fields._ADDED, true, { persist = true }) + device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) + + hue_id_to_device[device_sensor_resource_id] = device +end + +---@param driver HueDriver +---@param device HueChildDevice +function MotionLifecycleHandlers.init(driver, device) + log.info( + string.format("Init Motion Sensor for device %s", (device and device.label or device.id or "unknown sensor"))) + device:set_field(Fields.IS_MULTI_SERVICE, true, { persist = true }) + local device_sensor_resource_id = + utils.get_hue_rid(device) or + device.device_network_id + + log.debug("resource id " .. tostring(device_sensor_resource_id)) + + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) or {} + if not hue_id_to_device[device_sensor_resource_id] then + hue_id_to_device[device_sensor_resource_id] = device + end + local sensor_info, err + sensor_info = Discovery.device_state_disco_cache[device_sensor_resource_id] + if not sensor_info then + log.debug("no sensor info") + local parent_bridge = utils.get_hue_bridge_for_device( + driver, device, device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + ) + local api_instance = (parent_bridge and parent_bridge:get_field(Fields.BRIDGE_API)) + + if parent_bridge and api_instance then + log.debug("--------------------- update all start") + sensor_info, err = motion_sensor_disco.update_state_for_all_device_services( + driver, + api_instance, + hue_device_id, + parent_bridge.device_network_id, + Discovery.device_state_disco_cache + ) + log.debug("--------------------- update all complete") + if err then + log.error( + st_utils.stringify_table( + err, + string.format( + "Error populating initial state for sensor %s", + (device and device.label) or "unknown sensor" + ), + true + ) + ) + end + end + end + if not sensor_info then + log.warn(string.format("Motion Sensor %s parent bridge not ready, queuing refresh", device and device.label)) + driver._devices_pending_refresh[device.id] = device + else + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, sensor_info, HueDeviceTypes.CONTACT + ) + end + device:set_field(Fields._INIT, true, { persist = false }) + if device:get_field(Fields._REFRESH_AFTER_INIT) then + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) + end +end + +return MotionLifecycleHandlers diff --git a/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/bridge.lua b/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/bridge.lua new file mode 100644 index 0000000000..d1633ec6f1 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/bridge.lua @@ -0,0 +1,100 @@ +local cosock = require "cosock" +local log = require "log" + +local Discovery = require "disco" +local HueApi = require "hue.api" +local utils = require "utils" + +---@class BridgeMigrationHandler +local BridgeMigrationHandler = {} + +---comment +---@param driver HueDriver +---@param device HueBridgeDevice +---@param lifecycle_handlers LifecycleHandlers +function BridgeMigrationHandler.migrate(driver, device, lifecycle_handlers) + log.info_with({ hub_logs = true }, + string.format("Migrate Bridge for device %s", (device.label or device.id or "unknown device"))) + local api_key = device.data.username + local device_dni = device.device_network_id + + log.info( + string.format("Rediscovering bridge for migrated device %s", (device.label or device.id or "unknown device"))) + cosock.spawn( + function() + local bridge_found = false + local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) + local sleep_time = 0.1 + while true do + log.info( + string.format( + "[MigrateBridge] Scanning for Hue Bridge info for migrated device %s", + (device.label or device.id or "unknown device") + ) + ) + Discovery.search_for_bridges(driver, function(hue_driver, bridge_ip, bridge_id) + if bridge_id ~= device_dni then return end + + log.info( + string.format( + "[MigrateBridge] Matching Hue Bridge for migrated device %s found, querying configuration values", + (device.label or device.id or "unknown device") + ) + ) + + local bridge_info = driver.datastore.bridge_netinfo[bridge_id] + + if not bridge_info then + log.debug(string.format("Bridge info for %s not yet available", bridge_id)) + return + end + + if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then + log.warn("Found bridge that does not support CLIP v2 API, ignoring") + hue_driver.ignored_bridges[bridge_id] = true + return + end + + hue_driver.joined_bridges[bridge_id] = true + Discovery.api_keys[bridge_id] = api_key + + local new_metadata = { + profile = "hue-bridge", + manufacturer = "Signify Netherlands B.V.", + model = bridge_info.modelid or "BSB002", + vendor_provided_label = (bridge_info.name or "Philips Hue Bridge"), + } + + device:try_update_metadata(new_metadata) + log.info_with({ hub_logs = true }, + string.format("Bridge %s Migrated, re-adding", (device.label or device.id or "unknown device"))) + log.debug(string.format( + "Re-requesting added handler for %s after migrating", (device.label or device.id or "unknown device") + )) + lifecycle_handlers.device_added(hue_driver, device) + log.debug(string.format( + "Re-requesting init handler for %s after migrating", (device.label or device.id or "unknown device") + )) + lifecycle_handlers.device_init(hue_driver, device) + bridge_found = true + end) + if bridge_found then return end + + if sleep_time < 10 then + sleep_time = backoff_generator() + end + log.warn( + string.format( + "[MigrateBridge] Failed to find bridge info for device %s, waiting %s seconds then trying again", + (device.label or device.id or "unknown device"), + sleep_time + ) + ) + cosock.socket.sleep(sleep_time) + end + end, + string.format("bridge migration thread for %s", device.label) + ) +end + +return BridgeMigrationHandler diff --git a/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua b/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua new file mode 100644 index 0000000000..27f97be58d --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/migration_handlers/light.lua @@ -0,0 +1,162 @@ +local capabilities = require "st.capabilities" +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Discovery = require "disco" +local Fields = require "fields" +local StrayDeviceHelper = require "stray_device_helper" + +local utils = require "utils" + +---@class LightMigrationHandler +local LightMigrationHandler = {} + +---@param driver HueDriver +---@param device HueChildDevice +---@param lifecycle_handlers LifecycleHandlers +---@param parent_device_id string? +---@param hue_light_description HueLightInfo? +function LightMigrationHandler.migrate(driver, device, lifecycle_handlers, parent_device_id, hue_light_description) + local api_key = device.data.username + local v1_id = device.data.bulbId + local bridge_id = driver.api_key_to_bridge_id[api_key] + + log.info_with({ hub_logs = true }, + string.format("Migrate Light for device %s with v1_id %s and bridge_id %s", + (device.label or device.id or "unknown device"), v1_id, (bridge_id or "") + ) + ) + + local bridge_device = nil + if parent_device_id ~= nil then + bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_device_id) + end + + if not bridge_device then + bridge_device = driver:get_device_by_dni(bridge_id) + end + + ---@type PhilipsHueApi + local api_instance = (bridge_device and bridge_device:get_field(Fields.BRIDGE_API)) + or (bridge_device and Discovery.disco_api_instances[bridge_device.device_network_id]) + local light_resource = hue_light_description or + Discovery.device_state_disco_cache[utils.get_hue_rid(device) or v1_id] --[[@as HueLightInfo]] + + if not (api_instance and bridge_device and bridge_device:get_field(Fields._INIT) + and driver.joined_bridges[bridge_id] and light_resource) then + local bridge_dni = "not available" + if bridge_device then bridge_dni = bridge_device.device_network_id end + log.warn(string.format( + 'Attempting to migrate "stray" bulb before Hue Bridge network connection is fully established\n' .. + '(bridge not added or light resource not identified).\n' .. + '\tBulb Label: %s\n' .. + '\tBulb DTH API KEY: %s\n' .. + '\tBulb DTH v1_id: %s\n' .. + '\tBulb DNI: %s\n' .. + '\tBulb Parent Assigned Key: %s\n' .. + '\tMaybe Bridge Id: %s\n' .. + '\t`bridge_device` nil? %s\n' .. + '\tBridge marked as joined? %s\n' .. + '\tBridge Device DNI: %s', + (device.label), + api_key, + v1_id, + device.device_network_id, + device.parent_assigned_child_key, + bridge_id, + (bridge_device == nil), + driver.joined_bridges[bridge_id], + bridge_dni + )) + device:set_field(Fields.RETRY_MIGRATION, true, { persist = false }) + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.NewStrayDevice, + driver = driver, + device = device + }) + return + end + + log.info( + string.format("Found parent bridge %s for migrated light %s, beginning update and onboard" + , (bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge device") + , (device.label or device.id or "unknown light device") + ) + ) + + local mismatches = {} + local bridge_support = {} + local profile_support = {} + for _, cap in ipairs({ + capabilities.switch, + capabilities.switchLevel, + capabilities.colorTemperature, + capabilities.colorControl + }) do + local payload = {} + if cap.ID == capabilities.switch.ID then + payload = light_resource.on + elseif cap.ID == capabilities.switchLevel.ID then + payload = light_resource.dimming + elseif cap.ID == capabilities.colorControl.ID then + payload = light_resource.color + elseif cap.ID == capabilities.colorTemperature.ID then + payload = light_resource.color_temperature + end + + local profile_supports = device:supports_capability_by_id(cap.ID, nil) + local bridge_supports = driver.check_hue_repr_for_capability_support(light_resource, cap.ID) + + bridge_support[cap.NAME] = { + supports = bridge_supports, + payload = payload + } + profile_support[cap.NAME] = profile_supports + + if bridge_supports ~= profile_supports then + table.insert(mismatches, cap.NAME) + end + end + + local dbg_table = { + _mismatches = mismatches, + _name = { + device_label = (device.label or device.id or "unknown label"), + hue_name = (light_resource.hue_provided_name or "no name given") + }, + bridge_supports = bridge_support, + profile_supports = profile_support + } + + device.log.info_with({ hub_logs = true }, st_utils.stringify_table( + dbg_table, + "Comparing profile-reported capabilities to bridge reported representation", + false + )) + + local new_metadata = { + manufacturer = light_resource.hue_device_data.product_data.manufacturer_name, + model = light_resource.hue_device_data.product_data.model_id, + vendor_provided_label = light_resource.hue_device_data.product_data.product_name, + } + device:try_update_metadata(new_metadata) + device:set_field(Fields.RETRY_MIGRATION, false, { persist = false }) + + log.info(string.format( + "Migration to CLIPV2 for %s complete, going through onboarding flow again", + (device.label or device.id or "unknown device") + )) + log.debug(string.format( + "Re-requesting added handler for %s after migrating", (device.label or device.id or "unknown device") + )) + lifecycle_handlers.device_added(driver, device, bridge_device.id, light_resource.id) + log.debug(string.format( + "Re-requesting init handler for %s after migrating", (device.label or device.id or "unknown device") + )) + lifecycle_handlers.device_init(driver, device) +end + +return LightMigrationHandler diff --git a/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua b/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua new file mode 100644 index 0000000000..7050f606f2 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua @@ -0,0 +1,383 @@ +local cosock = require "cosock" +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" +local MultiServiceDeviceUtils = require "utils.hue_multi_service_device_utils" +local attribute_emitters = require "handlers.attribute_emitters" +local utils = require "utils" + +---@class RefreshHandlers +local RefreshHandlers = {} + +---@type table +local device_type_refresh_handlers_map = {} + +local function _refresh_zigbee(device, hue_api, zigbee_status) + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + local zigbee_resource_id + if not zigbee_status then + local rest_resp, rest_err = hue_api:get_device_by_id(hue_device_id) + if rest_err ~= nil then + log.error_with({ hub_logs = true }, rest_err) + return + end + + if rest_resp ~= nil then + if #rest_resp.errors > 0 then + for _, err in ipairs(rest_resp.errors) do + log.error_with({ hub_logs = true }, "Error in Hue API response: " .. err.description) + end + return + end + + for _, hue_device in ipairs(rest_resp.data) do + for _, svc_info in ipairs(hue_device.services or {}) do + if svc_info.rtype == "zigbee_connectivity" then + zigbee_resource_id = svc_info.rid + end + end + end + end + + if not zigbee_resource_id then + log.error_with({ hub_logs = true }, string.format("could not find zigbee_resource_id for device %s", (device and (device.label or device.id)) or "unknown device")) + return + end + rest_resp, rest_err = hue_api:get_zigbee_connectivity_by_id(zigbee_resource_id) + if rest_err ~= nil then + log.error_with({ hub_logs = true }, rest_err) + return + end + + if rest_resp ~= nil then + if #rest_resp.errors > 0 then + for _, err in ipairs(rest_resp.errors) do + log.error_with({ hub_logs = true }, "Error in Hue API response: " .. err.description) + end + return + end + + for _, zigbee_svc in ipairs(rest_resp.data) do + if zigbee_svc.owner and zigbee_svc.owner.rid == hue_device_id then + zigbee_status = zigbee_svc + break + end + end + end + end + + if zigbee_status and zigbee_status.status == "connected" then + device.log.debug(string.format("Zigbee Status for %s is connected", device.label)) + device:online() + device:set_field(Fields.IS_ONLINE, true) + else + device.log.debug(string.format("Zigbee Status for %s is not connected", device.label)) + device:set_field(Fields.IS_ONLINE, false) + device:offline() + end +end + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +function RefreshHandlers.do_refresh_all_for_bridge(driver, bridge_device) + cosock.spawn( + function() + local child_devices = bridge_device:get_child_list() + + if not bridge_device:get_field(Fields._INIT) then + log.warn("Bridge for devices not yet initialized, can't refresh yet.") + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + + local conn_status, conn_rest_err = hue_api:get_connectivity_status() + + if conn_rest_err ~= nil or not conn_status then + bridge_device.log.error( + string.format( + "Couldn't refresh device connectivity status for children of bridge [%s].\n" .. + "get_connectivity_status error? %s\n", + (bridge_device and bridge_device.label) or "Unknown Bridge", + conn_rest_err + ) + ) + return + end + + if conn_status.errors and #conn_status.errors > 0 then + bridge_device.log.error("Errors in connectivity status payload: " .. st_utils.stringify_table(conn_status.errors)) + return + end + + local conn_status_cache = {} + + for _, zigbee_status in ipairs(conn_status.data) do + conn_status_cache[zigbee_status.owner.rid] = zigbee_status + end + + local statuses_by_device_type = {} + for _, device in ipairs(child_devices) do + local device_type = utils.determine_device_type(device) + -- Query for the states of all devices for a device type for a given child device, + -- but only the first time we encounter a device type. We cache them since we're refreshing + -- everything. + if + device_type and + type(device_type_refresh_handlers_map[device_type]) == "function" and + statuses_by_device_type[device_type] == nil + then + local reprs, rest_err = hue_api:get_all_reprs_for_rtype(device_type) + if reprs ~= nil and rest_err == nil then + local cached_states = {} + for _, repr_state in ipairs(reprs.data) do + cached_states[repr_state.owner.rid] = repr_state + end + statuses_by_device_type[device_type] = cached_states + else + log.error("Error refreshing all for resource type [%s]", device_type) + end + end + _refresh_zigbee(device, hue_api, conn_status_cache[device:get_field(Fields.HUE_DEVICE_ID)]) + RefreshHandlers.handler_for_device_type(device_type)( + driver, + device, + statuses_by_device_type[device_type], + true + ) + end + end, + string.format("Refresh All Child Devices for Hue Bridge [%s]", + (bridge_device and bridge_device.label) or "Unknown Bridge") + ) +end +-- TODO: [Rule of three](https://en.wikipedia.org/wiki/Rule_of_three_(computer_programming)), this can be generalized. +-- At this point I'm pretty confident that we can actually just have a single generic +-- "refresh device" function and a "refresh all devices" function. +---@param driver HueDriver +---@param button_device HueChildDevice +---@param _ any +---@param skip_zigbee boolean +---@return table? +function RefreshHandlers.do_refresh_button(driver, button_device, _, skip_zigbee) + local hue_device_id = button_device:get_field(Fields.HUE_DEVICE_ID) + local bridge_id = button_device.parent_device_id or button_device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, button_device, bridge_id) + + if not bridge_device then + log.warn("Couldn't get Hue bridge for button device " .. (button_device.label or button_device.id or "unknown device")) + return + end + + if not bridge_device:get_field(Fields._INIT) then + log.warn("Bridge for button device not yet initialized, can't refresh yet.") + driver._devices_pending_refresh[button_device.id] = button_device + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if skip_zigbee ~= true then + _refresh_zigbee(button_device, hue_api) + end + + local sensor_info, err = MultiServiceDeviceUtils.get_all_service_states(driver, HueDeviceTypes.BUTTON, hue_api, hue_device_id, bridge_device.device_network_id) + if err then + log.error(string.format("Error refreshing motion sensor %s: %s", (button_device and button_device.label), err)) + end + + attribute_emitters.emitter_for_device_type(HueDeviceTypes.BUTTON)(button_device, sensor_info) + return sensor_info +end + +-- TODO: Refresh handlers need to be optimized/generalized for devices with multiple services +---@param driver HueDriver +---@param sensor_device HueChildDevice +---@param _ any +---@param skip_zigbee boolean +---@return table? +function RefreshHandlers.do_refresh_motion_sensor(driver, sensor_device, _, skip_zigbee) + local hue_device_id = sensor_device:get_field(Fields.HUE_DEVICE_ID) + local bridge_id = sensor_device.parent_device_id or sensor_device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, sensor_device, bridge_id) + + if not bridge_device then + log.warn("Couldn't get Hue bridge for motion_sensor " .. (sensor_device.label or sensor_device.id or "unknown device")) + return + end + + if not bridge_device:get_field(Fields._INIT) then + log.warn("Bridge for motion_sensor not yet initialized, can't refresh yet.") + driver._devices_pending_refresh[sensor_device.id] = sensor_device + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if skip_zigbee ~= true then + _refresh_zigbee(sensor_device, hue_api) + end + + local sensor_info, err = MultiServiceDeviceUtils.get_all_service_states(driver, HueDeviceTypes.MOTION, hue_api, hue_device_id, bridge_device.device_network_id) + if err then + log.error(string.format("Error refreshing motion sensor %s: %s", (sensor_device and sensor_device.label), err)) + end + + attribute_emitters.emitter_for_device_type(HueDeviceTypes.MOTION)(sensor_device, sensor_info) + return sensor_info +end + +---@param driver HueDriver +---@param sensor_device HueChildDevice +---@param _ any +---@param skip_zigbee boolean +---@return table? +function RefreshHandlers.do_refresh_contact_sensor(driver, sensor_device, _, skip_zigbee) + local hue_device_id = sensor_device:get_field(Fields.HUE_DEVICE_ID) + local bridge_id = sensor_device.parent_device_id or sensor_device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, sensor_device, bridge_id) + + if not bridge_device then + log.warn("Couldn't get Hue bridge for contact sensor " .. (sensor_device.label or sensor_device.id or "unknown device")) + return + end + + if not bridge_device:get_field(Fields._INIT) then + log.warn("Bridge for contact sensor not yet initialized, can't refresh yet.") + driver._devices_pending_refresh[sensor_device.id] = sensor_device + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if skip_zigbee ~= true then + _refresh_zigbee(sensor_device, hue_api) + end + + local sensor_info, err = MultiServiceDeviceUtils.get_all_service_states(driver, HueDeviceTypes.CONTACT, hue_api, hue_device_id, bridge_device.device_network_id) + if err then + log.error(string.format("Error refreshing contact sensor %s: %s", (sensor_device and sensor_device.label), err)) + end + + attribute_emitters.emitter_for_device_type(HueDeviceTypes.CONTACT)(sensor_device, sensor_info) + return sensor_info +end + +---@param driver HueDriver +---@param light_device HueChildDevice +---@param light_status_cache table|nil +---@param skip_zigbee boolean? +---@return HueLightInfo? light_info +function RefreshHandlers.do_refresh_light(driver, light_device, light_status_cache, skip_zigbee) + local light_resource_id = utils.get_hue_rid(light_device) + local hue_device_id = light_device:get_field(Fields.HUE_DEVICE_ID) + + if not (light_resource_id and hue_device_id) then + log.error( + string.format( + "Could not get light_resource_id or hue_device_id for light %s", + (light_device and light_device.label) or "unknown light" + ) + ) + return + end + local do_light_request = true + + if type(light_status_cache) == "table" then + local light_info = light_status_cache[hue_device_id] + if light_info ~= nil then + if light_info.id == light_resource_id then + if light_info.color ~= nil and light_info.color.gamut then + light_device:set_field(Fields.GAMUT, light_info.color.gamut_type, { persist = true }) + end + attribute_emitters.emit_light_attribute_events(light_device, light_info) + do_light_request = false + end + end + end + + local bridge_id = light_device.parent_device_id or light_device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, light_device, bridge_id) + + if not bridge_device then + log.warn("Couldn't get Hue bridge for light " .. (light_device.label or light_device.id or "unknown device")) + return + end + + if not bridge_device:get_field(Fields._INIT) then + log.warn("Bridge for light not yet initialized, can't refresh yet.") + driver._devices_pending_refresh[light_device.id] = light_device + return + end + + local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if skip_zigbee ~= true then + _refresh_zigbee(light_device, hue_api) + end + + local success = not (do_light_request) + local count = 0 + local num_attempts = 3 + local rest_resp, rest_err + local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) + --- this loop is a rate-limit dodge. + --- + --- One of the various symptoms of hitting the Hue Bridge's rate limit is that you'll get a silent + --- failure that takes the form of the bridge returning the last valid response it replied with. + --- So we hit the bridge 2-3 times and check the IDs in the responses to verify that we're getting + --- the information for the light that we expect to getting the info for. + repeat + count = count + 1 + if do_light_request and light_device:get_field(Fields.IS_ONLINE) then + rest_resp, rest_err = hue_api:get_light_by_id(light_resource_id) + if rest_err ~= nil then + log.error_with({ hub_logs = true }, rest_err) + goto continue + end + + if rest_resp ~= nil then + if #rest_resp.errors > 0 then + for _, err in ipairs(rest_resp.errors) do + log.error_with({ hub_logs = true }, "Error in Hue API response: " .. err.description) + end + goto continue + end + + for _, light_info in ipairs(rest_resp.data) do + if light_info.id == light_resource_id then + if light_info.color ~= nil and light_info.color.gamut then + light_device:set_field(Fields.GAMUT, light_info.color.gamut_type, { persist = true }) + end + attribute_emitters.emit_light_attribute_events(light_device, light_info) + return light_info + end + end + end + end + ::continue:: + if not success then + cosock.socket.sleep(backoff_generator()) + end + until count >= num_attempts +end + +local function noop_refresh_handler(driver, device, ...) + local label = (device and device.label) or "Unknown Device Name" + local device_type = (device and utils.determine_device_type(device)) or "Unknown Device Type" + log.warn(string.format("Received Refresh capability for unknown device [%s] type [%s], ignoring", label, device_type)) +end + +function RefreshHandlers.handler_for_device_type(device_type) + return device_type_refresh_handlers_map[device_type] or noop_refresh_handler +end + +-- TODO: Generalize this like the other handlers, and maybe even separate out non-primary services +device_type_refresh_handlers_map[HueDeviceTypes.BRIDGE] = RefreshHandlers.do_refresh_all_for_bridge +device_type_refresh_handlers_map[HueDeviceTypes.BUTTON] = RefreshHandlers.do_refresh_button +device_type_refresh_handlers_map[HueDeviceTypes.CONTACT] = RefreshHandlers.do_refresh_contact_sensor +device_type_refresh_handlers_map[HueDeviceTypes.LIGHT] = RefreshHandlers.do_refresh_light +device_type_refresh_handlers_map[HueDeviceTypes.MOTION] = RefreshHandlers.do_refresh_motion_sensor + +return RefreshHandlers diff --git a/drivers/SmartThings/philips-hue/src/hue/api.lua b/drivers/SmartThings/philips-hue/src/hue/api.lua index 6a8d2ef153..6039b395cc 100644 --- a/drivers/SmartThings/philips-hue/src/hue/api.lua +++ b/drivers/SmartThings/philips-hue/src/hue/api.lua @@ -6,11 +6,24 @@ local log = require "log" local RestClient = require "lunchbox.rest" local st_utils = require "st.utils" -- trick to fix the VS Code Lua Language Server typechecking ----@type fun(val: table, name: string?, multi_line: boolean?): string +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local HueDeviceTypes = require "hue_device_types" + +local GROUPED_LIGHT = "grouped_light" + +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string st_utils.stringify_table = st_utils.stringify_table local APPLICATION_KEY_HEADER = "hue-application-key" +---@class HueResourceResponse: { errors: table[], data: T[] } + +---@class HueApiKeyResponse: { error: { description: string }?, success: { username: string, client_key: string|nil }? } + +---@enum ControlMessageTypes local ControlMessageTypes = { Shutdown = "shutdown", Get = "get", @@ -57,9 +70,6 @@ local PhilipsHueApi = {} PhilipsHueApi.__index = PhilipsHueApi PhilipsHueApi.MIN_CLIP_V2_SWVERSION = 1948086000 -PhilipsHueApi.MIN_TEMP_KELVIN_COLOR_AMBIANCE = 2000 -PhilipsHueApi.MIN_TEMP_KELVIN_WHITE_AMBIANCE = 2200 -PhilipsHueApi.MAX_TEMP_KELVIN = 6500 PhilipsHueApi.APPLICATION_KEY_HEADER = APPLICATION_KEY_HEADER local function retry_fn(retry_attempts) @@ -70,6 +80,14 @@ local function retry_fn(retry_attempts) end end +---@param response Response? +---@param err string? +---@param partial string? +---@param err_callback fun(err: string)? +---@return table? tbl the table representation of the JSON response, nil on error +---@return string? err the error message, nil on success +---@return string? partial the partial response if the response was not complete +---@return ... local function process_rest_response(response, err, partial, err_callback) if err == nil and response == nil then log.error_with({ hub_logs = true }, @@ -114,7 +132,7 @@ function PhilipsHueApi.new_bridge_manager(base_url, api_key, socket_builder) true )) local control_tx, control_rx = channel.new() - control_rx:settimeout(30) + control_rx:settimeout(45) local self = setmetatable( { headers = { [APPLICATION_KEY_HEADER] = api_key or "" }, @@ -192,11 +210,14 @@ function PhilipsHueApi:update_connection(hub_base_url, api_key) try_send(self, msg) end +---@param instance PhilipsHueApi +---@param path string ---@return table|nil response REST response, nil if error ---@return nil|string error nil on success +---@return ... local function do_get(instance, path) local reply_tx, reply_rx = channel.new() - reply_rx:settimeout(10) + reply_rx:settimeout(45) local msg = ControlMessageBuilders.Get(path, reply_tx); try_send(instance, msg) local recv, err = reply_rx:receive() @@ -207,11 +228,15 @@ local function do_get(instance, path) return table.unpack(recv, 1, recv.n) end +---@param instance PhilipsHueApi +---@param path string +---@param payload string ---@return table|nil response REST response, nil if error ---@return nil|string error nil on success +---@return ... local function do_put(instance, path, payload) local reply_tx, reply_rx = channel.new() - reply_rx:settimeout(10) + reply_rx:settimeout(45) local msg = ControlMessageBuilders.Put(path, payload, reply_tx); try_send(instance, msg) local recv, err = reply_rx:receive() @@ -227,9 +252,10 @@ end ---@return HueBridgeInfo|nil bridge_info nil on err ---@return nil|string error nil on success ---@return nil|string partial partial response if available, nil otherwise +---@return ... function PhilipsHueApi.get_bridge_info(bridge_ip, socket_builder) local tx, rx = channel.new() - rx:settimeout(10) + rx:settimeout(45) cosock.spawn( function() tx:send(table.pack(process_rest_response(RestClient.one_shot_get("https://" .. bridge_ip .. "/api/config", nil, @@ -246,12 +272,13 @@ end ---@param bridge_ip string ---@param socket_builder nil|function optional an override to the default socket factory callback ----@return table|nil api_key_response nil on err ----@return nil|string error nil on success ----@return nil|string partial partial response if available, nil otherwise +---@return HueApiKeyResponse[]? api_key_response nil on err +---@return string? error nil on success +---@return string? partial partial response if available, nil otherwise +---@return ... function PhilipsHueApi.request_api_key(bridge_ip, socket_builder) local tx, rx = channel.new() - rx:settimeout(10) + rx:settimeout(45) cosock.spawn( function() local body = json.encode { devicetype = "smartthings_edge_driver#" .. bridge_ip, generateclientkey = true } @@ -267,32 +294,131 @@ function PhilipsHueApi.request_api_key(bridge_ip, socket_builder) return table.unpack(recv, 1, recv.n) end -function PhilipsHueApi:get_lights() return do_get(self, "/clip/v2/resource/light") end +---@param rtype string +---@return HueResourceResponse|nil +---@return string? err nil on success +function PhilipsHueApi:get_all_reprs_for_rtype(rtype) return do_get(self, string.format("/clip/v2/resource/%s", string.lower(tostring(rtype)))) end + +---@generic T: HueResourceInfo +---@param rtype string +---@param rid string +---@return HueResourceResponse|nil +---@return string? err nil on success +function PhilipsHueApi:get_rtype_by_rid(rtype, rid) + return do_get( + self, + string.format( + "/clip/v2/resource/%s/%s", + string.lower(tostring(rtype)), + string.lower(tostring(rid)) + ) + ) +end + +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_lights() return self:get_all_reprs_for_rtype(HueDeviceTypes.LIGHT) end + +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_devices() return self:get_all_reprs_for_rtype("device") end -function PhilipsHueApi:get_devices() return do_get(self, "/clip/v2/resource/device") end +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_connectivity_status() return self:get_all_reprs_for_rtype("zigbee_connectivity") end -function PhilipsHueApi:get_connectivity_status() return do_get(self, "/clip/v2/resource/zigbee_connectivity") end +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_zones() return self:get_all_reprs_for_rtype("zone") end -function PhilipsHueApi:get_rooms() return do_get(self, "/clip/v2/resource/room") end +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_rooms() return self:get_all_reprs_for_rtype("room") end +---@param light_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success function PhilipsHueApi:get_light_by_id(light_resource_id) - return do_get(self, string.format("/clip/v2/resource/light/%s", light_resource_id)) + return self:get_rtype_by_rid(HueDeviceTypes.LIGHT, light_resource_id) end +---@param hue_device_id string +---@return HueResourceResponse? +---@return string? err nil on success function PhilipsHueApi:get_device_by_id(hue_device_id) - return do_get(self, string.format("/clip/v2/resource/device/%s", hue_device_id)) + return self:get_rtype_by_rid("device", hue_device_id) end +---@param zigbee_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success function PhilipsHueApi:get_zigbee_connectivity_by_id(zigbee_resource_id) - return do_get(self, string.format("/clip/v2/resource/zigbee_connectivity/%s", zigbee_resource_id)) + return self:get_rtype_by_rid("zigbee_connectivity", zigbee_resource_id) +end + +---@param button_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_button_by_id(button_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.BUTTON, button_resource_id) end -function PhilipsHueApi:get_room_by_id(id) - return do_get(self, string.format("/clip/v2/resource/room/%s", id)) +---@param contact_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_contact_by_id(contact_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.CONTACT, contact_resource_id) end +---@param motion_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_motion_by_id(motion_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.MOTION, motion_resource_id) +end + +---@param device_power_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_device_power_by_id(device_power_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.DEVICE_POWER, device_power_resource_id) +end + +---@param tamper_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_tamper_by_id(tamper_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.TAMPER, tamper_resource_id) +end + +---@param temperature_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_temperature_by_id(temperature_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.TEMPERATURE, temperature_resource_id) +end + +---@param light_level_resource_id string +---@return HueResourceResponse? +---@return string? err nil on success +function PhilipsHueApi:get_light_level_by_id(light_level_resource_id) + return self:get_rtype_by_rid(HueDeviceTypes.LIGHT_LEVEL, light_level_resource_id) +end + +---@param id string +---@param on boolean +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. function PhilipsHueApi:set_light_on_state(id, on) - local url = string.format("/clip/v2/resource/light/%s", id) + return self:set_light_on_state_by_device_type(id, on, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_on_state(id, on) + return self:set_light_on_state_by_device_type(id, on, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_on_state_by_device_type(id, on, device_type) + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) if type(on) ~= "boolean" then if on then @@ -307,9 +433,21 @@ function PhilipsHueApi:set_light_on_state(id, on) return do_put(self, url, payload) end +---@param id string +---@param level number +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. function PhilipsHueApi:set_light_level(id, level) + return self:set_light_level_by_device_type(id, level, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_level(id, level) + return self:set_light_level_by_device_type(id, level, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_level_by_device_type(id, level, device_type) if type(level) == "number" then - local url = string.format("/clip/v2/resource/light/%s", id) + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) local payload_table = { dimming = { brightness = level } } return do_put(self, url, json.encode(payload_table)) @@ -319,12 +457,24 @@ function PhilipsHueApi:set_light_level(id, level) end end +---@param id string +---@param xy_table HueColorCoords +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. function PhilipsHueApi:set_light_color_xy(id, xy_table) + return self:set_light_color_xy_by_device_type(id, xy_table, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_color_xy(id, xy_table) + return self:set_light_color_xy_by_device_type(id, xy_table, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_color_xy_by_device_type(id, xy_table, device_type) local x_valid = (xy_table ~= nil) and ((xy_table.x ~= nil) and (type(xy_table.x) == "number")) local y_valid = (xy_table ~= nil) and ((xy_table.y ~= nil) and (type(xy_table.y) == "number")) if x_valid and y_valid then - local url = string.format("/clip/v2/resource/light/%s", id) + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) local payload = json.encode { color = { xy = xy_table }, on = { on = true } } return do_put(self, url, payload) else @@ -333,9 +483,21 @@ function PhilipsHueApi:set_light_color_xy(id, xy_table) end end +---@param id string +---@param mirek number +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. function PhilipsHueApi:set_light_color_temp(id, mirek) + return self:set_light_color_temp_by_device_type(id, mirek, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_color_temp(id, mirek) + return self:set_light_color_temp_by_device_type(id, mirek, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_color_temp_by_device_type(id, mirek, device_type) if type(mirek) == "number" then - local url = string.format("/clip/v2/resource/light/%s", id) + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) local payload = json.encode { color_temperature = { mirek = mirek }, on = { on = true } } return do_put(self, url, payload) @@ -345,4 +507,52 @@ function PhilipsHueApi:set_light_color_temp(id, mirek) end end +---@param id string +---@param brightness_delta number absolute brightness percentage delta +---@param action "up"|"down" +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. +function PhilipsHueApi:set_light_level_delta(id, brightness_delta, action) + return self:set_light_level_delta_by_device_type(id, brightness_delta, action, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_level_delta(id, brightness_delta, action) + return self:set_light_level_delta_by_device_type(id, brightness_delta, action, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_level_delta_by_device_type(id, brightness_delta, action, device_type) + if type(brightness_delta) == "number" then + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) + local payload = json.encode { dimming_delta = { action = action, brightness_delta = brightness_delta } } + return do_put(self, url, payload) + else + return nil, + string.format("Expected number for brightness delta, received %s", st_utils.stringify_table(brightness_delta, nil, false)) + end +end + +---@param id string +---@param mirek_delta number absolute mirek delta +---@param action "up"|"down" +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. +function PhilipsHueApi:set_light_color_temp_delta(id, mirek_delta, action) + return self:set_light_color_temp_delta_by_device_type(id, mirek_delta, action, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_color_temp_delta(id, mirek_delta, action) + return self:set_light_color_temp_delta_by_device_type(id, mirek_delta, action, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_color_temp_delta_by_device_type(id, mirek_delta, action, device_type) + if type(mirek_delta) == "number" then + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) + local payload = json.encode { color_temperature_delta = { action = action, mirek_delta = mirek_delta } } + return do_put(self, url, payload) + else + return nil, + string.format("Expected number for color temp mirek delta, received %s", st_utils.stringify_table(mirek_delta, nil, false)) + end +end + return PhilipsHueApi diff --git a/drivers/SmartThings/philips-hue/src/hue/types.lua b/drivers/SmartThings/philips-hue/src/hue/types.lua deleted file mode 100644 index 11907b535c..0000000000 --- a/drivers/SmartThings/philips-hue/src/hue/types.lua +++ /dev/null @@ -1,54 +0,0 @@ ---- @meta - ---- Hue Bridge Info as returned by the unauthenticated API endpoint `/api/config` ---- @class HueBridgeInfo ---- @field public name string ---- @field public datastoreversion string ---- @field public swversion string ---- @field public apiversion string ---- @field public mac string ---- @field public bridgeid string ---- @field public factorynew boolean ---- @field public replacesbridgeid string|nil ---- @field public modelid string ---- @field public starterkitid string ---- @field public ip string|nil - ---- @class HueDriver:Driver ---- @field public ignored_bridges table ---- @field public joined_bridges table ---- @field public light_id_to_device table ---- @field public device_rid_to_light_rid table ---- @field public stray_bulb_tx table cosock channel ---- @field public datastore table persistent store ---- @field public api_key_to_bridge_id table ---- @field public update_bridge_netinfo fun(self: HueDriver, bridge_id: string, bridge_info: HueBridgeInfo) ---- @field public emit_light_status_events fun(light_device: HueChildDevice, light: table) ---- @field public get_device_by_dni fun(self: HueDriver, device_network_id: string, force_refresh?: boolean): HueDevice|nil ---- @field public do_hue_light_delete fun(self: HueDriver, light_device: HueDevice) ---- @field public get_device_info fun(self: HueDriver, device_id: string, force_refresh: boolean?): HueDevice? ---- @field public check_hue_repr_for_capability_support fun(hue_repr: table, capability_id: string): boolean ---- @field public _lights_pending_refresh table - ---- @class HueDevice:st.Device ---- @field public label string ---- @field public id string ---- @field public device_network_id string ---- @field public parent_device_id string ---- @field public parent_assigned_child_key string? ---- @field public manufacturer string ---- @field public model string ---- @field public vendor_provided_label string ---- @field public data table|nil migration data for a migrated device ---- @field public log table device-scoped logging module ---- @field public profile table ---- @field public get_field fun(self: HueDevice, key: string):any ---- @field public set_field fun(self: HueDevice, key: string, value: any, args?: table) ---- @field public emit_event fun(self: HueDevice, event: any) ---- @field public supports_capability_by_id fun(self: HueDevice, capability_id: string, component: string?): boolean - ---- @class HueBridgeDevice:HueDevice ---- @field public device_network_id string - ---- @class HueChildDevice:HueDevice ---- @field public parent_assigned_child_key string diff --git a/drivers/SmartThings/philips-hue/src/hue_debug/init.lua b/drivers/SmartThings/philips-hue/src/hue_debug/init.lua new file mode 100644 index 0000000000..a6ce0ebfee --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/hue_debug/init.lua @@ -0,0 +1,107 @@ +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local log = require "log" +local utils = require "utils" + +local Discovery = require "disco" +local HueDeviceTypes = require "hue_device_types" + +local lazy_handlers = utils.lazy_handler_loader("handlers") + +local hue_debug = {} + +local _log = function(msg) + log.debug("*****>> " .. msg) +end + +local function _setup_delayed_bridges(template, delay_time) + _log("Debug Configuration: Delayed Bridges enabled.") + local lifecycle_handlers = lazy_handlers.lifecycle_handlers + local _init = lifecycle_handlers.initialize_device + + local added_bridges = {} + ---@diagnostic disable-next-line: duplicate-set-field + lifecycle_handlers.initialize_device = function (driver, device, event, ...) + local device_type = utils.determine_device_type(device) + if device_type == HueDeviceTypes.BRIDGE then + if event == "added" then + added_bridges[device.id] = true + end + + if added_bridges[device.id] and event == "init" then + _log(string.format("Ignoring init event for %s due to emulating delayed added", device.label)) + return + end + + _log(string.format("------------------------ Wrapped init bridge for event %s", event)) + _log(string.format("Delaying bridge initialization for [%s] by %s seconds", device.label, delay_time)) + local maybe_device = driver:get_device_by_dni(device.device_network_id) + if not ( + maybe_device + and maybe_device.id == device.id + ) + then + driver.datastore.dni_to_device_id[device.device_network_id] = device.id + end + local args = table.pack(...) + driver:call_with_delay( + delay_time, + function(_) + _log(string.format("Performing initialization for bridge [%s]", device.label)) + _init(driver, device, event, table.unpack(args)) + end, + "Delayed Bridge Initialize" + ) + return + end + + _init(driver, device, event, ...) + end + + template.lifecycle_handlers.added = utils.safe_wrap_handler(lifecycle_handlers.initialize_device) + template.lifecycle_handlers.init = utils.safe_wrap_handler(lifecycle_handlers.initialize_device) +end + +local function _setup_forced_strays(forced_stray_types) + _log( + st_utils.stringify_table( + forced_stray_types, + "Debug Configuration: Forced Stray Devices enabled for the following", + true + ) + ) + local type_map = {} + for _, device_type in ipairs(forced_stray_types) do + type_map[device_type] = HueDeviceTypes.is_valid_device_type(device_type) + end + local added = lazy_handlers.lifecycle_handlers.device_added + local forced_devices = {} + lazy_handlers.lifecycle_handlers.device_added = function(driver, device, ...) + local device_type = utils.determine_device_type(device) + local device_rid = utils.get_hue_rid(device) + if device_rid and type_map[device_type] and not forced_devices[device.id] then + _log(string.format("Forcing device [%s] as stray by removing its device state disco cache", device.label)) + forced_devices[device.id] = true + Discovery.device_state_disco_cache[device_rid] = nil + added(driver, device, ...) + end + end +end + +function hue_debug.enable_dbg_config(template, config) + assert(template.__running ~= true, "enable_dbg_config must be called before driver run and only once") + + config = config or {} + if config.delay_bridges then + _setup_delayed_bridges(template, config.bridge_delay_time or 10) + end + + if type(config.force_stray_for_device_type) == "table" then + _setup_forced_strays(config.force_stray_for_device_type) + end +end + +return hue_debug diff --git a/drivers/SmartThings/philips-hue/src/hue_device_types.lua b/drivers/SmartThings/philips-hue/src/hue_device_types.lua new file mode 100644 index 0000000000..f0c4dec0c7 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/hue_device_types.lua @@ -0,0 +1,73 @@ +---@enum HueDeviceTypes +local HueDeviceTypes = { + BRIDGE = "bridge", + BUTTON = "button", + CONTACT = "contact", + DEVICE_POWER = "device_power", + LIGHT = "light", + LIGHT_LEVEL = "light_level", + MOTION = "motion", + TAMPER = "tamper", + TEMPERATURE = "temperature", + ZIGBEE_CONNECTIVITY = "zigbee_connectivity" +} + +local SupportedNumberOfButtons = { + [1] = true, -- For Philips Hue Smart Button or single switch In-Wall Switch module which contains only 1 button + [2] = true, -- For double switch In-Wall Switch module + [4] = true, -- For Philips Hue Dimmer Remote and Tap Dial, which contains 4 buttons +} + +local PrimaryDeviceTypes = { + [HueDeviceTypes.BUTTON] = true, + [HueDeviceTypes.CONTACT] = true, + [HueDeviceTypes.LIGHT] = true, + [HueDeviceTypes.MOTION] = true +} + +---@type table +local bimap = {} + +for key, val in pairs(HueDeviceTypes) do + bimap[val] = key +end + +function HueDeviceTypes.can_join_device_for_service(device_type_str) + return PrimaryDeviceTypes[device_type_str] +end + +function HueDeviceTypes.is_valid_device_type(device_type_str) + return bimap[device_type_str] ~= nil +end + +function HueDeviceTypes.supports_button_configuration(button_description) + return SupportedNumberOfButtons[button_description.num_buttons] +end + +---@param device_info HueDeviceInfo +---@param primary_services table +---@return HueDeviceTypes? svc_rtype the main service rtype for the device +function HueDeviceTypes.determine_main_service_rtype(device_info, primary_services) + -- If the id_v1 is present, it'll be of the form '//' + local service_from_v1_id = string.match((device_info.id_v1 or ""), "/([%a]+)/[%d]+") or "" + -- Lights show up as `light` here, but buttons and sensors both show up as `sensors` + if PrimaryDeviceTypes[service_from_v1_id] then + return service_from_v1_id + end + + local has_service = {} + for rtype, _ in pairs(primary_services) do + has_service[rtype] = PrimaryDeviceTypes[rtype] + end + + -- At this point we'll make our best guess by establishing an order of precedence as a heuristic; + -- lights first, then the actual sensors, then buttons. + if has_service[HueDeviceTypes.LIGHT] then return HueDeviceTypes.LIGHT end + if has_service[HueDeviceTypes.CONTACT] then return HueDeviceTypes.CONTACT end + if has_service[HueDeviceTypes.MOTION] then return HueDeviceTypes.MOTION end + if has_service[HueDeviceTypes.BUTTON] then return HueDeviceTypes.BUTTON end + + return nil +end + +return HueDeviceTypes diff --git a/drivers/SmartThings/philips-hue/src/hue_driver_template.lua b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua new file mode 100644 index 0000000000..1c32b260e9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua @@ -0,0 +1,398 @@ +local Driver = require "st.driver" + +local capabilities = require "st.capabilities" +local cosock = require "cosock" +local log = require "log" + +local Discovery = require "disco" +local Fields = require "fields" +local HueApi = require "hue.api" +local StrayDeviceHelper = require "stray_device_helper" + +local command_handlers = require "handlers.commands" +local lifecycle_handlers = require "handlers.lifecycle_handlers" + +local bridge_utils = require "utils.hue_bridge_utils" +local utils = require "utils" +local batched_command_utils = require "utils.batched_command_utils" +local st_utils = require "st.utils" + +---@param driver HueDriver +---@param device HueDevice +local function _remove(driver, device) + driver.datastore.dni_to_device_id[device.device_network_id] = nil + if utils.determine_device_type(device) == "bridge" then + local api_instance = device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + if api_instance then + api_instance:shutdown() + device:set_field(Fields.BRIDGE_API, nil) + end + + local event_source = device:get_field(Fields.EVENT_SOURCE) + if event_source then + event_source:close() + device:set_field(Fields.EVENT_SOURCE, nil) + end + + + -- This operation will be committed to the datastore + -- immediately, see the note in init.lua#L57 + Discovery.api_keys[device.device_network_id] = nil + end +end + + +-- Discovery Handler +local disco = Discovery.discover + +-- Lifecycle Handlers +local added = utils.safe_wrap_handler(lifecycle_handlers.initialize_device) +local init = utils.safe_wrap_handler(lifecycle_handlers.initialize_device) +local removed = utils.safe_wrap_handler(_remove) + +-- Capability Command Handlers +local refresh_handler = utils.safe_wrap_handler(command_handlers.refresh_handler) +local switch_on_handler = utils.safe_wrap_handler(command_handlers.switch_on_handler) +local switch_off_handler = utils.safe_wrap_handler(command_handlers.switch_off_handler) +local switch_level_handler = utils.safe_wrap_handler(command_handlers.switch_level_handler) +local set_color_handler = utils.safe_wrap_handler(command_handlers.set_color_handler) +local set_hue_handler = utils.safe_wrap_handler(command_handlers.set_hue_handler) +local set_saturation_handler = utils.safe_wrap_handler(command_handlers.set_saturation_handler) +local set_color_temp_handler = utils.safe_wrap_handler(command_handlers.set_color_temp_handler) +local step_level_handler = utils.safe_wrap_handler(command_handlers.step_level_handler) +local step_color_temp_handler = utils.safe_wrap_handler(command_handlers.step_color_temp_handler) + +--- @class HueDriverDatastore +--- @field public bridge_netinfo table +--- @field public dni_to_device_id table +--- @field public api_keys table +--- @field public commit fun(self: HueDriverDatastore)|nil +--- @field public save fun(self: HueDriverDatastore) + +--- @class HueDriver:Driver +--- @field public ignored_bridges table +--- @field public joined_bridges table +--- @field public hue_identifier_to_device_record_by_bridge table> +--- @field public services_for_device_rid table> Map the device resource ID to another map that goes from service rid to service rtype +--- @field public waiting_grandchildren table? +--- @field public stray_device_tx table cosock channel +--- @field public datastore HueDriverDatastore persistent store +--- @field public api_key_to_bridge_id table +--- @field public _devices_pending_refresh table +--- @field public get_devices fun(self: HueDriver): HueChildDevice[] +--- @field public get_device_info fun(self: HueDriver, device_id: string, force_refresh: boolean?): HueDevice? +local HueDriver = {} + +function HueDriver.new_driver_template(dbg_config) + local stray_device_tx = StrayDeviceHelper.spawn() + local template = { + discovery = disco, + lifecycle_handlers = { added = added, init = init, removed = removed }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler, + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_on_handler, + [capabilities.switch.commands.off.NAME] = switch_off_handler, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = switch_level_handler, + }, + [capabilities.colorControl.ID] = { + [capabilities.colorControl.commands.setColor.NAME] = set_color_handler, + [capabilities.colorControl.commands.setHue.NAME] = set_hue_handler, + [capabilities.colorControl.commands.setSaturation.NAME] = set_saturation_handler, + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = step_level_handler, + }, + [capabilities.statelessColorTemperatureStep.ID] = { + [capabilities.statelessColorTemperatureStep.commands.stepColorTemperatureByPercent.NAME] = step_color_temp_handler, + }, + }, + + -- override the default capability message handler if batched receives are supported + capability_message_handler = type(cosock.socket.capability().receive_batch) == "function" and + HueDriver.batch_capability_message_handler or nil, + + ignored_bridges = {}, + joined_bridges = {}, + hue_identifier_to_device_record_by_bridge = {}, + services_for_device_rid = {}, + -- the only real way we have to know which bridge a bulb wants to use at migration time + -- is by looking at the stored api key so we will make a map to look up bridge IDs with + -- the API key as the map key. + api_key_to_bridge_id = {}, + stray_device_tx = stray_device_tx, + _devices_pending_refresh = {} + } + + -- What's going on here is that a driver template can't utilize metatables, because the metatable + -- won't get reflected in to the driver. But if you want to attach custom functionality to a driver, + -- such as custom methods, you won't get proper IDE support to go to the definition. We've defined the + -- custom behavior here on this "class", and then attach the customizations directly to the template like + -- this so that we get the best of both worlds; you can jump to definition on a function in this file, and + -- it gets attached to the driver built out of the template returned here. + for k, v in pairs(HueDriver) do + template[k] = v + end + + if type(dbg_config) == "table" and dbg_config.enable_debug then + local hue_debug = require "hue_debug" + hue_debug.enable_dbg_config(template, dbg_config) + end + + return template +end + +function HueDriver:run() + self.__running = true + Driver.run(self) +end + +---@param grandchild_devices { waiting_resource_info: HueResourceInfo, join_callback: fun(driver: HueDriver, waiting_resource_info: HueResourceInfo, parent_device: HueChildDevice)}[] +---@param waiting_for string +function HueDriver:queue_grandchild_device_for_join(grandchild_devices, waiting_for) + self.waiting_grandchildren = self.waiting_grandchildren or {} + + for _, waiting_info in ipairs(grandchild_devices) do + self.waiting_grandchildren[waiting_for] = self.waiting_grandchildren[waiting_for] or {} + table.insert(self.waiting_grandchildren[waiting_for], waiting_info) + end +end + +---@param new_device HueChildDevice +function HueDriver:check_waiting_grandchildren_for_device(new_device) + if not self.waiting_grandchildren then + return + end + local rid = utils.get_hue_rid(new_device) + for _, waiting in pairs(self.waiting_grandchildren[rid or ""] or {}) do + local waiting_info = waiting.waiting_resource_info + local join_callback = waiting.join_callback + if type(join_callback) == "function" then + cosock.spawn(function() + join_callback(self, waiting_info, new_device) + end) + end + end +end + +---@param bridge_network_id string +---@param bridge_info HueBridgeInfo +function HueDriver:update_bridge_netinfo(bridge_network_id, bridge_info) + if self.joined_bridges[bridge_network_id] then + local bridge_device = self:get_device_by_dni(bridge_network_id) --[[@as HueBridgeDevice]] + if not bridge_device then + log.warn_with({ hub_logs = true }, + string.format( + "Couldn't locate bridge device for joined bridge with DNI %s", + bridge_network_id + ) + ) + return + end + + if bridge_info.ip ~= bridge_device:get_field(Fields.IPV4) then + bridge_utils.update_bridge_fields_from_info(self, bridge_info, bridge_device) + local maybe_api_client = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + local maybe_api_key = bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) or Discovery.api_keys[bridge_network_id] + local maybe_event_source = bridge_device:get_field(Fields.EVENT_SOURCE) + local bridge_url = "https://" .. bridge_info.ip + + if maybe_api_key then + if maybe_api_client then + maybe_api_client:update_connection(bridge_url, maybe_api_key) + end + + if maybe_event_source then + maybe_event_source:close() + bridge_device:set_field(Fields.EVENT_SOURCE, nil) + bridge_utils.do_bridge_network_init(self, bridge_device, bridge_url, maybe_api_key) + end + end + end + end +end + +---@param dni string +---@param force_refresh boolean? +---@return HueDevice? +function HueDriver:get_device_by_dni(dni, force_refresh) + local device_uuid = self.datastore.dni_to_device_id[dni] + if not device_uuid then return nil end + return self:get_device_info(device_uuid, force_refresh) +end + +---@param device HueDevice +function HueDriver:do_hue_child_delete(device) + if type(self.try_delete_device) ~= "function" then + local _log = device.log or log + _log.warn("Requesting device delete on API version that doesn't support it. Marking device offline.") + device:offline() + return + end + + self:try_delete_device(device.id) +end + +local function supports_switch(hue_repr) + return + hue_repr.on ~= nil + and type(hue_repr.on) == "table" + and type(hue_repr.on.on) == "boolean" +end + +local function supports_switch_level(hue_repr) + return + hue_repr.dimming ~= nil + and type(hue_repr.dimming) == "table" + and type(hue_repr.dimming.brightness) == "number" +end + +local function supports_color_temp(hue_repr) + return + hue_repr.color_temperature ~= nil + and type(hue_repr.color_temperature) == "table" + and next(hue_repr.color_temperature) ~= nil +end + +local function supports_color_control(hue_repr) + return + hue_repr.color ~= nil + and type(hue_repr.color) == "table" + and type(hue_repr.color.xy) == "table" + and type(hue_repr.color.gamut) == "table" +end + +---@type table +local support_check_handlers = { + [capabilities.switch.ID] = supports_switch, + [capabilities.switchLevel.ID] = supports_switch_level, + [capabilities.colorControl.ID] = supports_color_control, + [capabilities.colorTemperature.ID] = supports_color_temp +} + +---@param hue_repr table +---@param capability_id string +---@return boolean +function HueDriver.check_hue_repr_for_capability_support(hue_repr, capability_id) + local handler = support_check_handlers[capability_id] + if type(handler) == "function" then + return handler(hue_repr) + else + return false + end +end + +---@param cmd_table BatchedCommand +function HueDriver:handle_single_command(cmd_table) + local device = self:get_device_info(cmd_table.device_uuid) + if device ~= nil then + -- Default handler + device.thread:queue_event( + Driver.handle_capability_command, self, device, cmd_table.capability_command + ) + end +end + +---@param handler function +---@param bridge_device HueBridgeDevice +---@param group HueLightGroup +---@param cmd BatchedCommand +function HueDriver:handle_batch_command(handler, bridge_device, group, cmd) + local cap_command = cmd.capability_command + local capability = cap_command.capability + local command = cap_command.command + -- This really shouldn't ever fail especially if using unaware capabilities, but it is not the + -- end of the world if it does here since it would have failed for ALL the commands + local valid = + capabilities[capability].commands[command]:validate_and_normalize_command(cap_command) + if not valid then + log.error(string.format("Invalid batch capability command: %s.%s (%s)", + capability, command, st_utils.stringify_table(cap_command.args) + )) + else + log.info_with({hub_logs = true}, string.format( + " Handling command %s.%s (%s) as batch. Sending to %s(%s) with %d devices.", + capability, command, st_utils.stringify_table(cap_command.args), + group.type, group.metadata.name, #group.devices + )) + + bridge_device.thread:queue_event( + handler, self, bridge_device, group, cap_command, cmd.auxilary_command_data + ) + end +end + +function HueDriver:batch_capability_message_handler(capability_channel) + local batch, err = capability_channel:receive_batch() + + -- nil or empty batch + if not batch or #batch == 0 then + log.error(string.format("Error receiving batched commands: %s", err or "unknown error")) + return + end + + -- Handle common case of single command. + if #batch == 1 then + log.info("Batch not big enough to handle with group commands") + self:handle_single_command(batch[1]) + return + end + + -- Sort batch by matching commands, args, and bridge. + log.info(string.format("Sorting batch of %d commands", #batch)) + local sorted_batch, misfits = batched_command_utils.sort_batch(self, batch) + + -- Send off any misfits to be handled as a single command + if #misfits ~= 0 then + log.info(string.format("Handling %d batch misfits", #misfits)) + for _, command in ipairs(misfits) do + self:handle_single_command(command) + end + end + + log.info(string.format("Fanning out sorted batch for %d bridges", #sorted_batch)) + for bridge_device, by_command in pairs(sorted_batch) do + log.info(string.format( + " Fanning out %d distinct commands for %s", + #by_command, bridge_device.label or "unknown bridge" + )) + for command_name, by_matching_args in pairs(by_command) do + log.info(string.format( + " Fanning out %d distinct args variations for %s command", + #by_matching_args, command_name + )) + for _, matching_commands in ipairs(by_matching_args) do + log.info(string.format(" %d commands are exact arg matches", #matching_commands)) + -- Unique command + if #matching_commands == 1 then + for _, command in pairs(matching_commands) do + self:handle_single_command(command) + end + else + local matching_groups = + batched_command_utils.find_matching_groups(bridge_device, matching_commands) + -- Handle any matching groups + for group, command in pairs(matching_groups) do + local handler = batched_command_utils.get_handler(command) + if handler then -- Should never be nil at this point but to make the diagnostic happy + self:handle_batch_command(handler, bridge_device, group, command) + end + end + -- Handle any potential remaining commands + for _, command in pairs(matching_commands) do + self:handle_single_command(command) + end + end + end + end + end +end + +return HueDriver diff --git a/drivers/SmartThings/philips-hue/src/init.lua b/drivers/SmartThings/philips-hue/src/init.lua index fed7768d01..860eaffd08 100644 --- a/drivers/SmartThings/philips-hue/src/init.lua +++ b/drivers/SmartThings/philips-hue/src/init.lua @@ -17,1587 +17,23 @@ -- Improvements to be made: -- -- =============================================================================================== -local cosock = require "cosock" +local logjam = require "logjam" +logjam.enable_passthrough() +logjam.inject_global() + local log = require "log" -local capabilities = require "st.capabilities" local Driver = require "st.driver" -local json = require "st.json" local st_utils = require "st.utils" -- trick to fix the VS Code Lua Language Server typechecking ----@type fun(val: table, name: string?, multi_line: boolean?): string +---@type fun(val: any?, name: string?, multi_line: boolean?): string st_utils.stringify_table = st_utils.stringify_table local Discovery = require "disco" -local EventSource = require "lunchbox.sse.eventsource" -local Fields = require "fields" -local handlers = require "handlers" -local HueApi = require "hue.api" -local HueColorUtils = require "hue.cie_utils" -local lunchbox_util = require "lunchbox.util" -local utils = require "utils" - -local syncCapabilityId = "samsungim.hueSyncMode" -local hueSyncMode = capabilities[syncCapabilityId] - -local StrayDeviceMessageTypes = { - FoundBridge = "FOUND_BRIDGE", - NewStrayLight = "NEW_STRAY_LIGHT", -} - ---- minimum colortemp value from Hue -local DEFAULT_MIREK = 153 - --- "forward declare" some functions -local _initialize, bridge_added, light_added, init_light, init_bridge - -local function safe_wrap_handler(handler) - return function(driver, device, ...) - if device == nil or (device and device.id == nil) then - log.warn("Tried to handle capability command for device that has been deleted") - return - end - local success, result = pcall(handler, driver, device, ...) - if not success then - log.error_with({ hub_logs = true }, string.format("Failed to invoke capability command handler. Reason: %s", result)) - end - return result - end -end - -local refresh_handler = safe_wrap_handler(handlers.refresh_handler) -local switch_on_handler = safe_wrap_handler(handlers.switch_on_handler) -local switch_off_handler = safe_wrap_handler(handlers.switch_off_handler) -local switch_level_handler = safe_wrap_handler(handlers.switch_level_handler) -local set_color_handler = safe_wrap_handler(handlers.set_color_handler) -local set_hue_handler = safe_wrap_handler(handlers.set_hue_handler) -local set_saturation_handler = safe_wrap_handler(handlers.set_saturation_handler) -local set_color_temp_handler = safe_wrap_handler(handlers.set_color_temp_handler) - ----@param light_device HueChildDevice ----@param light table -local function emit_light_status_events(light_device, light) - if light_device ~= nil then - if light.status then - if light.status == "connected" then - light_device.log.info_with({hub_logs=true}, "Light status event, marking device online") - light_device:online() - light_device:set_field(Fields.IS_ONLINE, true) - elseif light.status == "connectivity_issue" then - light_device.log.info_with({hub_logs=true}, "Light status event, marking device offline") - light_device:set_field(Fields.IS_ONLINE, false) - light_device:offline() - return - end - end - - if light_device:get_field(Fields.IS_ONLINE) ~= true then - return - end - - if light.mode then - light_device:emit_event(hueSyncMode.mode(light.mode)) - end - - if light.on and light.on.on then - light_device:emit_event(capabilities.switch.switch.on()) - elseif light.on and not light.on.on then - light_device:emit_event(capabilities.switch.switch.off()) - end - - if light.dimming then - local adjusted_level = st_utils.round(st_utils.clamp_value(light.dimming.brightness, 1, 100)) - if utils.is_nan(adjusted_level) then - light_device.log.warn( - string.format( - "Non numeric value %s computed for switchLevel Attribute Event, ignoring.", - adjusted_level - ) - ) - else - light_device:emit_event(capabilities.switchLevel.level(adjusted_level)) - end - end - - if light.color_temperature then - local mirek = DEFAULT_MIREK - if light.color_temperature.mirek_valid then - mirek = light.color_temperature.mirek - end - local min = light_device:get_field(Fields.MIN_KELVIN) or HueApi.MIN_TEMP_KELVIN_WHITE_AMBIANCE - local kelvin = math.floor( - st_utils.clamp_value(handlers.mirek_to_kelvin(mirek), min, HueApi.MAX_TEMP_KELVIN) - ) - if utils.is_nan(kelvin) then - light_device.log.warn( - string.format( - "Non numeric value %s computed for colorTemperature Attribute Event, ignoring.", - kelvin - ) - ) - else - light_device:emit_event(capabilities.colorTemperature.colorTemperature(kelvin)) - end - end - - if light.color then - light_device:set_field(Fields.GAMUT, light.color.gamut, { persist = true }) - local r, g, b = HueColorUtils.safe_xy_to_rgb(light.color.xy, light.color.gamut) - local hue, sat, _ = st_utils.rgb_to_hsv(r, g, b) - -- We sent a command where hue == 100 and wrapped the value to 0, reverse that here - if light_device:get_field(Fields.WRAPPED_HUE) == true and (hue + .05 >= 1 or hue - .05 <= 0) then - hue = 1 - light_device:set_field(Fields.WRAPPED_HUE, false) - end - - local adjusted_hue = st_utils.clamp_value(st_utils.round(hue * 100), 0, 100) - local adjusted_sat = st_utils.clamp_value(st_utils.round(sat * 100), 0, 100) - - if utils.is_nan(adjusted_hue) then - light_device.log.warn( - string.format( - "Non numeric value %s computed for colorControl.hue Attribute Event, ignoring.", - adjusted_hue - ) - ) - else - light_device:emit_event(capabilities.colorControl.hue(adjusted_hue)) - end - - if utils.is_nan(adjusted_sat) then - light_device.log.warn( - string.format( - "Non numeric value %s computed for colorControl.saturation Attribute Event, ignoring.", - adjusted_sat - ) - ) - else - light_device:emit_event(capabilities.colorControl.saturation(adjusted_sat)) - end - end - end -end - ----@param driver HueDriver ----@param device HueBridgeDevice -local function migrate_bridge(driver, device) - log.info_with({ hub_logs = true }, - string.format("Migrate Bridge for device %s", (device.label or device.id or "unknown device"))) - local api_key = device.data.username - local ipv4 = device.data.ip - local device_dni = device.device_network_id - - local known_macs = {} - known_macs[ipv4] = device_dni - - log.info( - string.format("Rediscovering bridge for migrated device %s", (device.label or device.id or "unknown device"))) - cosock.spawn( - function() - local bridge_found = false - local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) - local sleep_time = 0.1 - while true do - log.info( - string.format( - "[MigrateBridge] Scanning for Hue Bridge info for migrated device %s", - (device.label or device.id or "unknown device") - ) - ) - Discovery.search_for_bridges(driver, known_macs, function(hue_driver, bridge_ip, bridge_id) - if bridge_id ~= device_dni then return end - - log.info( - string.format( - "[MigrateBridge] Matching Hue Bridge for migrated device %s found, querying configuration values", - (device.label or device.id or "unknown device") - ) - ) - - local bridge_info = driver.datastore.bridge_netinfo[bridge_id] - - if not bridge_info then - log.debug(string.format("Bridge info for %s not yet available", bridge_id)) - return - end - - if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then - log.warn("Found bridge that does not support CLIP v2 API, ignoring") - hue_driver.ignored_bridges[bridge_id] = true - return - end - - hue_driver.joined_bridges[bridge_id] = true - Discovery.api_keys[bridge_id] = api_key - - local new_metadata = { - profile = "hue-bridge", - manufacturer = "Signify Netherlands B.V.", - model = bridge_info.modelid or "BSB002", - vendor_provided_label = (bridge_info.name or "Philips Hue Bridge"), - } - - device:try_update_metadata(new_metadata) - log.info_with({ hub_logs = true }, - string.format("Bridge %s Migrated, re-adding", (device.label or device.id or "unknown device"))) - log.debug(string.format( - "Re-requesting added handler for %s after migrating", (device.label or device.id or "unknown device") - )) - bridge_added(hue_driver, device) - log.debug(string.format( - "Re-requesting init handler for %s after migrating", (device.label or device.id or "unknown device") - )) - init_bridge(hue_driver, device) - bridge_found = true - end) - if bridge_found then return end - - if sleep_time < 10 then - sleep_time = backoff_generator() - end - log.warn( - string.format( - "[MigrateBridge] Failed to find bridge info for device %s, waiting %s seconds then trying again", - (device.label or device.id or "unknown device"), - sleep_time - ) - ) - cosock.socket.sleep(sleep_time) - end - end, - string.format("bridge migration thread for %s", device.label) - ) -end - ----@param driver HueDriver ----@param device HueBridgeDevice -local function spawn_bridge_add_api_key_task(driver, device) - local device_bridge_id = device.device_network_id - cosock.spawn(function() - -- 30 seconds is the typical UX for waiting to hit the link button in the Hue ecosystem - local timeout_time = cosock.socket.gettime() + 30 - - -- we pre-declare these variables in the outer scope so that our gotos work. - -- a sad day that we need these gotos. - local api_key_response, err, api_key, bridge_info, bridge_ip, _ - repeat - local time_remaining = math.max(0, timeout_time - cosock.socket.gettime()) - if time_remaining == 0 then - local _log = device.log or log - _log.error_with({ hub_logs = true }, - string.format( - "Link button not pressed or API key not received for bridge \"%s\" after 30 seconds, sleeping then trying again in a few minutes.", - device.label - ) - ) - cosock.socket.sleep(120) -- two minutes - timeout_time = cosock.socket.gettime() + 30 -- refresh timeout time - goto continue - end - - if not driver.datastore.bridge_netinfo[device_bridge_id] then - goto continue - end - - bridge_info = driver.datastore.bridge_netinfo[device_bridge_id] - bridge_ip = bridge_info.ip - - api_key_response, err, _ = HueApi.request_api_key( - bridge_ip, - utils.labeled_socket_builder((device.label or device.device_network_id or device.id or "unknown bridge")) - ) - - if err ~= nil or not api_key_response then - log.warn("Error while trying to request Bridge API Key: ", err) - goto continue - end - - for _, item in ipairs(api_key_response) do - if item.error ~= nil then - log.warn("Error paylod in bridge API key response: " .. item.error.description) - goto continue - end - - api_key = item.success.username - end - - ::continue:: - -- don't hammer the bridge since we're waiting for the user to hit the link button - if api_key == nil then cosock.socket.sleep(2) end - until api_key ~= nil - - if not api_key then - log.error_with({ hub_logs = true }, "Link button not pressed or API key not received for bridge " .. - (device.label or device.device_network_id or device.id or "unknown")) - return - end - - Discovery.api_keys[device_bridge_id] = api_key - _initialize(driver, device) - end, "Hue Bridge Background Join Task") -end - -local function update_bridge_fields_from_info(driver, bridge_info, bridge_device) - local bridge_ip = bridge_info.ip - local device_bridge_id = bridge_device.device_network_id - - if bridge_device:get_field(Fields._REFRESH_AFTER_INIT) == nil then - bridge_device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) - end - - bridge_device:set_field(Fields.DEVICE_TYPE, "bridge", { persist = true }) - bridge_device:set_field(Fields.MODEL_ID, bridge_info.modelid, { persist = true }) - bridge_device:set_field(Fields.BRIDGE_ID, device_bridge_id, { persist = true }) - bridge_device:set_field(Fields.BRIDGE_SW_VERSION, tonumber(bridge_info.swversion or "0", 10), { persist = true }) - - if Discovery.api_keys[device_bridge_id] then - bridge_device:set_field(HueApi.APPLICATION_KEY_HEADER, Discovery.api_keys[device_bridge_id], { persist = true }) - driver.api_key_to_bridge_id[Discovery.api_keys[device_bridge_id]] = device_bridge_id - end - bridge_device:set_field(Fields.IPV4, bridge_ip, { persist = true }) -end - ----@param driver HueDriver ----@param device HueBridgeDevice -bridge_added = function(driver, device) - log.info_with({ hub_logs = true }, - string.format("Bridge Added for device %s", (device.label or device.id or "unknown device"))) - local device_bridge_id = device.device_network_id - local bridge_info = driver.datastore.bridge_netinfo[device_bridge_id] - - if not bridge_info then - local known_macs = {} - cosock.spawn( - function() - local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) - local sleep_time = 0.1 - local bridge_found = false - - while true do - log.info( - string.format( - "[BridgeAdded] Scanning the network for Hue Bridge matching device %s", - (device.label or device.id or "unknown device") - ) - ) - Discovery.search_for_bridges(driver, known_macs, function(driver, bridge_ip, bridge_id) - if bridge_id ~= device_bridge_id then return end - - log.info(string.format( - "[BridgeAdded] Found Hue Bridge on the network for %s, querying configuration values", - (device.label or device.id or "unknown device") - ) - ) - local bridge_info = driver.datastore.bridge_netinfo[bridge_id] - - if not bridge_info then - log.debug(string.format("Bridge info for %s not yet available", bridge_id)) - return - end - - if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then - log.warn("Found bridge that does not support CLIP v2 API, ignoring") - driver.ignored_bridges[bridge_id] = true - return - end - - driver.joined_bridges[bridge_id] = true - update_bridge_fields_from_info(driver, bridge_info, device) - device:set_field(Fields._ADDED, true, { persist = true }) - bridge_found = true - end) - if bridge_found then return end - - if sleep_time < 10 then - sleep_time = backoff_generator() - end - log.warn( - string.format( - "[BridgeAdded] Failed to find bridge info for device %s, waiting %s seconds then trying again", - (device.label or device.id or "unknown device"), - sleep_time - ) - ) - cosock.socket.sleep(sleep_time) - end - end, - "bridge added re-scan" - ) - else - update_bridge_fields_from_info(driver, bridge_info, device) - device:set_field(Fields._ADDED, true, { persist = true }) - end - - if not Discovery.api_keys[device_bridge_id] then - log.error_with({ hub_logs = true }, - string.format( - "Received `added` lifecycle event for bridge %s with unknown API key, " .. - "please press the Link Button on your Hue Bridge(s).", - (device.label or device.device_network_id or device.id or "unknown bridge") - ) - ) - -- we have to do a long poll here to give the user a chance to hit the link button on the - -- hue bridge, so in this specific pathological case we start a new coroutine and complete - -- the handling of the device add on a different task to free up the driver task - spawn_bridge_add_api_key_task(driver, device) - return - end -end - ----@param driver HueDriver ----@param device HueChildDevice ----@param parent_device_id nil|string ----@param hue_light_description nil|table -local function migrate_light(driver, device, parent_device_id, hue_light_description) - local api_key = device.data.username - local v1_id = device.data.bulbId - local bridge_id = driver.api_key_to_bridge_id[api_key] - - log.info_with({ hub_logs = true }, - string.format("Migrate Light for device %s with v1_id %s and bridge_id %s", - (device.label or device.id or "unknown device"), v1_id, (bridge_id or "") - ) - ) - - local bridge_device = nil - if parent_device_id ~= nil then - bridge_device = driver:get_device_info(parent_device_id, false) - end - - if not bridge_device then - bridge_device = driver:get_device_by_dni(bridge_id) - end - - local api_instance = (bridge_device and bridge_device:get_field(Fields.BRIDGE_API)) - or (bridge_device and Discovery.disco_api_instances[bridge_device.device_network_id]) - local light_resource = hue_light_description or - Discovery.light_state_disco_cache[(device:get_field(Fields.RESOURCE_ID))] - - if not (api_instance and bridge_device and bridge_device:get_field(Fields._INIT) - and driver.joined_bridges[bridge_id] and light_resource) then - local bridge_dni = "not available" - if bridge_device then bridge_dni = bridge_device.device_network_id end - log.warn(string.format( - 'Attempting to migrate "stray" bulb before Hue Bridge network connection is fully established\n' .. - '(bridge not added or light resource not identified).\n' .. - '\tBulb Label: %s\n' .. - '\tBulb DTH API KEY: %s\n' .. - '\tBulb DTH v1_id: %s\n' .. - '\tBulb DNI: %s\n' .. - '\tBulb Parent Assigned Key: %s\n' .. - '\tMaybe Bridge Id: %s\n' .. - '\t`bridge_device` nil? %s\n' .. - '\tBridge marked as joined? %s\n' .. - '\tBridge Device DNI: %s', - (device.label), - api_key, - v1_id, - device.device_network_id, - device.parent_assigned_child_key, - bridge_id, - (bridge_device == nil), - driver.joined_bridges[bridge_id], - bridge_dni - )) - driver.stray_bulb_tx:send({ - type = StrayDeviceMessageTypes.NewStrayLight, - driver = driver, - device = device - }) - return - end - - log.info( - string.format("Found parent bridge %s for migrated light %s, beginning update and onboard" - , (bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge device") - , (device.label or device.id or "unknown light device") - ) - ) - - local mismatches = {} - local bridge_support = {} - local profile_support = {} - for _, cap in ipairs({ - capabilities.switch, - capabilities.switchLevel, - capabilities.colorTemperature, - capabilities.colorControl - }) do - local payload = {} - if cap.ID == capabilities.switch.ID then - payload = light_resource.on - elseif cap.ID == capabilities.switchLevel.ID then - payload = light_resource.dimming - elseif cap.ID == capabilities.colorControl.ID then - payload = light_resource.color - elseif cap.ID == capabilities.colorTemperature.ID then - payload = light_resource.color_temperature - end - - local profile_supports = device:supports_capability_by_id(cap.ID, nil) - local bridge_supports = driver.check_hue_repr_for_capability_support(light_resource, cap.ID) - - bridge_support[cap.NAME] = { - supports = bridge_supports, - payload = payload - } - profile_support[cap.NAME] = profile_supports - - if bridge_supports ~= profile_supports then - table.insert(mismatches, cap.NAME) - end - end - - local dbg_table = { - _mismatches = mismatches, - _name = { - device_label = (device.label or device.id or "unknown label"), - hue_name = (light_resource.hue_provided_name or "no name given") - }, - bridge_supports = bridge_support, - profile_supports = profile_support - } - - device.log.info_with({ hub_logs = true }, st_utils.stringify_table( - dbg_table, - "Comparing profile-reported capabilities to bridge reported representation", - false - )) - - local new_metadata = { - manufacturer = light_resource.hue_device_data.product_data.manufacturer_name, - model = light_resource.hue_device_data.product_data.model_id, - vendor_provided_label = light_resource.hue_device_data.product_data.product_name, - } - device:try_update_metadata(new_metadata) - - log.info(string.format( - "Migration to CLIPV2 for %s complete, going through onboarding flow again", - (device.label or device.id or "unknown device") - )) - log.debug(string.format( - "Re-requesting added handler for %s after migrating", (device.label or device.id or "unknown device") - )) - light_added(driver, device, bridge_device.id, light_resource.id) - log.debug(string.format( - "Re-requesting init handler for %s after migrating", (device.label or device.id or "unknown device") - )) - init_light(driver, device) -end - ----@param driver HueDriver ----@param device HueChildDevice ----@param parent_device_id nil|string ----@param resource_id nil|string -light_added = function(driver, device, parent_device_id, resource_id) - log.info( - string.format("Light Added for device %s", (device.label or device.id or "unknown device"))) - local child_key = device.parent_assigned_child_key - local device_light_resource_id = resource_id or child_key - - local light_info_known = (Discovery.light_state_disco_cache[device_light_resource_id] ~= nil) - if not light_info_known then - log.info( - string.format("Querying device info for parent of %s", (device.label or device.id or "unknown device"))) - local parent_bridge = driver:get_device_info(parent_device_id or device.parent_device_id or - device:get_field(Fields.PARENT_DEVICE_ID)) - if not parent_bridge then - log.error_with({ hub_logs = true }, string.format( - "Device %s added with parent UUID of %s but could not find a device with that UUID in the driver", - (device.label or device.id or "unknown device"), - (device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID)))) - return - end - - log.info( - string.format( - "Found parent bridge device %s info for %s", - (parent_bridge.label or parent_bridge.device_network_id or parent_bridge.id or "unknown bridge"), - (device.label or device.id or "unknown device") - ) - ) - - local key = parent_bridge:get_field(HueApi.APPLICATION_KEY_HEADER) - local bridge_ip = parent_bridge:get_field(Fields.IPV4) - local bridge_id = parent_bridge:get_field(Fields.BRIDGE_ID) - if not (Discovery.api_keys[bridge_id or {}] or key) then - log.warn( - "Found \"stray\" bulb without associated Hue Bridge. Waiting to see if a bridge becomes available.") - driver.stray_bulb_tx:send({ - type = StrayDeviceMessageTypes.NewStrayLight, - driver = driver, - device = device - }) - return - end - - local api_instance = - parent_bridge:get_field(Fields.BRIDGE_API) or Discovery.disco_api_instances[bridge_id] - - if not api_instance then - api_instance = HueApi.new_bridge_manager( - "https://" .. bridge_ip, - (parent_bridge:get_field(HueApi.APPLICATION_KEY_HEADER) or Discovery.api_keys[bridge_id] or key), - utils.labeled_socket_builder( - (parent_bridge.label or bridge_id or parent_bridge.id or "unknown bridge") - ) - ) - Discovery.disco_api_instances[parent_bridge.device_network_id] = api_instance - end - - local light_resource, err, _ = api_instance:get_light_by_id(device_light_resource_id) - if err ~= nil or not light_resource then - log.error_with({ hub_logs = true }, "Error getting light info: ", error) - return - end - - if light_resource.errors and #light_resource.errors > 0 then - log.error_with({ hub_logs = true }, "Errors found in API response:") - for idx, err in ipairs(light_resource.errors) do - log.error_with({ hub_logs = true }, st_utils.stringify_table(err, "Error " .. idx, true)) - end - return - end - - for _, light in ipairs(light_resource.data or {}) do - if device_light_resource_id == light.id then - Discovery.light_state_disco_cache[light.id] = { - hue_provided_name = light.metadata.name, - id = light.id, - on = light.on, - color = light.color, - dimming = light.dimming, - color_temperature = light.color_temperature, - mode = light.mode, - parent_device_id = parent_bridge.id, - hue_device_id = light.owner.rid, - hue_device_data = { - product_data = { - manufacturer_name = device.manufacturer, - model_id = device.model, - product_name = device.vendor_provided_label - } - } - } - light_info_known = true - break - end - end - end - - -- still unable to get information about the bulb over REST API, bailing - if not light_info_known then - log.warn(string.format( - "Couldn't get light info for %s, marking as \"stray\"", (device.label or device.id or "unknown device") - )) - driver.stray_bulb_tx:send({ - type = StrayDeviceMessageTypes.NewStrayLight, - driver = driver, - device = device - }) - return - end - - local light_info = Discovery.light_state_disco_cache[device_light_resource_id] - local minimum_dimming = 2 - - if light_info.dimming and light_info.dimming.min_dim_level then minimum_dimming = light_info.dimming.min_dim_level end - - -- persistent fields - device:set_field(Fields.DEVICE_TYPE, "light", { persist = true }) - if light_info.color ~= nil and light_info.color.gamut then - device:set_field(Fields.GAMUT, light_info.color.gamut, { persist = true }) - end - device:set_field(Fields.HUE_DEVICE_ID, light_info.hue_device_id, { persist = true }) - device:set_field(Fields.MIN_DIMMING, minimum_dimming, { persist = true }) - device:set_field(Fields.PARENT_DEVICE_ID, light_info.parent_device_id, { persist = true }) - device:set_field(Fields.RESOURCE_ID, device_light_resource_id, { persist = true }) - device:set_field(Fields._ADDED, true, { persist = true }) - device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) - - driver.light_id_to_device[device_light_resource_id] = device - - -- the refresh handler adds lights that don't have a fully initialized bridge to a queue. - refresh_handler(driver, device) -end - -local function do_bridge_network_init(driver, bridge_device, bridge_url, api_key) - if not bridge_device:get_field(Fields.EVENT_SOURCE) then - log.info_with({ hub_logs = true }, "Creating SSE EventSource for bridge " .. - (bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge")) - local url_table = lunchbox_util.force_url_table(bridge_url .. "/eventstream/clip/v2") - local eventsource = EventSource.new( - url_table, - { [HueApi.APPLICATION_KEY_HEADER] = api_key }, - nil - ) - - eventsource.onopen = function(msg) - log.info_with({ hub_logs = true }, - string.format("Event Source Connection for Hue Bridge \"%s\" established, marking online", bridge_device.label)) - bridge_device:online() - - local bridge_api = bridge_device:get_field(Fields.BRIDGE_API) - cosock.spawn(function() - -- We don't want to do a scan if we're already in a discovery loop, - -- because the event source connection will open if a bridge is discovered - -- and we'll effectively be scanning twice. - -- Two scans that find the same device close together can emit events close enough - -- together that the dedupe logic at the cloud layer will get bypassed and lead to - -- duplicate device records. - if not Discovery.discovery_active then - Discovery.scan_bridge_and_update_devices(driver, bridge_device:get_field(Fields.BRIDGE_ID)) - end - local child_device_map = {} - local children = bridge_device:get_child_list() - local _log = bridge_device.log or log - _log.debug(string.format("Scanning connectivity of %s child devices", #children)) - for _, device_record in ipairs(children) do - local hue_device_id = device_record:get_field(Fields.HUE_DEVICE_ID) - if hue_device_id ~= nil then - child_device_map[hue_device_id] = device_record - end - end - - local scanned = false - local connectivity_status, rest_err - - while true do - if scanned then break end - connectivity_status, rest_err = bridge_api:get_connectivity_status() - if rest_err ~= nil then - log.error(string.format("Couldn't query Hue Bridge %s for zigbee connectivity status for child devices: %s", - bridge_device.label, st_utils.stringify_table(rest_err, "Rest Error", true))) - goto continue - end - - if connectivity_status.errors and #connectivity_status.errors > 0 then - log.error( - string.format( - "Hue Bridge %s replied with the following error message(s) " .. - "when querying child device connectivity status:", - bridge_device.label - ) - ) - for idx, err in ipairs(connectivity_status.errors) do - log.error(string.format("--- %s", st_utils.stringify_table(err, string.format("Error %s:", idx), true))) - end - goto continue - end - - if connectivity_status.data and #connectivity_status.data > 0 then - scanned = true - for _, status in ipairs(connectivity_status.data) do - local hue_device_id = (status.owner and status.owner.rid) or "" - log.trace(string.format("Checking connectivity status for device resource id %s", hue_device_id)) - local child_device = child_device_map[hue_device_id] - if child_device then - if not child_device.id then - child_device_map[hue_device_id] = nil - else - if status.status == "connected" then - child_device.log.info_with({hub_logs=true}, "Marking Online after SSE Reconnect") - child_device:online() - child_device:set_field(Fields.IS_ONLINE, true) - elseif status.status == "connectivity_issue" then - child_device.log.info_with({hub_logs=true}, "Marking Offline after SSE Reconnect") - child_device:set_field(Fields.IS_ONLINE, false) - child_device:offline() - end - end - end - end - end - - ::continue:: - end - end, string.format("Hue Bridge %s Zigbee Scan Task", bridge_device.label)) - end - - eventsource.onerror = function() - log.error_with({ hub_logs = true }, string.format("Hue Bridge \"%s\" Event Source Error", bridge_device.label)) - - for _, device_record in ipairs(bridge_device:get_child_list()) do - device_record:set_field(Fields.IS_ONLINE, false) - device_record:offline() - end - - bridge_device:offline() - end - - eventsource.onmessage = function(msg) - if msg and msg.data then - local json_result = table.pack(pcall(json.decode, msg.data)) - local success = table.remove(json_result, 1) - local events, err = table.unpack(json_result, 1, json_result.n) - - if not success then - log.error_with({ hub_logs = true }, - "Couldn't decode JSON in SSE callback: " .. (events or "unexpected nil from pcall catch")) - return - end - - if err ~= nil then - log.error_with({ hub_logs = true }, "JSON Parsing Error: " .. err) - return - end - - for _, event in ipairs(events) do - if event.type == "update" then - for _, update_data in ipairs(event.data) do - --- for a regular message from a light doing something normal, - --- you get the resource id of the light service for that device in - --- the data field - local light_resource_id = update_data.id - if update_data.type == "zigbee_connectivity" and update_data.owner ~= nil then - --- zigbee connectivity messages sometimes emit with the device as the owner - light_resource_id = driver.device_rid_to_light_rid[update_data.owner.rid] - end - local light_device = driver.light_id_to_device[light_resource_id] - if light_device ~= nil and light_device.id ~= nil then - driver.emit_light_status_events(light_device, update_data) - end - end - elseif event.type == "delete" then - for _, delete_data in ipairs(event.data) do - if delete_data.type == "light" then - local light_resource_id = delete_data.id - local light_device = driver.light_id_to_device[light_resource_id] - if light_device ~= nil and light_device.id ~= nil then - log.info( - string.format( - "Light device \"%s\" was deleted from hue bridge %s", - (light_device.label or light_device.id or "unknown device"), - (bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge") - ) - ) - light_device.log.trace("Attempting to delete Device UUID " .. tostring(light_device.id)) - driver:do_hue_light_delete(light_device) - end - end - end - elseif event.type == "add" then - for _, add_data in ipairs(event.data) do - if add_data.type == "light" and add_data.owner and add_data.owner.rtype == "device" then - log.info( - string.format( - "New light added to Hue Bridge \"%s\", light properties: \"%s\"", - bridge_device.label, json.encode(add_data) - ) - ) - - cosock.spawn(function() - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) - if hue_api == nil then - local _log = bridge_device.log or log - _log.warn("No Hue API instance available for new light event.") - return - end - - local hue_device_rid = add_data.owner.rid - local rest_resp, rest_err = hue_api:get_device_by_id(hue_device_rid) - - if rest_err ~= nil then - log.error( - string.format( - "Error getting device information for new light \"%s\" with device RID %s: %s", - add_data.metadata.name, - hue_device_rid, - st_utils.stringify_table(rest_err) - ) - ) - return - end - - if rest_resp == nil then - log.error("REST Response while handling New Light Event unexpectedly nil without error message") - return - end - - if rest_resp.errors and #rest_resp.errors > 0 then - for _, hue_error in ipairs(rest_resp.errors) do - log.error_with({ hub_logs = true }, "Error in Hue API response: " .. hue_error.description) - end - return - end - - local new_device_info = nil - for _, hue_device in ipairs(rest_resp.data or {}) do - for _, svc_info in ipairs(hue_device.services or {}) do - if svc_info.rtype == "light" and svc_info.rid == add_data.id then - new_device_info = hue_device - break - end - end - if new_device_info ~= nil then break end - end - - if new_device_info == nil then - log.warn("Couldn't get all device info for new light, unable to join. Try using Scan Nearby to find new Hue lights.") - return - end - - log.info( - string.format( - "Adding light \"%s\"", - add_data.metadata.name - ) - ) - - local profile_ref - - if add_data.color then - if add_data.color_temperature then - profile_ref = "white-and-color-ambiance" - else - profile_ref = "legacy-color" - end - elseif add_data.color_temperature then - profile_ref = "white-ambiance" -- all color temp products support `white` (dimming) - elseif add_data.dimming then - profile_ref = "white" -- `white` refers to dimmable and includes filament bulbs - else - log.warn( - string.format( - "Light resource [%s] does not seem to be A White/White-Ambiance/White-Color-Ambiance device, currently unsupported" - , - add_data.id - ) - ) - return - end - - local create_device_msg = { - type = "EDGE_CHILD", - label = add_data.metadata.name, - vendor_provided_label = new_device_info.product_data.product_name, - profile = profile_ref, - manufacturer = new_device_info.product_data.manufacturer_name, - model = new_device_info.product_data.model_id, - parent_device_id = bridge_device.id, - parent_assigned_child_key = add_data.id, - } - - Discovery.light_state_disco_cache[add_data.id] = { - hue_provided_name = add_data.metadata.name, - id = add_data.id, - on = add_data.on, - color = add_data.color, - dimming = add_data.dimming, - color_temperature = add_data.color_temperature, - mode = add_data.mode, - parent_device_id = bridge_device.id, - hue_device_id = add_data.owner.rid, - hue_device_data = new_device_info - } - - driver:try_create_device(create_device_msg) - end, "New Device Event Task") - end - end - end - end - end - end - - bridge_device:set_field(Fields.EVENT_SOURCE, eventsource, { persist = false }) - end - bridge_device:set_field(Fields._INIT, true, { persist = false }) - local ids_to_remove = {} - for id, light_device in ipairs(driver._lights_pending_refresh) do - local bridge_id = light_device.parent_device_id or bridge_device:get_field(Fields.PARENT_DEVICE_ID) - if bridge_id == bridge_device.id then - table.insert(ids_to_remove, id) - refresh_handler(driver, light_device) - end - end - for _, id in ipairs(ids_to_remove) do - driver._lights_pending_refresh[id] = nil - end - driver.stray_bulb_tx:send({ - type = StrayDeviceMessageTypes.FoundBridge, - driver = driver, - device = bridge_device - }) -end - ----@param driver HueDriver ----@param device HueBridgeDevice -init_bridge = function(driver, device) - log.info( - string.format("Init Bridge for device %s", (device.label or device.id or "unknown device"))) - local device_bridge_id = device:get_field(Fields.BRIDGE_ID) - local bridge_manager = device:get_field(Fields.BRIDGE_API) or Discovery.disco_api_instances[device_bridge_id] - - local ip = device:get_field(Fields.IPV4) - local api_key = device:get_field(HueApi.APPLICATION_KEY_HEADER) - local bridge_url = "https://" .. ip - - if not Discovery.api_keys[device_bridge_id] then - log.debug(string.format( - "init_bridge for %s, caching API key", (device.label or device.id or "unknown device") - )) - Discovery.api_keys[device_bridge_id] = api_key - end - - if not bridge_manager then - log.debug(string.format( - "init_bridge for %s, creating bridge manager", (device.label or device.id or "unknown device") - )) - bridge_manager = HueApi.new_bridge_manager( - bridge_url, - api_key, - utils.labeled_socket_builder((device.label or device_bridge_id or device.id or "unknown bridge")) - ) - Discovery.disco_api_instances[device_bridge_id] = bridge_manager - end - device:set_field(Fields.BRIDGE_API, bridge_manager, { persist = false }) - - if not driver.api_key_to_bridge_id[api_key] then - log.debug(string.format( - "init_bridge for %s, mapping API key to Bridge DNI", (device.label or device.id or "unknown device") - )) - driver.api_key_to_bridge_id[api_key] = device_bridge_id - end - - if not driver.joined_bridges[device_bridge_id] then - log.debug(string.format( - "init_bridge for %s, cacheing bridge info", (device.label or device.id or "unknown device") - )) - cosock.spawn( - function() - local bridge_info - -- create a very slow backoff - local backoff_generator = utils.backoff_builder(10, 0.1, 0.1) - local sleep_time = 0.1 - while true do - log.info( - string.format( - "[BridgeInit] Querying bridge %s for configuration values", - (device.label or device.id or "unknown device") - ) - ) - bridge_info = driver.datastore.bridge_netinfo[device_bridge_id] - - if not bridge_info then - log.debug(string.format("Bridge info for %s not yet available", device_bridge_id)) - goto continue - else - if tonumber(bridge_info.swversion or "0", 10) < HueApi.MIN_CLIP_V2_SWVERSION then - log.warn("Found bridge that does not support CLIP v2 API, ignoring") - driver.ignored_bridges[device_bridge_id] = true - return - end - - log.info(string.format("Bridge info for %s received, initializing network configuration", device_bridge_id)) - driver.joined_bridges[device_bridge_id] = true - do_bridge_network_init(driver, device, bridge_url, api_key) - return - end - - ::continue:: - if sleep_time < 10 then - sleep_time = backoff_generator() - end - log.warn( - string.format( - "[BridgeInit] Failed to find bridge info for device %s, waiting %s seconds then trying again", - (device.label or device.id or "unknown device"), - sleep_time - ) - ) - cosock.socket.sleep(sleep_time) - end - end, - "bridge init" - ) - else - do_bridge_network_init(driver, device, bridge_url, api_key) - end -end - ----@param driver HueDriver ----@param device HueChildDevice -init_light = function(driver, device) - log.info( - string.format("Init Light for device %s", (device.label or device.id or "unknown device"))) - local caps = device.profile.components.main.capabilities - if caps.colorTemperature then - if caps.colorControl then - device:set_field(Fields.MIN_KELVIN, HueApi.MIN_TEMP_KELVIN_COLOR_AMBIANCE, { persist = true }) - else - device:set_field(Fields.MIN_KELVIN, HueApi.MIN_TEMP_KELVIN_WHITE_AMBIANCE, { persist = true }) - end - end - local device_light_resource_id = device:get_field(Fields.RESOURCE_ID) or device.parent_assigned_child_key or - device.device_network_id - local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) - if not driver.light_id_to_device[device_light_resource_id] then - driver.light_id_to_device[device_light_resource_id] = device - end - if not driver.device_rid_to_light_rid[hue_device_id] then - driver.device_rid_to_light_rid[hue_device_id] = device_light_resource_id - end - device:set_field(Fields._INIT, true, { persist = false }) - if device:get_field(Fields._REFRESH_AFTER_INIT) then - refresh_handler(driver, device) - device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) - end -end - ----@param driver HueDriver ----@param device HueDevice -local function device_init(driver, device) - local device_type = device:get_field(Fields.DEVICE_TYPE) - log.info( - string.format("device_init for device %s, device_type: %s", (device.label or device.id or "unknown device"), - device_type)) - if device_type == "bridge" then - init_bridge(driver, device --[[@as HueBridgeDevice]]) - elseif device_type == "light" then - init_light(driver, device --[[@as HueChildDevice]]) - end -end - ----@param driver HueDriver ----@param device HueDevice ----@param parent_device_id nil|string -local function device_added(driver, device, _, _, parent_device_id) - log.info( - string.format("device_added for device %s", (device.label or device.id or "unknown device"))) - if utils.is_dth_bridge(device) then - migrate_bridge(driver, device --[[@as HueBridgeDevice]]) - elseif utils.is_dth_light(device) then - migrate_light(driver, device --[[@as HueChildDevice]], parent_device_id) - -- Don't do a refresh if it's a migration - device:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) - elseif utils.is_edge_bridge(device) then - bridge_added(driver, device --[[@as HueBridgeDevice]]) - elseif utils.is_edge_light(device) then - light_added(driver, device --[[@as HueChildDevice]], parent_device_id) - else - log.warn( - st_utils.stringify_table(device, - string.format("Device Added %s does not appear to be a bridge or bulb", - device.label or device.id or "unknown device"), true) - ) - end -end - ----@param driver HueDriver ----@param device HueDevice -_initialize = function(driver, device, event, args, parent_device_id) - local maybe_device = driver:get_device_by_dni(device.device_network_id) - if not ( - maybe_device - and maybe_device.id == device.id - ) - then - driver.datastore.dni_to_device_id[device.device_network_id] = device.id - end - - log.info( - string.format("_initialize handling event %s for device %s", event, (device.label or device.id or "unknown device"))) - if not device:get_field(Fields._ADDED) then - log.debug( - string.format( - "_ADDED for device %s not set while _initialize is handling %s, performing added lifecycle operations", - (device.label or device.id or "unknown device"), event)) - device_added(driver, device, event, args, parent_device_id) - end - - if not device:get_field(Fields._INIT) then - log.debug( - string.format( - "_INIT for device %s not set while _initialize is handling %s, performing device init lifecycle operations", - (device.label or device.id or "unknown device"), event)) - device_init(driver, device) - end -end - -local disco = Discovery.discover -local added = safe_wrap_handler(_initialize) -local init = safe_wrap_handler(_initialize) - -local stray_bulb_tx, stray_bulb_rx = cosock.channel.new() -stray_bulb_rx:settimeout(30) - -cosock.spawn(function() - local stray_lights = {} - local stray_dni_to_rid = {} - local found_bridges = {} - local thread_local_driver = nil - - local process_strays = function(driver, strays, bridge_device_uuid) - local dnis_to_remove = {} - - for light_dni, light_device in pairs(strays) do - local light_rid = stray_dni_to_rid[light_dni] - local cached_light_description = Discovery.light_state_disco_cache[light_rid] - if cached_light_description then - table.insert(dnis_to_remove, light_dni) - migrate_light(driver, light_device, bridge_device_uuid, cached_light_description) - end - end - - for _, dni in ipairs(dnis_to_remove) do - strays[dni] = nil - end - end - - while true do - local msg, err = stray_bulb_rx:receive() - if err and err ~= "timeout" then - log.error_with({ hub_logs = true }, "Cosock Receive Error: ", err) - goto continue - end - - if err == "timeout" then - if next(stray_lights) ~= nil and next(found_bridges) ~= nil and thread_local_driver ~= nil then - log.info_with({ hub_logs = true }, - "No new stray lights received but some remain in queue, attempting to resolve remaining stray lights") - for _, bridge in pairs(found_bridges) do - stray_bulb_tx:send({ - type = StrayDeviceMessageTypes.FoundBridge, - driver = thread_local_driver, - device = bridge - }) - end - end - goto continue - end - - do - local msg_device = msg.device - thread_local_driver = msg.driver - if msg.type == StrayDeviceMessageTypes.FoundBridge then - local bridge_ip = msg_device:get_field(Fields.IPV4) - local api_instance = - msg_device:get_field(Fields.BRIDGE_API) - or Discovery.disco_api_instances[msg_device.device_network_id] - - if not api_instance then - api_instance = HueApi.new_bridge_manager( - "https://" .. bridge_ip, - msg_device:get_field(HueApi.APPLICATION_KEY_HEADER), - utils.labeled_socket_builder((msg_device.label or msg_device.device_network_id or msg_device.id or "unknown bridge")) - ) - Discovery.disco_api_instances[msg_device.device_network_id] = api_instance - end - - found_bridges[msg_device.id] = msg.device - local bridge_device_uuid = msg_device.id - - -- TODO: We can optimize around this by keeping track of whether or not this bridge - -- needs to be scanned (maybe skip scanning if there are no stray lights?) - -- - -- @doug.stephen@smartthings.com - log.info( - string.format( - "Stray light handler notified of new bridge %s, scanning bridge", - (msg.device.label or msg.device.device_network_id or msg.device.id or "unknown bridge") - ) - ) - Discovery.search_bridge_for_supported_devices(thread_local_driver, msg_device:get_field(Fields.BRIDGE_ID), api_instance, - function(hue_driver, svc_info, device_data) - if not (svc_info.rid and svc_info.rtype and svc_info.rtype == "light") then return end - - local device_light_resource_id = svc_info.rid - local light_resource, rest_err, _ = api_instance:get_light_by_id(device_light_resource_id) - if rest_err ~= nil or not light_resource then - log.error_with({ hub_logs = true }, string.format( - "Error getting light info while processing new bridge %s", - (msg_device.label or msg_device.id or "unknown device"), rest_err - )) - return - end - - if light_resource.errors and #light_resource.errors > 0 then - log.error_with({ hub_logs = true }, "Errors found in API response:") - for idx, resource_err in ipairs(light_resource.errors) do - log.error_with({ hub_logs = true }, string.format( - "Error Number %s in get_light_by_id response while onboarding bridge %s: %s", - idx, - (msg_device.label or msg_device.id or "unknown device"), - st_utils.stringify_table(resource_err) - )) - end - return - end - - if light_resource.data and #light_resource.data > 0 then - for _, light in ipairs(light_resource.data) do - local light_resource_description = { - hue_provided_name = device_data.metadata.name, - id = light.id, - on = light.on, - color = light.color, - dimming = light.dimming, - color_temperature = light.color_temperature, - mode = light.mode, - parent_device_id = bridge_device_uuid, - hue_device_id = light.owner.rid, - hue_device_data = device_data - } - if not Discovery.light_state_disco_cache[light.id] then - log.info(string.format("Caching previously unknown light service description for %s", - device_data.metadata.name)) - Discovery.light_state_disco_cache[light.id] = light_resource_description - end - end - end - - for stray_dni, stray_light in pairs(stray_lights) do - local matching_v1_id = stray_light.data and stray_light.data.bulbId and - stray_light.data.bulbId == device_data.id_v1:gsub("/lights/", "") - local matching_uuid = stray_light.parent_assigned_child_key == svc_info.rid or - stray_light.device_network_id == svc_info.rid - - if matching_v1_id or matching_uuid then - local api_key_extracted = api_instance.headers["hue-application-key"] - log.info_with({ hub_logs = true }, " ", (stray_light.label or stray_light.id or "unknown light"), - ", re-adding") - log.info_with({ hub_logs = true }, string.format( - 'Found Bridge for stray light %s, retrying onboarding flow.\n' .. - '\tMatching v1 id? %s\n' .. - '\tMatching uuid? %s\n' .. - '\tlight_device DNI: %s\n' .. - '\tlight_device Parent Assigned Key: %s\n' .. - '\tlight_device parent device id: %s\n' .. - '\tProvided bridge_device_id: %s\n' .. - '\tAPI key cached for given bridge_device_id? %s\n' .. - '\tCached bridge device for given API key: %s\n' - , - stray_light.label, - matching_v1_id, - matching_uuid, - stray_light.device_network_id, - stray_light.parent_assigned_child_key, - stray_light.parent_device_id, - bridge_device_uuid, - (Discovery.api_keys[hue_driver:get_device_info(bridge_device_uuid).device_network_id] ~= nil), - hue_driver.api_key_to_bridge_id[api_key_extracted] - )) - stray_dni_to_rid[stray_dni] = svc_info.rid - break - end - end - end, - "[process_strays]" - ) - log.info(string.format( - "Finished querying bridge %s for devices from stray light handler", - (msg.device.label or msg.device.device_network_id or msg.device.id or "unknown bridge") - ) - ) - process_strays(thread_local_driver, stray_lights, msg_device.id) - elseif msg.type == StrayDeviceMessageTypes.NewStrayLight then - stray_lights[msg_device.device_network_id] = msg_device - - local maybe_bridge_id = - msg_device.parent_device_id or msg_device:get_field(Fields.PARENT_DEVICE_ID) - local maybe_bridge = found_bridges[maybe_bridge_id] - - if maybe_bridge ~= nil then - local bridge_ip = maybe_bridge:get_field(Fields.IPV4) - local api_instance = - maybe_bridge:get_field(Fields.BRIDGE_API) - or Discovery.disco_api_instances[maybe_bridge.device_network_id] - - if not api_instance then - api_instance = HueApi.new_bridge_manager( - "https://" .. bridge_ip, - maybe_bridge:get_field(HueApi.APPLICATION_KEY_HEADER), - utils.labeled_socket_builder((maybe_bridge.label or maybe_bridge.device_network_id or maybe_bridge.id or "unknown bridge")) - ) - Discovery.disco_api_instances[maybe_bridge.device_network_id] = api_instance - end - - process_strays(thread_local_driver, stray_lights, maybe_bridge.id) - end - end - end - ::continue:: - if next(stray_lights) ~= nil then - local stray_lights_pseudo_json = "{\"stray_bulbs\":[" - for dni, light in pairs(stray_lights) do - stray_lights_pseudo_json = stray_lights_pseudo_json .. - string.format( - [[{"label":"%s","dni":"%s","device_id":"%s"},]], - (light.label or light.id or "unknown light"), - dni, - light.id - ) - end - -- strip trailing comma and close array/root object - stray_lights_pseudo_json = stray_lights_pseudo_json:sub(1, -2) .. "]}" - log.info_with({ hub_logs = true }, - string.format("Stray light loop end, unprocessed lights: %s", stray_lights_pseudo_json)) - end - end -end, "Stray Hue Bulb Resolution Task") - - -local function remove(driver, device) - driver.datastore.dni_to_device_id[device.device_network_id] = nil - if device:get_field(Fields.DEVICE_TYPE) == "bridge" then - local api_instance = device:get_field(Fields.BRIDGE_API) - if api_instance then - api_instance:shutdown() - device:set_field(Fields.BRIDGE_API, nil) - end - - local event_source = device:get_field(Fields.EVENT_SOURCE) - if event_source then - event_source:close() - device:set_field(Fields.EVENT_SOURCE, nil) - end - - Discovery.api_keys[device.device_network_id] = nil - end -end - -local function supports_switch(hue_repr) - return - hue_repr.on ~= nil - and type(hue_repr.on) == "table" - and type(hue_repr.on.on) == "boolean" -end - -local function supports_switch_level(hue_repr) - return - hue_repr.dimming ~= nil - and type(hue_repr.dimming) == "table" - and type(hue_repr.dimming.brightness) == "number" -end - -local function supports_color_temp(hue_repr) - return - hue_repr.color_temperature ~= nil - and type(hue_repr.color_temperature) == "table" - and next(hue_repr.color_temperature) ~= nil -end - -local function supports_color_control(hue_repr) - return - hue_repr.color ~= nil - and type(hue_repr.color) == "table" - and type(hue_repr.color.xy) == "table" - and type(hue_repr.color.gamut) == "table" -end - -local support_check_handlers = { - [capabilities.switch.ID] = supports_switch, - [capabilities.switchLevel.ID] = supports_switch_level, - [capabilities.colorControl.ID] = supports_color_control, - [capabilities.colorTemperature.ID] = supports_color_temp -} +local HueDriverTemplate = require "hue_driver_template" --- @type HueDriver -local hue = Driver("hue", - { - discovery = disco, - lifecycle_handlers = { added = added, init = init, removed = remove }, - capability_handlers = { - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = refresh_handler, - }, - [capabilities.switch.ID] = { - [capabilities.switch.commands.on.NAME] = switch_on_handler, - [capabilities.switch.commands.off.NAME] = switch_off_handler, - }, - [capabilities.switchLevel.ID] = { - [capabilities.switchLevel.commands.setLevel.NAME] = switch_level_handler, - }, - [capabilities.colorControl.ID] = { - [capabilities.colorControl.commands.setColor.NAME] = set_color_handler, - [capabilities.colorControl.commands.setHue.NAME] = set_hue_handler, - [capabilities.colorControl.commands.setSaturation.NAME] = set_saturation_handler, - }, - [capabilities.colorTemperature.ID] = { - [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, - }, - }, - ignored_bridges = {}, - joined_bridges = {}, - light_id_to_device = {}, - device_rid_to_light_rid = {}, - -- the only real way we have to know which bridge a bulb wants to use at migration time - -- is by looking at the stored api key so we will make a map to look up bridge IDs with - -- the API key as the map key. - api_key_to_bridge_id = {}, - stray_bulb_tx = stray_bulb_tx, - _lights_pending_refresh = {}, - emit_light_status_events = function(light_device, light_table) - if light_device == nil or (light_device and light_device.id == nil) then - log.warn("Tried to emit light status event for device that has been deleted") - return - end - local success, result = pcall(emit_light_status_events, light_device, light_table) - if not success then - log.error_with({ hub_logs = true }, string.format("Failed to invoke emit light status handler. Reason: %s", result)) - end - return result - end, - do_hue_light_delete = function(driver, device) - if type(driver.try_delete_device) ~= "function" then - local _log = device.log or log - _log.warn("Requesting device delete on API version that doesn't support it. Marking device offline.") - device:offline() - return - end - - driver:try_delete_device(device.id) - end, - check_hue_repr_for_capability_support = function(hue_repr, capability_id) - local handler = support_check_handlers[capability_id] - if type(handler) == "function" then - return handler(hue_repr) - else - return false - end - end, - update_bridge_netinfo = function(self, bridge_id, bridge_info) - if self.joined_bridges[bridge_id] then - local bridge_device = self:get_device_by_dni(bridge_id) - if not bridge_device then - log.warn_with({ hub_logs = true }, - string.format( - "Couldn't locate bridge device for joined bridge with DNI %s", - bridge_id - ) - ) - return - end - - if bridge_info.ip ~= bridge_device:get_field(Fields.IPV4) then - update_bridge_fields_from_info(self, bridge_info, bridge_device) - local maybe_api_client = bridge_device:get_field(Fields.BRIDGE_API) - local maybe_api_key = bridge_device:get_field(HueApi.APPLICATION_KEY_HEADER) or Discovery.api_keys[bridge_id] - local maybe_event_source = bridge_device:get_field(Fields.EVENT_SOURCE) - local bridge_url = "https://" .. bridge_info.ip - - if maybe_api_key then - if maybe_api_client then - maybe_api_client:update_connection(bridge_url, maybe_api_key) - end - - if maybe_event_source then - maybe_event_source:close() - bridge_device:set_field(Fields.EVENT_SOURCE, nil) - do_bridge_network_init(self, bridge_device, bridge_url, maybe_api_key) - end - end - end - end - end, - get_device_by_dni = function(self, dni, force_refresh) - local device_uuid = self.datastore.dni_to_device_id[dni] - if not device_uuid then return nil end - return self:get_device_info(device_uuid, force_refresh) - end - } -) +local hue = Driver("hue", HueDriverTemplate.new_driver_template()) if hue.datastore["bridge_netinfo"] == nil then hue.datastore["bridge_netinfo"] = {} @@ -1622,6 +58,14 @@ Discovery.api_keys = setmetatable({}, { ) hue.datastore.api_keys[k] = v hue.datastore:save() + if hue.datastore.commit then + -- Because we never actually store keys on the metatable target itself, + -- __newindex is invoked for ever mutation; values for a new key, updating + -- the value for an existing key, and setting an existing key to `nil` will + -- all hit this path. + local commit_result = table.pack(hue.datastore:commit()) + log.trace(st_utils.stringify_table(commit_result, "[DataStoreCommit] commit result", true)) + end end, __index = function(self, k) return hue.datastore.api_keys[k] diff --git a/drivers/SmartThings/philips-hue/src/logjam.lua b/drivers/SmartThings/philips-hue/src/logjam.lua new file mode 100644 index 0000000000..bb2f0627aa --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/logjam.lua @@ -0,0 +1,127 @@ +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local logjam = {} + +logjam.real_log = log.log + +logjam.enabled_modules = {} + +function logjam.inject_global() + for field_key, level_key in pairs(log) do + if + string.find(field_key, "LOG_LEVEL_") and + type(level_key) == "string" and + type(log[level_key]) == "function" + then + log[level_key] = logjam[level_key] + local level_with_key = string.format("%s_with", level_key) + if type(log[level_with_key]) == "function" then + log[level_with_key] = logjam[level_with_key] + end + end + end + log.log = logjam.log +end + +function logjam.enable_passthrough() + logjam.passthrough = true +end + +function logjam.disable_passthrough() + logjam.passthrough = false +end + +function logjam.enable(module) + logjam.enabled_modules[module] = true +end + +function logjam.disable(module) + logjam.enabled_modules[module] = nil +end + +function logjam.log(opts, level, ...) + local call_info + if not opts.call_info then + call_info = debug.getinfo(2) + else + call_info = opts.call_info + end + opts.call_info = nil + + local module_name = nil + if type(call_info.source) == "string" then + module_name = + call_info.source + :gsub("%.lua", "") + :gsub("/init", "") + :gsub("/", ".") + :gsub("^init$", "philips-hue") + end + + local module_enabled = false + local module_prefix = "" + if type(module_name) == "string" and module_name:len() > 0 then + module_enabled = logjam.enabled_modules[module_name] + module_prefix = string.format("[%s] ", module_name) + end + + -- explicit on/off log option takes precedence, so that we can allow + -- `false` to override passthrough/module_enabled flags. + if type(opts.on) == "boolean" then + if opts.on then + logjam.real_log(opts, level, module_prefix, ...) + end + return + end + if logjam.passthrough or module_enabled then + logjam.real_log(opts, level, module_prefix, ...) + end +end + +for field_key, level_key in pairs(log) do + if + string.find(field_key, "LOG_LEVEL_") and + type(level_key) == "string" and + type(log[level_key]) == "function" + then + local level_with_key = string.format("%s_with", level_key) + logjam[level_key] = function(...) + local first_arg = select(1, ...) + local opts = {} + local log_args_start_idx = 1 + if type(first_arg) == "boolean" then + opts.on = first_arg + log_args_start_idx = 2 + elseif type(first_arg) == "table" then + opts = first_arg + log_args_start_idx = 2 + end + local info = debug.getinfo(2) + opts.call_info = info + logjam.log(opts, level_key, select(log_args_start_idx, ...)) + end + + logjam[level_with_key] = function(opts, ...) + local log_opts = {} + local log_args = table.pack(...) + if type(opts) == "table" then + for k, v in pairs(opts) do + log_opts[k] = v + end + elseif type(opts) == "boolean" then + log_opts.on = opts + elseif opts ~= nil then + log_args.insert(log_args, 1, opts) + end + local info = debug.getinfo(2) + log_opts.call_info = info + logjam.log(log_opts, level_key, table.unpack(log_args)) + end + end +end + +return logjam diff --git a/drivers/SmartThings/philips-hue/src/lunchbox/rest.lua b/drivers/SmartThings/philips-hue/src/lunchbox/rest.lua index 340b73e114..d062932ce9 100644 --- a/drivers/SmartThings/philips-hue/src/lunchbox/rest.lua +++ b/drivers/SmartThings/philips-hue/src/lunchbox/rest.lua @@ -52,7 +52,7 @@ end ---comment ---@param client RestClient ----@param request Request +---@param request HttpMessage ---@return integer? bytes_sent ---@return string? err_msg ---@return integer idx @@ -70,6 +70,10 @@ local function send_request(client, request) return bytes, err, idx end +---@param original_response Response +---@param sock table +---@return Response? +---@return string? err local function parse_chunked_response(original_response, sock) local ChunkedTransferStates = { EXPECTING_CHUNK_LENGTH = "ExpectingChunkLength", @@ -81,8 +85,8 @@ local function parse_chunked_response(original_response, sock) for header in original_response.headers:iter() do full_response.headers:append_chunk(header) end local original_body, err = original_response:get_body() - if type(original_body) ~= "string" or err ~= nil then - return original_body, (err or "unexpected nil in error position") + if original_body == nil or err ~= nil then + return nil, (err or "unexpected nil in error position") end local next_chunk_bytes = tonumber(original_body, 16) local next_chunk_body = "" @@ -143,6 +147,10 @@ local function parse_chunked_response(original_response, sock) return full_response end +---@param sock table +---@return Response|nil +---@return string? err +---@return string? partial local function handle_response(sock) if api_version >= 9 then local response, err = Response.tcp_source(sock) @@ -173,6 +181,12 @@ local function handle_response(sock) end end +---@param client RestClient +---@param request HttpMessage +---@param retry_fn nil|fun(): boolean +---@return Response? response nil on error +---@return string? err nil on success +---@return string? partial local function execute_request(client, request, retry_fn) if not client._active then return nil, "Called `execute request` on a terminated REST Client", nil @@ -190,7 +204,7 @@ local function execute_request(client, request, retry_fn) end -- send output - local _bytes_sent, send_err, _idx = nil, nil, 0 + local bytes_sent, send_err, _idx = nil, nil, 0 -- recv output local response, recv_err, partial = nil, nil, nil -- return values @@ -203,7 +217,7 @@ local function execute_request(client, request, retry_fn) local retry = should_retry() if current_state == RestCallStates.SEND then backoff = utils.backoff_builder(60, 1, 0.1) - _bytes_sent, send_err, _idx = send_request(client, request) + bytes_sent, send_err, _idx = send_request(client, request) if not send_err then current_state = RestCallStates.RECEIVE @@ -302,6 +316,12 @@ function RestClient:update_base_url(new_url) self.base_url = lb_utils.force_url_table(new_url) end +---@param path string +---@param additional_headers table +---@param retry_fn nil|fun(): boolean +---@return Response? the response, nil on error +---@return string? err error, nil on success +---@return string? partial function RestClient:get(path, additional_headers, retry_fn) local request = Request.new("GET", path, nil):add_header( "user-agent", "smartthings-lua-edge-driver" diff --git a/drivers/SmartThings/philips-hue/src/lunchbox/sse/eventsource.lua b/drivers/SmartThings/philips-hue/src/lunchbox/sse/eventsource.lua index 67e4a33d20..517c07c03f 100644 --- a/drivers/SmartThings/philips-hue/src/lunchbox/sse/eventsource.lua +++ b/drivers/SmartThings/philips-hue/src/lunchbox/sse/eventsource.lua @@ -1,6 +1,14 @@ local cosock = require "cosock" local socket = require "cosock.socket" local ssl = require "cosock.ssl" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(sock: table, config: table?): table?, string? +ssl.wrap = ssl.wrap + +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table local log = require "log" local util = require "lunchbox.util" @@ -17,7 +25,7 @@ local Response = require "luncheon.response" --- --- @class EventSource --- @field public url table A `net.url` table representing the URL for the connection ---- @field public ready_state number Enumeration of the ready states outlined in the spec. +--- @field public ready_state EventSource.ReadyStates Enumeration of the ready states outlined in the spec. --- @field public onopen function in-line callback for on-open events --- @field public onmessage function in-line callback for on-message events --- @field public onerror function in-line callback for on-error events; error callbacks will fire @@ -27,31 +35,34 @@ local Response = require "luncheon.response" --- @field package _sock table? the TCP socket for the connection --- @field package _needs_more boolean flag to track whether or not we're still expecting mroe on this source before we dispatch --- @field package _last_field string the last field the parsing path saw, in case it needs to append more to its value ---- @field package _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. ---- @field package _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. ---- @field package _listeners table event listeners attached using the add_event_listener API instead of the inline callbacks. +--- @field package _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. +--- @field package _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. +--- @field package _listeners table event listeners attached using the add_event_listener API instead of the inline callbacks. local EventSource = {} EventSource.__index = EventSource --- The Ready States that an EventSource can be in. We use base 0 to match the specification. -EventSource.ReadyStates = util.read_only { +---@enum EventSource.ReadyStates +EventSource.ReadyStates = { CONNECTING = 0, -- The connection has not yet been established OPEN = 1, -- The connection is open CLOSED = 2 -- The connection has closed } +EventSource.ReadyStates = util.read_only(EventSource.ReadyStates) --- The event types supported by this source, patterned after their values in JavaScript. -EventSource.EventTypes = util.read_only { +---@enum EventSource.EventTypes +EventSource.EventTypes = { ON_OPEN = "open", ON_MESSAGE = "message", ON_ERROR = "error", } +EventSource.EventTypes = util.read_only(EventSource.EventTypes) --- Helper function that creates the initial Request to start the stream. ---- @function create_request ---- @local --- @param url_table table a net.url table --- @param extra_headers table a set of key/value pairs (strings) to capture any extra HTTP headers needed. +--- @return HttpMessage request local function create_request(url_table, extra_headers) local request = Request.new("GET", url_table.path, nil) :add_header("user-agent", "smartthings-lua-edge-driver") @@ -69,10 +80,11 @@ local function create_request(url_table, extra_headers) end --- Helper function to send the request and kick off the stream. ---- @function send_stream_start_request ---- @local --- @param payload string the entire string buffer to send --- @param sock table the TCP socket to send it over +--- @return number? bytes bytes sent +--- @return string? err error, nil on success +--- @return number? idx index of last byte sent on error local function send_stream_start_request(payload, sock) local bytes, err, idx = nil, nil, 0 @@ -88,9 +100,8 @@ local function send_stream_start_request(payload, sock) end --- Helper function to create an table representing an event from the source's parse buffers. ---- @function make_event ---- @local --- @param source EventSource +--- @return event table local function make_event(source) local event_type = nil @@ -108,8 +119,6 @@ end --- SSE spec for dispatching an event: --- https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage ---- @function dispatch_event ---- @local --- @param source EventSource local function dispatch_event(source) local data_buffer = source._parse_buffers["data"] @@ -189,8 +198,6 @@ local function event_lines(chunk) end --- SSE spec for interpreting an event stream: --- https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface ---- @function parse ---- @local --- @param source EventSource --- @param recv string the received payload from the last socket receive local function sse_parse_chunk(source, recv) @@ -237,24 +244,26 @@ local function sse_parse_chunk(source, recv) end --- Helper function that captures the cyclic logic of the EventSource while in the CONNECTING state. ---- @function connecting_action ---- @local --- @param source EventSource +--- @return nil nothing always returns nil +--- @return string? err error message if there was a failure +--- @return table? http_err if the error was an HTTP error, returned here local function connecting_action(source) if not source._sock then if type(source._sock_builder) == "function" then source._sock = source._sock_builder() else + local err = nil source._sock, err = socket.tcp() if err ~= nil then return nil, err end - _, err = source._sock:settimeout(60) + err = select(2, source._sock:settimeout(60)) if err ~= nil then return nil, err end - _, err = source._sock:connect(source.url.host, source.url.port) + err = select(2, source._sock:connect(source.url.host, source.url.port)) if err ~= nil then return nil, err end - _, err = source._sock:setoption("keepalive", true) + err = select(2, source._sock:setoption("keepalive", true)) if err ~= nil then return nil, err end if source.url.scheme == "https" then @@ -266,7 +275,11 @@ local function connecting_action(source) }) if err ~= nil then return nil, err end - _, err = source._sock:dohandshake() + -- Re-set timeout due to cosock not carrying timeout over in some Lua library versions + err = select(2, source._sock:settimeout(60)) + if err ~= nil then return nil, err end + + err = select(2, source._sock:dohandshake()) if err ~= nil then return nil, err end end end @@ -280,7 +293,12 @@ local function connecting_action(source) request = request:add_header("Last-Event-ID", last_event_id) end - local _, err, _ = send_stream_start_request(request:serialize(), source._sock) + local request_string, ser_err = request:serialize() + if ser_err or not request_string then + return nil, ser_err + end + + local _, err, _ = send_stream_start_request(request_string, source._sock) if err ~= nil then return nil, err @@ -320,9 +338,10 @@ local function connecting_action(source) end end --- Helper function that captures the cyclic logic of the EventSource while in the OPEN state. ---- @function open_action ---- @local --- @param source EventSource +--- @return nil nothing always nil +--- @return string? err error message if failed +--- @return string? partial partial socket receive if available local function open_action(source) local recv, err, partial = source._sock:receive('*l') @@ -344,7 +363,7 @@ local function open_action(source) -- the number of bytes to read per the chunked encoding spec local recv_as_num = tonumber(recv, 16) - if recv_as_num ~= nil then + if recv_as_num ~= nil and recv_as_num > 0 then recv, err, partial = source._sock:receive(recv_as_num) if err then if err == "timeout" or err == "wantread" then @@ -419,7 +438,7 @@ local state_actions = { --- --- @param url string|table a string or a net.url table representing the complete URL (minimally a scheme/host/path, port optional) for the event stream. --- @param extra_headers table|nil an optional table of key-value pairs (strings) to be added to the initial GET request ---- @param sock_builder function|nil an optional function to be used to create the TCP socket for the stream. If nil, a set of defaults will be used to create a new TCP socket. +--- @param sock_builder nil|fun(): table an optional function to be used to create the TCP socket for the stream. If nil, a set of defaults will be used to create a new TCP socket. --- @return EventSource a new EventSource function EventSource.new(url, extra_headers, sock_builder) local url_table = util.force_url_table(url) @@ -464,7 +483,6 @@ function EventSource.new(url, extra_headers, sock_builder) }, EventSource) cosock.spawn(function() - local st_utils = require "st.utils" while true do if source.ready_state == EventSource.ReadyStates.CLOSED and not source._reconnect diff --git a/drivers/SmartThings/philips-hue/src/lunchbox/util.lua b/drivers/SmartThings/philips-hue/src/lunchbox/util.lua index 54a421047f..bdfc3f0964 100644 --- a/drivers/SmartThings/philips-hue/src/lunchbox/util.lua +++ b/drivers/SmartThings/philips-hue/src/lunchbox/util.lua @@ -16,6 +16,8 @@ util.force_url_table = function(url) return url end +---@param tbl table the table to make read only +---@return table read_only read-only version of the table util.read_only = function(tbl) if type(tbl) == "table" then local proxy = {} @@ -30,17 +32,4 @@ util.read_only = function(tbl) end end -util.iter_string_lines = function(str) - if str:sub(-1) ~= "\n" then str = str .. "\n" end - - return str:gmatch("(.-)\n") -end - -util.copy_data = function(tbl) - local ret = {} - for k, v in pairs(tbl) do ret[k] = v end - - return ret -end - return util diff --git a/drivers/SmartThings/philips-hue/src/stray_device_helper.lua b/drivers/SmartThings/philips-hue/src/stray_device_helper.lua new file mode 100644 index 0000000000..fb58e81725 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/stray_device_helper.lua @@ -0,0 +1,292 @@ +local cosock = require "cosock" +local log = require "log" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Discovery = require "disco" +local Fields = require "fields" +local HueApi = require "hue.api" +local HueDeviceTypes = require "hue_device_types" + +local utils = require "utils" + +---@type { lifecycle_handlers: LifecycleHandlers } +local lazy_handlers = utils.lazy_handler_loader("handlers") + +---@type { [string]: DiscoveredChildDeviceHandler } +local lazy_disco_handlers = utils.lazy_handler_loader("disco") + +---@class StrayDeviceHelper +local StrayDeviceHelper = {} + +---@enum MessageTypes +local MessageTypes = { + FoundBridge = "FOUND_BRIDGE", + NewStrayDevice = "NEW_STRAY_DEVICE", +} +StrayDeviceHelper.MessageTypes = MessageTypes + +local function check_strays_for_match(hue_driver, api_instance, stray_devices, bridge_device_uuid, device_data, svc_info) + for _, stray_device in pairs(stray_devices) do + local matching_v1_id = stray_device.data and stray_device.data.bulbId and + stray_device.data.bulbId == device_data.id_v1:gsub("/lights/", "") + local matching_uuid = utils.get_hue_rid(stray_device) == svc_info.rid or + stray_device.device_network_id == svc_info.rid + + if matching_v1_id or matching_uuid then + stray_device:set_field(Fields.RESOURCE_ID, svc_info.rid, { persist = true }) + local api_key_extracted = api_instance.headers["hue-application-key"] + log.info_with({ hub_logs = true }, " ", (stray_device.label or stray_device.id or "unknown device"), + ", re-adding") + log.info_with({ hub_logs = true }, string.format( + 'Found Bridge for stray device %s, retrying onboarding flow.\n' .. + '\tMatching v1 id? %s\n' .. + '\tMatching uuid? %s\n' .. + '\tdevice DNI: %s\n' .. + '\tdevice Parent Assigned Key: %s\n' .. + '\tdevice parent device id: %s\n' .. + '\tProvided bridge_device_id: %s\n' .. + '\tAPI key cached for given bridge_device_id? %s\n' .. + '\tCached bridge device for given API key: %s\n' + , + stray_device.label, + matching_v1_id, + matching_uuid, + stray_device.device_network_id, + stray_device.parent_assigned_child_key, + stray_device.parent_device_id, + bridge_device_uuid, + (Discovery.api_keys[hue_driver:get_device_info(bridge_device_uuid).device_network_id] ~= nil), + hue_driver.api_key_to_bridge_id[api_key_extracted] + )) + break + end + end +end + +---@param driver HueDriver +---@param strays table +---@param bridge_device_uuid string +function StrayDeviceHelper.process_strays(driver, strays, bridge_device_uuid) + ---@type string[] + local dnis_to_remove = {} + for _, device in pairs(strays) do + local device_rid, err = utils.get_hue_rid(device) + if err or not device_rid then + log.warn(tostring(err) or "could not determine device resource ID, continuing") + goto continue + end + local cached_device_description = Discovery.device_state_disco_cache[device_rid] + if cached_device_description then + table.insert(dnis_to_remove, device.device_network_id) + lazy_handlers.lifecycle_handlers.initialize_device( + driver, device, "added", nil, bridge_device_uuid, cached_device_description + ) + end + ::continue:: + end + + for _, dni in ipairs(dnis_to_remove) do + strays[dni] = nil + end +end + +---@param hue_driver HueDriver +---@param bridge_network_id string +---@param api_instance PhilipsHueApi +---@param primary_services HueServiceInfo +---@param device_data HueDeviceInfo +---@param msg_device HueDevice +---@param bridge_device_uuid string +---@param stray_devices table +function StrayDeviceHelper.discovery_callback( + hue_driver, bridge_network_id, api_instance, primary_services, + device_data, msg_device, bridge_device_uuid, stray_devices +) + for _, svc_info in pairs(primary_services) do + if not (HueDeviceTypes.can_join_device_for_service(svc_info.rtype)) then return end + local service_resource, rest_err, _ = api_instance:get_rtype_by_rid(svc_info.rtype, svc_info.rid) + if rest_err ~= nil or not service_resource then + log.error_with({ hub_logs = true }, string.format( + "Error getting device info info while processing new bridge %s", + (msg_device.label or msg_device.id or "unknown device"), rest_err + )) + return + end + + if service_resource.errors and #service_resource.errors > 0 then + log.error_with({ hub_logs = true }, "Errors found in API response:") + for idx, resource_err in ipairs(service_resource.errors) do + log.error_with({ hub_logs = true }, string.format( + "Error Number %s in get_rtype_by_rid response while onboarding bridge %s: %s", + idx, + (msg_device.label or msg_device.id or "unknown device"), + st_utils.stringify_table(resource_err) + )) + end + return + end + + if service_resource.data and #service_resource.data > 0 then + lazy_disco_handlers[svc_info.rtype].handle_discovered_device( + hue_driver, + bridge_network_id, + api_instance, + primary_services, + device_data, + Discovery.device_state_disco_cache, + nil + ) + + check_strays_for_match( + hue_driver, + api_instance, + stray_devices, + bridge_device_uuid, + device_data, + svc_info + ) + end + end + +end + +--- Spawn the stray device resolution task, returning a handle to the tx side of the +--- channel for controlling it. +function StrayDeviceHelper.spawn() + local stray_device_tx, stray_device_rx = cosock.channel.new() + stray_device_rx:settimeout(30) + + cosock.spawn(function() + ---@type table + local stray_devices = {} + ---@type table + local found_bridges = {} + ---@type HueDriver? + local thread_local_driver = nil + + while true do + local msg, err = stray_device_rx:receive() + if err and err ~= "timeout" then + log.error_with({ hub_logs = true }, "Cosock Receive Error: ", err) + goto continue + end + + if err == "timeout" then + if next(stray_devices) ~= nil and next(found_bridges) ~= nil and thread_local_driver ~= nil then + log.info_with({ hub_logs = true }, + "No new stray devices received but some remain in queue, attempting to resolve remaining stray devices") + for _, bridge in pairs(found_bridges) do + stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.FoundBridge, + driver = thread_local_driver, + device = bridge + }) + end + end + goto continue + end + + do + local msg_device = msg.device + thread_local_driver = msg.driver + if msg.type == StrayDeviceHelper.MessageTypes.FoundBridge then + local bridge_ip = msg_device:get_field(Fields.IPV4) + local api_instance = + msg_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + or Discovery.disco_api_instances[msg_device.device_network_id] + + if not api_instance then + api_instance = HueApi.new_bridge_manager( + "https://" .. bridge_ip, + msg_device:get_field(HueApi.APPLICATION_KEY_HEADER), + utils.labeled_socket_builder((msg_device.label or msg_device.device_network_id or msg_device.id or "unknown bridge")) + ) + Discovery.disco_api_instances[msg_device.device_network_id] = api_instance + end + + found_bridges[msg_device.id] = msg.device + local bridge_device_uuid = msg_device.id + + if next(stray_devices) ~= nil then + log.info( + string.format( + "Stray devices handler notified of new bridge %s, scanning bridge", + (msg.device.label or msg.device.device_network_id or msg.device.id or "unknown bridge") + ) + ) + Discovery.search_bridge_for_supported_devices(thread_local_driver, + msg_device:get_field(Fields.BRIDGE_ID), + api_instance, + function(driver, bridge_network_id, primary_services, device_data) + StrayDeviceHelper.discovery_callback( + driver, + bridge_network_id, + api_instance, + primary_services, + device_data, + msg_device, + bridge_device_uuid, + stray_devices + ) + end, + "[process_strays]" + ) + log.info(string.format( + "Finished querying bridge %s for devices from stray devices handler", + (msg.device.label or msg.device.device_network_id or msg.device.id or "unknown bridge") + ) + ) + StrayDeviceHelper.process_strays(thread_local_driver, stray_devices, msg_device.id) + end + elseif msg.type == StrayDeviceHelper.MessageTypes.NewStrayDevice then + stray_devices[msg_device.device_network_id] = msg_device + + local maybe_bridge = utils.get_hue_bridge_for_device(thread_local_driver, msg_device) + + if maybe_bridge ~= nil then + local bridge_ip = maybe_bridge:get_field(Fields.IPV4) + local api_instance = + maybe_bridge:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + or Discovery.disco_api_instances[maybe_bridge.device_network_id] + + if not api_instance and bridge_ip then + api_instance = HueApi.new_bridge_manager( + "https://" .. bridge_ip, + maybe_bridge:get_field(HueApi.APPLICATION_KEY_HEADER), + utils.labeled_socket_builder((maybe_bridge.label or maybe_bridge.device_network_id or maybe_bridge.id or "unknown bridge")) + ) + Discovery.disco_api_instances[maybe_bridge.device_network_id] = api_instance + end + + if api_instance then + StrayDeviceHelper.process_strays(thread_local_driver, stray_devices, maybe_bridge.id) + end + end + end + end + ::continue:: + if next(stray_devices) ~= nil then + local stray_devices_pseudo_json = "{\"stray_devices\":[" + for _, device in pairs(stray_devices) do + stray_devices_pseudo_json = stray_devices_pseudo_json .. + string.format( + [[{"label":"%s","dni":"%s","device_id":"%s"},]], + (device.label or device.id or "unknown device"), + device.device_network_id, + device.id + ) + end + -- strip trailing comma and close array/root object + stray_devices_pseudo_json = stray_devices_pseudo_json:sub(1, -2) .. "]}" + log.info_with({ hub_logs = true }, + string.format("Stray device loop end, unprocessed devices: %s", stray_devices_pseudo_json)) + end + end + end, "Stray Hue Device Resolution Task") + return stray_device_tx +end + +return StrayDeviceHelper diff --git a/drivers/SmartThings/philips-hue/src/test/spec/device_faker.lua b/drivers/SmartThings/philips-hue/src/test/spec/device_faker.lua new file mode 100644 index 0000000000..8afae0cfcf --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/spec/device_faker.lua @@ -0,0 +1,50 @@ +local utils = require "utils" +local lazy_fakers = utils.lazy_handler_loader("fakers") + +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local test_helpers = require "test_helpers" + +local fake_device_mt = { + set_field = function(device, key, val, _opts) (device.fields or {})[key] = val end, + get_field = function(device, key) return (device.fields or {})[key] end +} +fake_device_mt.__index = fake_device_mt + +---Make a fake device +---@param args table args for the faker +---@param bridge_info table? the hue bridge info for the faker to pull from, will be randomly generated if absent +---@return table fake_device faked device +---@return HueBridgeInfo bridge_info the bridge info used for this fake device +local function device_faker(args, bridge_info) + local faker = string.format("%s_faker", args.device_type) + bridge_info = bridge_info or test_helpers.random_bridge_info() + args.bridge_key = args.bridge_key or test_helpers.random_hue_bridge_key() + + local faked_device = lazy_fakers[faker](args, bridge_info) + assert(faked_device, string.format("No faking available for device type %s", args.device_type)) + + faked_device.label = args.name or "Fake Hue Device" + if args.migrated == true and type(args.data) == "table" then + faked_device.data = faked_device.data or {} + for k, v in pairs(args.data) do + rawset(faked_device.data, k, v) + end + end + + if type(args.fields) == "table" then + faked_device.fields = faked_device.fields or {} + for k, v in pairs(args.fields) do + rawset(faked_device.fields, k, v) + end + end + + faked_device.id = args.id or st_utils.generate_uuid_v4() + + return setmetatable(faked_device, fake_device_mt), bridge_info +end + +return device_faker diff --git a/drivers/SmartThings/philips-hue/src/test/spec/fakers/bridge_faker.lua b/drivers/SmartThings/philips-hue/src/test/spec/fakers/bridge_faker.lua new file mode 100644 index 0000000000..dc13a63544 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/spec/fakers/bridge_faker.lua @@ -0,0 +1,30 @@ +local spec_utils = require "test_helpers" + +local function make_edge_device(_faker_args, bridge_info) + return { + device_network_id = bridge_info.mac or spec_utils.random_mac_address(), + label = bridge_info.name or "Philips Hue Bridge", + model = bridge_info.modelid or "BSB002" + } +end + +local function make_migrated_device(faker_args, bridge_info) + local bridge = make_edge_device(faker_args, bridge_info) + bridge.data = { + ip = bridge_info.ip or spec_utils.random_private_ip_address(), + mac = bridge.device_network_id, + username = faker_args.bridge_key + } + + return bridge +end + +return function(faker_args, bridge_info) + bridge_info = bridge_info or {} + faker_args.name = faker_args.name or "Fake Hue Bridge" + if faker_args.migrated == true then + return make_migrated_device(faker_args, bridge_info) + else + return make_edge_device(faker_args, bridge_info) + end +end diff --git a/drivers/SmartThings/philips-hue/src/test/spec/fakers/driver_faker.lua b/drivers/SmartThings/philips-hue/src/test/spec/fakers/driver_faker.lua new file mode 100644 index 0000000000..5c6c749cca --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/spec/fakers/driver_faker.lua @@ -0,0 +1,55 @@ +local fake_driver_mt = {} +fake_driver_mt.__index = fake_driver_mt + +function fake_driver_mt:get_device_info(device_id) + return self.devices[device_id] +end + +local function driver_faker(faker_args) + faker_args = faker_args or {} + faker_args.bridges = faker_args.bridges or {} + faker_args.child_devices = faker_args.child_devices or {} + + local fake_driver = { devices = {} } + fake_driver.datastore = faker_args.datastore or {} + fake_driver.datastore.bridge_netinfo = fake_driver.datastore.bridge_netinfo or {} + fake_driver.datastore.dni_to_device_id = fake_driver.datastore.dni_to_device_id or {} + fake_driver.datastore.api_keys = fake_driver.datastore.api_keys or {} + + fake_driver._bridges = {} + fake_driver._child_devices = {} + + for _, bridge_details in ipairs(faker_args.bridges) do + local device = bridge_details.device + local bridge_netinfo = bridge_details.info + local api_key = bridge_details.key + + fake_driver.devices[device.id] = device + + if bridge_details.add_info_to_datastore == true then + fake_driver.datastore.bridge_netinfo[device.device_network_id] = bridge_netinfo + end + + if bridge_details.map_dni_to_device then + fake_driver.datastore.dni_to_device_id[device.device_network_id] = device.id + end + + if bridge_details.add_key_to_datastore then + fake_driver.datastore.api_keys[device.device_network_id] = api_key + end + + fake_driver._bridges[bridge_netinfo] = device + end + + for _, child_details in ipairs(faker_args.child_devices) do + local device = child_details.device + local parent_bridge = fake_driver._bridges[child_details.parent_bridge_info] + + device.parent_device_id = parent_bridge.id + fake_driver.devices[device.id] = device + end + + return setmetatable(fake_driver, fake_driver_mt) +end + +return driver_faker diff --git a/drivers/SmartThings/philips-hue/src/test/spec/fakers/light_faker.lua b/drivers/SmartThings/philips-hue/src/test/spec/fakers/light_faker.lua new file mode 100644 index 0000000000..189b0c9e33 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/spec/fakers/light_faker.lua @@ -0,0 +1,46 @@ +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local function make_migrated_device(faker_args, bridge_info) + local device_network_id = faker_args.dni or st_utils.generate_uuid_v4() + local v1_bulb_id = faker_args.v1_bulb_id or math.random(255) + local pack = string.format("%s/%d", bridge_info.bridgeid, v1_bulb_id) + return { + parent_assigned_child_key = pack, + device_network_id = device_network_id, + data = { + ip = bridge_info.ip, + mac = bridge_info.mac, + username = faker_args.bridge_key, + bulbId = v1_bulb_id + } + } +end + +local function make_edge_device(faker_args, bridge_info) + local hue_id = faker_args.hue_id or st_utils.generate_uuid_v4() + local device_network_id = faker_args.dni or st_utils.generate_uuid_v4() + + local parent_assigned_child_key + if faker_args.uuid_only_parent_assigned_key == true then + parent_assigned_child_key = hue_id + else + parent_assigned_child_key = string.format("light:%s", hue_id) + end + + return { + parent_assigned_child_key = parent_assigned_child_key, + device_network_id = device_network_id + } +end + +return function(faker_args, bridge_info) + faker_args.name = faker_args.name or "Fake Hue Bulb" + if faker_args.migrated == true then + return make_migrated_device(faker_args, bridge_info) + else + return make_edge_device(faker_args, bridge_info) + end +end diff --git a/drivers/SmartThings/philips-hue/src/test/spec/test_helpers/init.lua b/drivers/SmartThings/philips-hue/src/test/spec/test_helpers/init.lua new file mode 100644 index 0000000000..8e4f53a8ba --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/spec/test_helpers/init.lua @@ -0,0 +1,133 @@ +---@class spec_utils +local test_helpers = {} + +local private_cidr_strings = { + ["192.168.0.0/16"] = true, + ["172.16.0.0/12"] = true, + ["10.0.0.0/8"] = true +} + +---Generate a random IP address for the given CIDR string +---@param cidr_string string? Optional, must be a CIDR string identifying one of the three private network spaces. Defaults to "192.168.0.0/16" +---@return string ip a random IP address. +function test_helpers.random_private_ip_address(cidr_string) + cidr_string = cidr_string or "192.168.0.0/16" + + local valid_strings = {} + for k, _ in pairs(private_cidr_strings) do + table.insert(valid_strings, k) + end + + assert( + private_cidr_strings[cidr_string], + "CIDR string is not a valid private network string, must be one of %s, %s, or %s", + table.unpack(valid_strings) + ) + if cidr_string == "172.16.0.0/12" then + return string.format( + "172.%d.%d.%d", + math.random(16, 31), + math.random(255), + math.random(255) + ) + end + + if cidr_string == "10.0.0.1/8" then + return string.format( + "10.%d.%d.%d", + math.random(255), + math.random(255), + math.random(255) + ) + end + + return string.format( + "192.168.%d.%d", + math.random(255), + math.random(255) + ) +end + +---Generate a random MAC address +---@param separator string? Optional, separator to use in between MAC address segments. Can be '.', ':', '-', or ''. Defaults to ':' +---@param cisco_format boolean? if true, use 3 segments of 4 hex characters instead of 6 segments of 2 characters. +---@return string mac Random MAC address. +function test_helpers.random_mac_address(separator, cisco_format) + separator = separator or ':' + assert( + separator == '.' or + separator == ':' or + separator == '-' or + separator == '', + "Invalid separator for random mac addr: " .. tostring(separator) + ) + local segments = {} + + local num_segments + if cisco_format == true then + num_segments = 3 + else + num_segments = 6 + end + + local chars_per_segment + if cisco_format == true then + chars_per_segment = 4 + else + chars_per_segment = 2 + end + + local function make_segment() + local segment = "" + for _ = 1, chars_per_segment do + segment = segment .. string.format("%x", math.random(0xF)) + end + + return segment + end + + for i = 1, num_segments do + table.insert(segments, i, make_segment()) + end + + return table.concat(segments, separator):upper() +end + + +---Generate a random Hue Application Key/"username" +---@param len number? Number between 10 and 40 for the desired length of the application key. Selected randomly from the range if omitted. +---@return string key the hue application key +function test_helpers.random_hue_bridge_key(len) + local charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_" + len = (type(len) == "number" and len > 10 and len) or math.random(10, 40) + + local key = "" + for i=1,len do + key = key .. charset:sub(math.random(1, #charset), 1) + end + return key +end + +---Generate partiall random Hue Bridge Info +---@param override_info HueBridgeInfo? Optional, overrides for the generated fields; can be a partial table. +---@return HueBridgeInfo bridge_info +function test_helpers.random_bridge_info(override_info) + override_info = override_info or {} + local mac_addr = override_info.mac_addr or test_helpers.random_mac_address(':', false) + + return { + name = override_info.name or "Philips Hue", + datastoreversion = override_info.datastoreversion or "166", + swversion = override_info.swversion or "1963089030", + apiversion = override_info.apiversion or "1.63.0", + mac = mac_addr, + bridge_id = mac_addr:upper():gsub(':', ''), + factorynew = override_info.factoryenw or false, + replacesbridgeid = override_info.replacesbridgeid or false, + modelid = override_info.modelid or "BSB002", + starterkitid = override_info.starterkitid or "", + ip = override_info.ip or test_helpers.random_private_ip_address() + } +end + +return test_helpers diff --git a/drivers/SmartThings/philips-hue/src/test/spec/unit/utils_spec.lua b/drivers/SmartThings/philips-hue/src/test/spec/unit/utils_spec.lua new file mode 100644 index 0000000000..724d7316cf --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/spec/unit/utils_spec.lua @@ -0,0 +1,682 @@ +describe("utility functions", function() + local Consts + local Fields + local HueDeviceTypes + local st_utils + local utils + + setup(function() + Consts = require "consts" + Fields = require "fields" + HueDeviceTypes = require "hue_device_types" + utils = require "utils" + st_utils = require "st.utils" + end) + + describe("that perform mirek to kelvin conversions behave properly:", function() + it("Common minimum mirek of 153 results in 6500 Kelvin", function() + local expected_kelvin = 6500 + local computed_kelvin = utils.mirek_to_kelvin(Consts.DEFAULT_MIN_MIREK, Consts.KELVIN_STEP_SIZE) + assert.are.equal(expected_kelvin, computed_kelvin, string.format("Expected value of %s, got %s", expected_kelvin, computed_kelvin)) + end) + + it("Common maximum mirek of 500 results in 2000 Kelvin", function() + local expected_kelvin = 2000 + local computed_kelvin = utils.mirek_to_kelvin(Consts.DEFAULT_MAX_MIREK, Consts.KELVIN_STEP_SIZE) + assert.are.equal(expected_kelvin, computed_kelvin, string.format("Expected value of %s, got %s", expected_kelvin, computed_kelvin)) + end) + end) + + describe("that handle raw data will handle their input correctly:", function() + local test_mac_addr + local test_cisco_mac_addr + local test_hue_uuid + + before_each(function() + test_mac_addr = "CA:FE:C0:FF:EE:00" + test_cisco_mac_addr = "CAFE.C0FF.EE00" + test_hue_uuid = st_utils.generate_uuid_v4() + end) + + it("is_hue_id_string accepts a UUIDv4", function() + assert.True(utils.is_hue_id_string(test_hue_uuid), string.format("Failed with value %s", test_hue_uuid)) + end) + it("is_hue_id_string rejects strings that aren't a UUIDv4", function() + assert.False(utils.is_hue_id_string("foo")) + end) + it("is_hue_id_string rejects values that aren't strings", function() + assert.False(utils.is_hue_id_string(42)) + assert.False(utils.is_hue_id_string(nil)) + assert.False(utils.is_hue_id_string(true)) + assert.False(utils.is_hue_id_string(false)) + assert.False(utils.is_hue_id_string({ "foo" })) + assert.False(utils.is_hue_id_string({ test_hue_uuid })) + assert.False(utils.is_hue_id_string(print)) + assert.False(utils.is_hue_id_string()) + end) + it("is_valid_mac_addr_string accepts normal MAC address with colon separators", function() + assert.True(utils.is_valid_mac_addr_string(test_mac_addr), string.format("Failed with value %s", test_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_mac_addr)), + string.format("Failed with value %s", test_mac_addr)) + end) + it("is_valid_mac_addr_string accepts normal MAC address with hyphen separators", function() + test_mac_addr = string.gsub(test_mac_addr, ':', '-') + assert.True(utils.is_valid_mac_addr_string(test_mac_addr), string.format("Failed with value %s", test_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_mac_addr)), + string.format("Failed with value %s", test_mac_addr)) + end) + it("is_valid_mac_addr_string accepts normal MAC address with dot separators", function() + test_mac_addr = string.gsub(test_mac_addr, ':', '.') + assert.True(utils.is_valid_mac_addr_string(test_mac_addr), string.format("Failed with value %s", test_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_mac_addr)), + string.format("Failed with value %s", test_mac_addr)) + end) + it("is_valid_mac_addr_string accepts Cisco MAC address with dot separators", function() + assert.True(utils.is_valid_mac_addr_string(test_cisco_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_cisco_mac_addr)), + string.format("Failed with value %s", test_cisco_mac_addr)) + end) + it("is_valid_mac_addr_string accepts Cisco MAC address with colon separators", function() + test_cisco_mac_addr = string.gsub(test_cisco_mac_addr, '%.', ':') + assert.True(utils.is_valid_mac_addr_string(test_cisco_mac_addr), + string.format("Failed with value %s", test_cisco_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_cisco_mac_addr)), + string.format("Failed with value %s", test_cisco_mac_addr)) + end) + it("is_valid_mac_addr_string accepts Cisco MAC address with hyphen separators", function() + test_cisco_mac_addr = string.gsub(test_cisco_mac_addr, '%.', '-') + assert.True(utils.is_valid_mac_addr_string(test_cisco_mac_addr), + string.format("Failed with value %s", test_cisco_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_cisco_mac_addr)), + string.format("Failed with value %s", test_cisco_mac_addr)) + end) + it("is_valid_mac_addr_string accepts MAC addresses with no separators", function() + test_mac_addr = string.gsub(test_mac_addr, ':', '') + assert.True(utils.is_valid_mac_addr_string(test_mac_addr), string.format("Failed with value %s", test_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_mac_addr)), + string.format("Failed with value %s", test_mac_addr)) + + test_cisco_mac_addr = string.gsub(test_cisco_mac_addr, '%.', '') + assert.True(utils.is_valid_mac_addr_string(test_cisco_mac_addr), + string.format("Failed with value %s", test_cisco_mac_addr)) + assert.True(utils.is_valid_mac_addr_string(string.lower(test_cisco_mac_addr)), + string.format("Failed with value %s", test_cisco_mac_addr)) + end) + end) + + describe("that handle devices will handle their metadata correctly:", function() + local driver_faker + local device_faker + + setup(function() + driver_faker = require "fakers.driver_faker" + device_faker = require "device_faker" + end) + + it("parse_parent_assigned_child_key parses an Edge Light", function() + local hue_id = st_utils.generate_uuid_v4() + local fake_light = device_faker { + migrated = false, + device_type = HueDeviceTypes.LIGHT, + hue_id = hue_id + } + local _, rid, rtype = assert.True(utils.parse_parent_assigned_key(fake_light)) + assert.are.equal(hue_id, rid) + assert.are.equal(HueDeviceTypes.LIGHT, rtype) + end) + + it("parse_parent_assigned_key does NOT parse migrated child light", function() + local fake_light = device_faker { + migrated = true, + device_type = HueDeviceTypes.LIGHT + } + assert.False(utils.parse_parent_assigned_key(fake_light)) + end) + + it("parse_parent_assigned_key does NOT parse migrated child light", function() + local fake_light = device_faker { + migrated = true, + device_type = HueDeviceTypes.LIGHT + } + assert.False(utils.parse_parent_assigned_key(fake_light)) + end) + + it("get_hue_rid gets the rid from the RESOURCE_ID field", function() + local hue_id = st_utils.generate_uuid_v4() + local fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = true, + fields = { + [Fields.RESOURCE_ID] = hue_id + } + } + + assert.are.equal(hue_id, utils.get_hue_rid(fake_light)) + + hue_id = st_utils.generate_uuid_v4() + fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + fields = { + [Fields.RESOURCE_ID] = hue_id + } + } + + assert.are.equal(hue_id, utils.get_hue_rid(fake_light)) + end) + + it("get_hue_rid gets the rid from the parent-assigned key of a non-migrated light", function() + local hue_id = st_utils.generate_uuid_v4() + local fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + hue_id = hue_id + } + + assert.are.equal(hue_id, utils.get_hue_rid(fake_light)) + end) + + it("get_hue_rid fails to get the rid of a migrated light without a RESOURCE_ID field", function() + local hue_id = st_utils.generate_uuid_v4() + local fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = true, + hue_id = hue_id + } + + local _, err_msg = assert.is_nil(utils.get_hue_rid(fake_light)) + assert.String(err_msg) + end) + + it( + "determine_device_type gets the device type of a non-migrated light WITH the type in the parent-assigned child key", + function() + local hue_id = st_utils.generate_uuid_v4() + local fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + hue_id = hue_id, + uuid_only_parent_assigned_key = false + } + + local expected_parent_assigned_child_key = string.format("light:%s", hue_id) + assert.are.same(expected_parent_assigned_child_key, fake_light.parent_assigned_child_key) + + local rtype, err_msg = assert(utils.determine_device_type(fake_light)) + assert.are.same(HueDeviceTypes.LIGHT, rtype) + assert.is_nil(err_msg) + end) + + it( + "determine_device_type gets the device type of a non-migrated light WITHOUT the type in the parent-assigned child key", + function() + local hue_id = st_utils.generate_uuid_v4() + local fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + hue_id = hue_id, + uuid_only_parent_assigned_key = true + } + + local expected_parent_assigned_child_key = hue_id + assert.are.same(expected_parent_assigned_child_key, fake_light.parent_assigned_child_key) + + local rtype, err_msg = assert(utils.determine_device_type(fake_light)) + assert.are.same(HueDeviceTypes.LIGHT, rtype) + assert.is_nil(err_msg) + end) + + it("determine_device_type gets the device type of a migrated light", function() + local fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = true, + } + + local rtype, err_msg = assert(utils.determine_device_type(fake_light)) + assert.are.same(HueDeviceTypes.LIGHT, rtype) + assert.is_nil(err_msg) + end) + + it("determine_device_type gets the device type of a migrated bridge", function() + local fake_bridge = device_faker { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + + local rtype, err_msg = assert(utils.determine_device_type(fake_bridge)) + assert.are.same(HueDeviceTypes.BRIDGE, rtype) + assert.is_nil(err_msg) + end) + + it("determine_device_type gets the device type of a non-migrated light", function() + local fake_light = device_faker { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + } + + local rtype, err_msg = assert(utils.determine_device_type(fake_light)) + assert.are.same(HueDeviceTypes.LIGHT, rtype) + assert.is_nil(err_msg) + end) + + it("determine_device_type gets the device type of a non-migrated bridge", function() + local fake_bridge = device_faker { + device_type = HueDeviceTypes.BRIDGE, + migrated = false, + } + + local rtype, err_msg = assert(utils.determine_device_type(fake_bridge)) + assert.are.same(HueDeviceTypes.BRIDGE, rtype) + assert.is_nil(err_msg) + end) + + it("is_bridge returns true for a migrated bridge without DEVICE_TYPE field set, with cached bridge info", function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = true, + add_key_to_datastore = true, + map_dni_to_device = true + } + } + } + + assert.True(utils.is_bridge(fake_driver, fake_bridge)) + end) + + it("is_bridge returns true for a migrated bridge without DEVICE_TYPE field set, without cached bridge info", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = false, + add_key_to_datastore = false, + map_dni_to_device = false + } + } + } + + assert.True(utils.is_bridge(fake_driver, fake_bridge)) + end) + + it("is_bridge returns true for a non-migrated bridge without DEVICE_TYPE field set, with cached bridge info", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = true, + add_key_to_datastore = true, + map_dni_to_device = true + } + } + } + + assert.True(utils.is_bridge(fake_driver, fake_bridge)) + end) + + it("is_bridge returns true for a non-migrated bridge without DEVICE_TYPE field set, without cached bridge info", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = false, + add_key_to_datastore = false, + map_dni_to_device = false + } + } + } + + assert.True(utils.is_bridge(fake_driver, fake_bridge)) + end) + + it("is_bridge returns false for a migrated light without DEVICE_TYPE field set, with cached bridge info", function() + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = true, + } + local fake_light, _ = device_faker(light_faker_args) + + local fake_driver = driver_faker {} + + assert.False(utils.is_bridge(fake_driver, fake_light)) + end) + + it("is_bridge returns false for a migrated light without DEVICE_TYPE field set, without cached bridge info", + function() + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = true, + } + local fake_light, _ = device_faker(light_faker_args) + + local fake_driver = driver_faker {} + + assert.False(utils.is_bridge(fake_driver, fake_light)) + end) + + it("is_bridge returns false for a non-migrated light without DEVICE_TYPE field set, with cached bridge info", + function() + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + } + local fake_light, _ = device_faker(light_faker_args) + + local fake_driver = driver_faker {} + + assert.False(utils.is_bridge(fake_driver, fake_light)) + end) + + it("is_bridge returns false for a non-migrated light without DEVICE_TYPE field set, without cached bridge info", + function() + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + } + local fake_light, _ = device_faker(light_faker_args) + + local fake_driver = driver_faker {} + + assert.False(utils.is_bridge(fake_driver, fake_light)) + end) + + it("is_edge_bridge and is_dth_bridge behave as expected for a migrated bridge", function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, _ = device_faker(bridge_faker_args) + + assert.True(utils.is_dth_bridge(fake_bridge)) + assert.False(utils.is_edge_bridge(fake_bridge)) + end) + + it("is_edge_bridge and is_dth_bridge behave as expected for a non-migrated bridge", function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = false, + } + local fake_bridge, _ = device_faker(bridge_faker_args) + + assert.True(utils.is_edge_bridge(fake_bridge)) + assert.False(utils.is_dth_bridge(fake_bridge)) + end) + + it("is_dth_light behaves as expected for a migrated light", function() + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = true, + } + local fake_bridge, _ = device_faker(light_faker_args) + + assert.True(utils.is_dth_light(fake_bridge)) + end) + + it("is_dth_light behaves as expected for a non-migrated light", function() + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = false, + } + local fake_bridge, _ = device_faker(light_faker_args) + + assert.False(utils.is_dth_light(fake_bridge)) + end) + + it("get_hue_bridge_for_device returns the passed in bridge for a migrated bridge when bridge info is not cached", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + } + } + } + + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_bridge)) + end) + + it("get_hue_bridge_for_device returns the passed in bridge for a non-migrated bridge when bridge info is not cached", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = false, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + } + } + } + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_bridge)) + end) + + it("get_hue_bridge_for_device returns the passed in bridge for a migrated bridge when bridge info is cached", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = true, + add_key_to_datastore = true, + map_dni_to_device = true + } + } + } + + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_bridge)) + end) + + it("get_hue_bridge_for_device returns the passed in bridge for a non-migrated bridge when bridge info is cached", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = false, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = true, + add_key_to_datastore = true, + map_dni_to_device = true + } + } + } + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_bridge)) + end) + + it("get_hue_bridge_for_device returns the parent bridge for a migrated light when bridge info is not cached", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = true + } + local fake_light = device_faker(light_faker_args, bridge_info) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + } + }, + child_devices = { + { + device = fake_light, + parent_bridge_info = bridge_info + } + } + } + + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_light)) + end) + + it("get_hue_bridge_for_device returns the parent bridge for a non-migrated light when bridge info is not cached", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = false, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = false + } + local fake_light = device_faker(light_faker_args, bridge_info) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + } + }, + child_devices = { + { + device = fake_light, + parent_bridge_info = bridge_info + } + } + } + + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_light)) + end) + + it("get_hue_bridge_for_device returns the parent bridge for a migrated light when bridge info is cached", function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = true, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = true + } + local fake_light = device_faker(light_faker_args, bridge_info) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = true, + add_key_to_datastore = true, + map_dni_to_device = true + } + }, + child_devices = { + { + device = fake_light, + parent_bridge_info = bridge_info + } + } + } + + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_light)) + end) + + it("get_hue_bridge_for_device returns the parent bridge for a non-migrated light when bridge info is cached", + function() + local bridge_faker_args = { + device_type = HueDeviceTypes.BRIDGE, + migrated = false, + } + local fake_bridge, bridge_info = device_faker(bridge_faker_args) + + local light_faker_args = { + device_type = HueDeviceTypes.LIGHT, + migrated = false + } + local fake_light = device_faker(light_faker_args, bridge_info) + + local fake_driver = driver_faker { + bridges = { + { + device = fake_bridge, + info = bridge_info, + key = bridge_faker_args.bridge_key, + add_info_to_datastore = true, + add_key_to_datastore = true, + map_dni_to_device = true + } + }, + child_devices = { + { + device = fake_light, + parent_bridge_info = bridge_info + } + } + } + + assert.are.same(fake_bridge, utils.get_hue_bridge_for_device(fake_driver, fake_light)) + end) + end) +end) diff --git a/drivers/SmartThings/philips-hue/src/types.lua b/drivers/SmartThings/philips-hue/src/types.lua new file mode 100644 index 0000000000..56643ac96a --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/types.lua @@ -0,0 +1,118 @@ +--- @meta + +---@class HueSseEvent +---@field id string +---@field type string +---@field data HueResourceInfo[] + +---@class HueServiceInfo +---@field public rid string +---@field public rtype HueDeviceTypes + +---@class HueResourceInfo +---@field public metadata { name: string, [string]: any} +---@field public id string +---@field public id_v1 string? +---@field public service_id? integer +---@field public type HueDeviceTypes +---@field public owner HueServiceInfo? +---@field public hue_provided_name string +---@field public hue_device_data table + +---@class HueZigbeeInfo: HueResourceInfo +---@field public status string + +---@class HueDeviceInfo: HueResourceInfo +---@field public services HueServiceInfo[] +---@field public product_data { [string]: string } + +---@class HueColorCoords +---@field public x number +---@field public y number + +---@class HueGamut +---@field public red HueColorCoords +---@field public green HueColorCoords +---@field public blue HueColorCoords + +---@class HueLightInfo: HueResourceInfo +---@field public hue_device_id string +---@field public parent_device_id string +---@field public on { on: boolean } +---@field public color { xy: HueColorCoords, gamut: HueGamut, gamut_type: string } +---@field public dimming { brightness: number, min_dim_level: number } +---@field public color_temperature { mirek: number?, mirek_valid: boolean, mirek_schema: { mirek_minimum: number, mirek_maximum: number }?} +---@field public mode string + +---@class HueMotionInfo: HueResourceInfo +---@field public enabled boolean +---@field public motion { motion: boolean, motion_valid: boolean, motion_report: { motion: true, changed: string } } + +---@class HuePowerLevelInfo: HueResourceInfo +---@field public power_state { battery_state: string, battery_level: number} + +---@class HueTemperatureInfo: HueResourceInfo +---@field public enabled boolean +---@field public temperature { temperature: number, temperature_valid: boolean, temperature_report: { temperature: number, changed: string } } + +---@class HueLightLevelInfo: HueResourceInfo +---@field public enabled boolean +---@field public light { light_level: number, light_level_valid: boolean, light_level_report: { light_level: number, changed: string } } + +---@class HueButtonInfo: HueResourceInfo +---@field public metadata { control_id: integer } +---@field public button { repeat_interval: integer, last_event: string?, button_report: { updated: string, event: string }?, event_values: string[] } + +---@class HueContactInfo: HueResourceInfo +---@field public enabled boolean +---@field public contact_report { changed: string, state: string } + +--- Note that the tamper representation uses an array for its reports. Not sure why this is. +---@class HueTamperInfo: HueResourceInfo +---@field public tamper_reports { changed: string, source: string, state: string }[] + +--- Hue Bridge Info as returned by the unauthenticated API endpoint `/api/config` +--- @class HueBridgeInfo: { [string]: any } +--- @field public name string +--- @field public datastoreversion string +--- @field public swversion string +--- @field public apiversion string +--- @field public mac string +--- @field public bridgeid string +--- @field public factorynew boolean +--- @field public replacesbridgeid string|nil +--- @field public modelid string +--- @field public starterkitid string +--- @field public ip string|nil + +--- @class HueDevice:st.Device +--- @field public label string +--- @field public id string +--- @field public device_network_id string +--- @field public parent_device_id string +--- @field public parent_assigned_child_key string? +--- @field public manufacturer string +--- @field public model string +--- @field public vendor_provided_label string +--- @field public log table device-scoped logging module +--- @field public profile table +--- @field public data nil|{ username: string, bulbId: string } migration data for a migrated device +--- @field public get_child_list fun(self: HueBridgeDevice): HueChildDevice[] +--- @field public get_field fun(self: HueDevice, key: string):any +--- @field public set_field fun(self: HueDevice, key: string, value: any, args?: table) +--- @field public emit_event fun(self: HueDevice, event: any) +--- @field public supports_capability_by_id fun(self: HueDevice, capability_id: string, component: string?): boolean + +--- @class HueBridgeDevice:HueDevice +--- @field public device_network_id string + +--- @class HueChildDevice:HueDevice +--- @field public parent_assigned_child_key string + +--- @class HueGroupInfo:HueResourceInfo +--- @field public services HueServiceInfo[] +--- @field public children HueServiceInfo[] + +--- @class HueZoneInfo:HueGroupInfo + +--- @class HueRoomInfo:HueResourceInfo diff --git a/drivers/SmartThings/philips-hue/src/utils/batched_command_utils.lua b/drivers/SmartThings/philips-hue/src/utils/batched_command_utils.lua new file mode 100644 index 0000000000..241ca95f13 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/batched_command_utils.lua @@ -0,0 +1,215 @@ +local utils = require "utils" +local Fields = require "fields" +local command_handlers = require "handlers.grouped_light_commands" +local capabilities = require "st.capabilities" +local KVCounter = require "utils.kv_counter" + +--- Alias for clearer documentation. +---@alias CommandName string +--- Alias for clearer documentation. +---@alias DeviceUuid string + +--- Capability command before being normalized via st.capabilities +---@class RawCapCommand +---@field public capability string +---@field public component string +---@field public command CommandName +---@field public args any[] +---@field public named_args table? + +--- Serialized batched command from receive_batch. +--- This is already in a table format unlike the normal receive function. +---@class BatchedCommand +---@field public device_uuid DeviceUuid +---@field public capability_command RawCapCommand +---@field public auxilary_command_data table? + +--- Table where all commands match by args and any additional data needed for the command. +---@alias MatchingCommands table + +--- Table sorted by the command name. +---@alias SortedCommandNames table + +--- Sorted command batched. First sorted by the hue bridge. +---@alias SortedCommandBatch table + +--- A matching group that covers some portion of a command batch. +---@alias MatchingGroups table + + +local batched_command_utils = {} + +local switch_on_handler = command_handlers.switch_on_handler +local switch_off_handler = command_handlers.switch_off_handler +local switch_level_handler = command_handlers.switch_level_handler +local set_color_handler = command_handlers.set_color_handler +local set_hue_handler = command_handlers.set_hue_handler +local set_saturation_handler = command_handlers.set_saturation_handler +local set_color_temp_handler = command_handlers.set_color_temp_handler + +local capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_on_handler, + [capabilities.switch.commands.off.NAME] = switch_off_handler, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = switch_level_handler, + }, + [capabilities.colorControl.ID] = { + [capabilities.colorControl.commands.setColor.NAME] = set_color_handler, + [capabilities.colorControl.commands.setHue.NAME] = set_hue_handler, + [capabilities.colorControl.commands.setSaturation.NAME] = set_saturation_handler, + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, + }, +} + +-- Mapping for fields on the device that must also match for the commands to be handled in a group. +local command_name_to_aux_fields = { + [capabilities.colorControl.commands.setColor.NAME] = { + Fields.GAMUT + }, + [capabilities.colorControl.commands.setHue.NAME] = { + Fields.GAMUT, + Fields.COLOR_SATURATION, + }, + [capabilities.colorControl.commands.setSaturation.NAME] = { + Fields.GAMUT, + Fields.COLOR_HUE, + }, + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = { + Fields.MIN_KELVIN, + } +} + +---@param cmd BatchedCommand +---@return function? +function batched_command_utils.get_handler(cmd) + local capability = cmd.capability_command.capability + local command = cmd.capability_command.command + return capability_handlers[capability] and capability_handlers[capability][command] +end + +--- Sort the table by bridge, command, and matching args + any auxilary data needed for the command. +--- +--- The sorted batch is first sorted in to a table with the bridge device as a key and an inner +--- table as the value. +--- +--- The inner table is sorted by command name as the key and an inner array as the value. +--- +--- The inner array contains tables with the device id as the key and the BatchedCommand as the +--- value where all of the BatchedCommands have matching arguments and auxilary data. +--- +--- See SortedCommandBatch. +--- +---@param driver HueDriver +---@param batch BatchedCommand[] +---@return SortedCommandBatch +---@return BatchedCommand[] misfits Commands that cannot be attempted to handle in a batch +function batched_command_utils.sort_batch(driver, batch) + local sorted_batch = KVCounter() + local misfits = {} + + for _, to_inspect in ipairs(batch) do + -- Check if we can handle this in a batch + if not batched_command_utils.get_handler(to_inspect) then + table.insert(misfits, to_inspect) + goto continue + end + + -- First key off bridge. + local device = driver:get_device_info(to_inspect.device_uuid) + if not device then + table.insert(misfits, to_inspect) + goto continue + end + local parent_id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + local bridge_device = utils.get_hue_bridge_for_device(driver, device, parent_id, true) + if not bridge_device then + table.insert(misfits, to_inspect) + goto continue + end + sorted_batch[bridge_device] = sorted_batch[bridge_device] or KVCounter() + local by_bridge = sorted_batch[bridge_device] + + -- Next, key off the command name. + -- Commands are unique across the capabilities supported here so avoid nesting another table + -- with capability name. + local command_name = to_inspect.capability_command.command + by_bridge[command_name] = by_bridge[command_name] or KVCounter() + local by_command = by_bridge[command_name] + + -- Add extra data that must match for the commands to be handled in a group + to_inspect.auxilary_command_data = {} + for _, field in ipairs(command_name_to_aux_fields[command_name] or {}) do + to_inspect.auxilary_command_data[field] = device:get_field(field) + end + + -- Initialize the index to the last position. + -- This will be updated if a matching command group is found. + local index = #by_command + 1 + -- Finally, group commands with matching bridge and command name by matching arguments + -- and auxilary command data. + for match_idx, matching_table in ipairs(by_command) do + -- Grab first command, all the arguments in this table are the same so it doesn't matter. + -- next is a defined function on KVCounter that uses a similar implementation as the default Lua next. + local _, to_match = matching_table.next(matching_table, nil) + + if utils.deep_table_eq(to_match.capability_command.args, to_inspect.capability_command.args) and + utils.deep_table_eq(to_match.auxilary_command_data, to_inspect.auxilary_command_data) then + -- These commands match + index = match_idx + break + end + end + if not by_command[index] then + table.insert(by_command, index, KVCounter()) + end + by_command[index][device.id] = to_inspect + + ::continue:: + end + return sorted_batch, misfits +end + +--- Find groups that the matching commands can use. +--- Larger groups are prefered and overlap is not allowed. +--- Removes commands from the provided matching commands as they are handled by matching groups. +---@param bridge_device HueBridgeDevice +---@param commands MatchingCommands +---@return MatchingGroups +function batched_command_utils.find_matching_groups(bridge_device, commands) + local groups = bridge_device:get_field(Fields.GROUPS) or {} + local matching_groups = {} + -- Groups are sortered from most to least children + for _, group in ipairs(groups) do + if #group.devices == 0 or #group.devices > #commands then + -- Can't match if the group has no light children or if it has more light children + -- than is in the command + goto continue + end + for _, device in ipairs(group.devices) do + if not commands[device.id] then + -- The commands didn't contain one of the light children for this group + goto continue + end + end + -- If we get here then we have a match. Save one of the commands for the handler + matching_groups[group] = commands[group.devices[1].id] + for _, device in ipairs(group.devices) do + -- clear out commands handled by the group + commands[device.id] = nil + end + if #commands == 0 then + -- Nothing else to handle + break + end + ::continue:: + end + return matching_groups +end + +return batched_command_utils + + diff --git a/drivers/SmartThings/philips-hue/src/hue/cie_utils.lua b/drivers/SmartThings/philips-hue/src/utils/cie_utils.lua similarity index 83% rename from drivers/SmartThings/philips-hue/src/hue/cie_utils.lua rename to drivers/SmartThings/philips-hue/src/utils/cie_utils.lua index f8a6d4b3c1..85a25b462d 100644 --- a/drivers/SmartThings/philips-hue/src/hue/cie_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/cie_utils.lua @@ -1,4 +1,8 @@ local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + local CieUtils = {} local DefaultGamut = { @@ -7,6 +11,8 @@ local DefaultGamut = { blue = {x = 0.1532, y = 0.0475} } +---@param val number +---@return number local function _apply_gamma_correct(val) if val > 0.04045 then return ((val + 0.055) / (1.0 + 0.055)) ^ (2.4) @@ -15,6 +21,8 @@ local function _apply_gamma_correct(val) end end +---@param val number +---@return number local function _invert_gamma_correct(val) if val <= 0.0031308 then return val * 12.92 @@ -23,10 +31,16 @@ local function _invert_gamma_correct(val) end end +---@param a HueColorCoords +---@param b HueColorCoords +---@return number local function _vec_cross_product(a, b) return (a.x * b.y) - (b.x * a.y) end +---@param xy HueColorCoords +---@param gamut HueGamut +---@return boolean is_in_triangle local function _xy_in_gamut_triangle(xy, gamut) local red = gamut.red local green = gamut.green @@ -50,6 +64,10 @@ local function _xy_in_gamut_triangle(xy, gamut) return (s >= 0) and (t >= 0) and (s + t < 1) end +---@param point HueColorCoords +---@param line_segment_start HueColorCoords +---@param line_segment_end HueColorCoords +---@return HueColorCoords local function _closest_point_on_line(point, line_segment_start, line_segment_end) local AP = {} local AB = {} @@ -68,12 +86,18 @@ local function _closest_point_on_line(point, line_segment_start, line_segment_en } end +---@param a HueColorCoords +---@param b HueColorCoords +---@return number local function _distance_between_points(a, b) local dx = a.x - b.x local dy = a.y - b.y return math.sqrt(dx * dx + dy * dy) end +---@param xy HueColorCoords +---@param gamut HueGamut +---@return HueColorCoords local function _closest_point_in_gamut_triangle(xy, gamut) local red_green_corner = _closest_point_on_line(xy, gamut.red, gamut.green) local blue_red_corner = _closest_point_on_line(xy, gamut.blue, gamut.red) @@ -97,6 +121,11 @@ local function _closest_point_in_gamut_triangle(xy, gamut) return closest_point end +---@param red number +---@param green number +---@param blue number +---@param gamut HueGamut +---@return HueColorCoords function CieUtils.safe_rgb_to_xy(red, green, blue, gamut) if gamut == nil then gamut = DefaultGamut end red = _apply_gamma_correct(red) @@ -123,6 +152,11 @@ function CieUtils.safe_rgb_to_xy(red, green, blue, gamut) return xy end +---@param xy HueColorCoords +---@param gamut HueGamut +---@return number red +---@return number green +---@return number blue function CieUtils.safe_xy_to_rgb(xy, gamut) if gamut == nil then gamut = DefaultGamut end if not _xy_in_gamut_triangle(xy, gamut) then diff --git a/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua new file mode 100644 index 0000000000..f169381eed --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua @@ -0,0 +1,327 @@ +local log = require "log" +local Fields = require "fields" +local cosock = require "cosock" +local utils = require "utils" + +--- Room or zone with the children translated from their hue device id or light resource id to +--- their SmartThings represented device object. The grouped light resource id is also moved into +--- a separate field from the list of services for ease of use. +--- @class HueLightGroup:HueGroupInfo +--- @field public devices HueDevice[] +--- @field public grouped_light_rid string? + +local grouped_utils = {} + +grouped_utils.GROUP_TYPES = {room = true, zone = true} + +--- Build up mapping of hue device id to SmartThings device record +---@param bridge_device HueBridgeDevice +---@return table +local function build_hue_id_to_device_map(bridge_device) + local hue_id_to_device = {} + local children = bridge_device:get_child_list() + for _, device_record in ipairs(children) do + local hue_device_id = device_record:get_field(Fields.HUE_DEVICE_ID) + if hue_device_id ~= nil then + hue_id_to_device[hue_device_id] = device_record + end + end + return hue_id_to_device +end + +--- Search services to find grouped_light service of a room/zone +---@param group HueGroupInfo +---@return string? +local function find_grouped_light_rid(group) + local services = group.services or {} + for _, service in ipairs(services) do + if service.rtype == "grouped_light" then + return service.rid + end + end + return nil +end + +--- Take the children services in the group and transform them to smartthings device records. +--- This will be helpful when determining if a command batch matches a group and avoids having to +--- deal with hue specific ids for multiple services. +--- +--- If we don't have a device record for the child, then clear out the entire device map so +--- we don't interact with a device through groups that we don't have a record for. +---@param group HueGroupInfo|HueLightGroup +---@param hue_id_to_device table Mapping of hue ID to device record, for rooms. +---@param light_id_to_device table Mapping of light resource ID to device record, for zones. +---@return table +local function build_group_device_table(group, hue_id_to_device, light_id_to_device) + local devices = {} + local seen = {} + for _, child in ipairs(group.children) do + if child.rtype == "light" then + local device = light_id_to_device[child.rid] + if not device then + return {} + end + if not seen[device] then + seen[device] = true + table.insert(devices, device) + end + elseif child.rtype == "device" then + local device = hue_id_to_device[child.rid] + if not device then + return {} + end + if device:get_field(Fields.DEVICE_TYPE) == "light" and not seen[device] then + seen[device] = true + table.insert(devices, device) + end + end + end + return devices +end + +---@param group HueGroupInfo +---@param hue_id_to_device table +---@param light_id_to_device table +---@return HueLightGroup +local function build_hue_light_group(group, hue_id_to_device, light_id_to_device) + local light_group = group --[[@as HueLightGroup]] + light_group.devices = {} + + local grouped_light_id = find_grouped_light_rid(group) + -- If there is no way to control the lights then don't bother adding in the device records + if grouped_light_id == nil then + return light_group + end + light_group.grouped_light_rid = grouped_light_id + light_group.devices = build_group_device_table(light_group, hue_id_to_device, light_id_to_device) + + return light_group +end + +---@param group_kind string room or zone +---@param driver HueDriver +---@param hue_id_to_device table +---@param light_id_to_device table +---@param resp table? +---@param err any? +---@return HueLightGroup[]? +local function handle_group_scan_response(group_kind, driver, hue_id_to_device, light_id_to_device, resp, err) + if err or not resp then + log.error(string.format("Failed to scan for %s: %s", group_kind, err or "unknown error")) + return nil + end + if resp.errors and #resp.errors > 0 then + log.warn(string.format("Bridge replied with %d errors when scanning for %s", + #resp.errors, group_kind)) + return nil + end + if not resp.data then + log.warn(string.format("Bridge replied with no errors or data when scanning for %s", + group_kind)) + return nil + end + + log.info(string.format("Successfully got %d %s", #resp.data, group_kind)) + for _, group in ipairs(resp.data) do + build_hue_light_group(group, hue_id_to_device, light_id_to_device) + log.info(string.format("Found light group %s with %d device records", group.id, #group.devices)) + end + + return resp.data +end + +--- @param driver HueDriver +--- @param bridge_device HueBridgeDevice +--- @param api PhilipsHueApi +--- @param hue_id_to_device table +function grouped_utils.scan_groups(driver, bridge_device, api, hue_id_to_device) + local rooms, zones + -- These are the hue light/other service ids rather than the hue device ids + local light_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, bridge_device) or {} + while not (rooms and zones) do + if not rooms then + rooms = handle_group_scan_response("rooms", driver, hue_id_to_device, light_id_to_device, api:get_rooms()) + end + if not zones then + zones = handle_group_scan_response("zones", driver, hue_id_to_device, light_id_to_device, api:get_zones()) + end + end + -- Combine rooms and zones. + for _, zone in ipairs(zones) do + table.insert(rooms, zone) + end + -- Sort from most devices to least for efficiency when checking batched commands. + table.sort(rooms, function (a, b) return #a.devices > #b.devices end) + bridge_device:set_field(Fields.GROUPS, rooms) +end + +--- Find group in known groups and return the index and group. +---@param groups HueLightGroup[] +---@param id string +---@param type string +---@return integer?, HueLightGroup? +local function find(groups, id, type) + for i, group in ipairs(groups) do + if group.id == id and group.type == type then + return i, group + end + end + return nil, nil +end + +--- Insert a group into the list of groups in correct index based off device count. +--- @param groups HueLightGroup[] +--- @param to_insert HueLightGroup +local function insert(groups, to_insert) + -- 1 if no other entries + -- last element if smallest group according to loop below + local index = #groups + 1 + + for i, group in ipairs(groups) do + if #group.devices <= #to_insert.devices then + -- if this is bigger or equal to the current index then insert here + index = i + break + end + end + table.insert(groups, index, to_insert) +end + +--- Handle room or zone update from SSE stream. +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param to_update table +function grouped_utils.group_update(driver, bridge_device, to_update) + local groups = bridge_device:get_field(Fields.GROUPS) + if groups == nil then + log.warn("Received group update before groups on bridge were initialized. Not handling.") + return + end + + local index, group = find(groups, to_update.id, to_update.type) + if not index or not group then + log.warn("Received update for group with no record. Not handling") + return + end + + local update_index = false + if to_update.children then + local devices = + build_group_device_table( + to_update, + build_hue_id_to_device_map(bridge_device), + -- These are the hue light/other service ids rather than the hue device ids + utils.get_hue_id_to_device_table_by_bridge(driver, bridge_device) or {} + ) + -- check if number of children has changed and if we need to move it + update_index = #devices ~= #group.devices + group.devices = devices + end + if to_update.services then + group.grouped_light_rid = find_grouped_light_rid(to_update) + if group.grouped_light_rid == nil then + group.devices = {} + update_index = true + end + end + + for key, value in pairs(to_update) do + group[key] = value + end + if update_index then + -- Move to the new correct index + table.remove(groups, index) + insert(groups, group) + end + log.info(string.format("Updating group %s, %d devices", group.id, #group.devices)) + bridge_device:set_field(Fields.GROUPS, groups) +end + +--- Handle new room or zone from SSE stream. +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param to_add table +function grouped_utils.group_add(driver, bridge_device, to_add) + local groups = bridge_device:get_field(Fields.GROUPS) + if groups == nil then + log.warn("Received group add before groups on bridge were initialized. Not handling.") + end + local hue_light_group = + build_hue_light_group( + to_add, + build_hue_id_to_device_map(bridge_device), + -- These are the hue light/other service ids rather than the hue device ids + utils.get_hue_id_to_device_table_by_bridge(driver, bridge_device) or {} + ) + insert(groups, hue_light_group) + log.info(string.format("Adding group %s, %d devices", + hue_light_group.id, #hue_light_group.devices)) + bridge_device:set_field(Fields.GROUPS, groups) +end + +--- Handle room or zone delete from SSE stream. +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param to_delete table +function grouped_utils.group_delete(driver, bridge_device, to_delete) + local groups = bridge_device:get_field(Fields.GROUPS) or {} + local index, group = find(groups, to_delete.id, to_delete.type) + if index and group then + log.info(string.format("Deleting group %s, %d devices", group.id, #group.devices)) + table.remove(groups, index) + bridge_device:set_field(Fields.GROUPS, groups) + else + log.warn("Received delete for group with no record.") + end +end + +function grouped_utils.set_field_on_group_devices(group, field, v) + for _, device in ipairs(group.devices) do + device:set_field(field, v) + end +end + + +function grouped_utils.queue_group_scan(driver, bridge_device) + local queue = bridge_device:get_field(Fields.GROUPS_SCAN_QUEUE) + if queue == nil then + local tx, rx = cosock.channel.new() + -- Set timeout to 30 seconds to allow for other queued scans to come in. + rx:settimeout(45) + cosock.spawn(function() + while true do + -- The goal here is to timeout on the receive. If we receive a message then another request + -- to scan came in. This queuing is to prevent a bunch of added devices from causing to have + -- to keep scanning over and over. + local _, err = rx:receive() + if err then + -- err is most likely a timeout but break for all errs + break + end + end + + -- Finally do the scan + local hue_device_table = build_hue_id_to_device_map(bridge_device) + local api = bridge_device:get_field(Fields.BRIDGE_API) + + if api == nil then + log.warn("Bridge api is nil, unable to do group scan") + -- Clear out tx for the next request + bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, nil) + return + end + + grouped_utils.scan_groups(driver, bridge_device, api, hue_device_table) + + -- Clear out tx for the next request + bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, nil) + end) + -- Set the queue to the tx + bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, tx) + else + -- Send on the tx that another request has come in. + queue:send(nil) + end +end + +return grouped_utils diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua new file mode 100644 index 0000000000..05905b80ff --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -0,0 +1,374 @@ +local capabilities = require "st.capabilities" +local cosock = require "cosock" +local log = require "log" +local json = require "st.json" +local st_utils = require "st.utils" +-- trick to fix the VS Code Lua Language Server typechecking +---@type fun(val: any?, name: string?, multi_line: boolean?): string +st_utils.stringify_table = st_utils.stringify_table + +local Discovery = require "disco" +local EventSource = require "lunchbox.sse.eventsource" +local Fields = require "fields" +local HueApi = require "hue.api" +local HueDeviceTypes = require "hue_device_types" +local StrayDeviceHelper = require "stray_device_helper" + +local attribute_emitters = require "handlers.attribute_emitters" +local command_handlers = require "handlers.commands" +local lifecycle_handlers = require "handlers.lifecycle_handlers" + +local hue_multi_service_device_utils = require "utils.hue_multi_service_device_utils" +local lunchbox_util = require "lunchbox.util" +local utils = require "utils" +local grouped_utils = require "utils.grouped_utils" + +---@class hue_bridge_utils +local hue_bridge_utils = {} + +---@param driver HueDriver +---@param bridge_device HueBridgeDevice +---@param bridge_url string +---@param api_key string +function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_url, api_key) + if not bridge_device:get_field(Fields.EVENT_SOURCE) then + log.info_with({ hub_logs = true }, "Creating SSE EventSource for bridge " .. + (bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge")) + local url_table = lunchbox_util.force_url_table(bridge_url .. "/eventstream/clip/v2") + local eventsource = EventSource.new( + url_table, + { [HueApi.APPLICATION_KEY_HEADER] = api_key }, + nil + ) + local hue_identifier_to_device_record = utils.get_hue_id_to_device_table_by_bridge(driver, bridge_device) or {} + + eventsource.onopen = function(msg) + log.info_with({ hub_logs = true }, + string.format("Event Source Connection for Hue Bridge \"%s\" established, marking online", bridge_device.label)) + bridge_device:online() + + local bridge_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + cosock.spawn(function() + -- We don't want to do a scan if we're already in a discovery loop, + -- because the event source connection will open if a bridge is discovered + -- and we'll effectively be scanning twice. + -- Two scans that find the same device close together can emit events close enough + -- together that the dedupe logic at the cloud layer will get bypassed and lead to + -- duplicate device records. + if not Discovery.discovery_active then + Discovery.scan_bridge_and_update_devices(driver, bridge_device:get_field(Fields.BRIDGE_ID)) + end + ---@type table + local child_device_map = {} + local children = bridge_device:get_child_list() + local _log = bridge_device.log or log + _log.debug(string.format("Scanning connectivity of %s child devices", #children)) + for _, device_record in ipairs(children) do + local hue_device_id = device_record:get_field(Fields.HUE_DEVICE_ID) + if hue_device_id ~= nil then + child_device_map[hue_device_id] = device_record + end + end + + local scanned = false + local connectivity_status, rest_err + + while true do + if scanned then break end + connectivity_status, rest_err = bridge_api:get_connectivity_status() + if rest_err ~= nil or not connectivity_status then + log.error(string.format("Couldn't query Hue Bridge %s for zigbee connectivity status for child devices: %s", + bridge_device.label, st_utils.stringify_table(rest_err, "Rest Error", true))) + goto continue + end + + if connectivity_status.errors and #connectivity_status.errors > 0 then + log.error( + string.format( + "Hue Bridge %s replied with the following error message(s) " .. + "when querying child device connectivity status:", + bridge_device.label + ) + ) + for idx, err in ipairs(connectivity_status.errors) do + log.error(string.format("--- %s", st_utils.stringify_table(err, string.format("Error %s:", idx), true))) + end + goto continue + end + + if connectivity_status.data and #connectivity_status.data > 0 then + scanned = true + for _, status in ipairs(connectivity_status.data) do + local hue_device_id = (status.owner and status.owner.rid) or "" + log.trace(string.format("Checking connectivity status for device resource id %s", hue_device_id)) + local child_device = child_device_map[hue_device_id] + if child_device then + if not child_device.id then + child_device_map[hue_device_id] = nil + else + if status.status == "connected" then + child_device.log.info_with({ hub_logs = true }, "Marking Online after SSE Reconnect") + child_device:online() + child_device:set_field(Fields.IS_ONLINE, true) + driver:inject_capability_command(child_device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) + elseif status.status == "connectivity_issue" then + child_device.log.info_with({ hub_logs = true }, "Marking Offline after SSE Reconnect") + child_device:set_field(Fields.IS_ONLINE, false) + child_device:offline() + end + end + end + end + end + + ::continue:: + end + grouped_utils.queue_group_scan(driver, bridge_device) + end, string.format("Hue Bridge %s On Connect Task", bridge_device.label)) + end + + eventsource.onerror = function() + log.error_with({ hub_logs = true }, string.format("Hue Bridge \"%s\" Event Source Error", bridge_device.label)) + + for _, device_record in ipairs(bridge_device:get_child_list()) do + device_record:set_field(Fields.IS_ONLINE, false) + device_record:offline() + end + + bridge_device:offline() + end + + eventsource.onmessage = function(msg) + if msg and msg.data then + local json_result = table.pack(pcall(json.decode, msg.data)) + local success = table.remove(json_result, 1) + ---@type HueSseEvent[], string? + local events, err = table.unpack(json_result, 1, json_result.n) + + if not success then + log.error_with( + { hub_logs = true, }, + string.format("Couldn't decode JSON in SSE callback: %s", (events or "unexpected nil from pcall catch")) + ) + return + end + + if err ~= nil then + log.error_with({ hub_logs = true }, "JSON Parsing Error: " .. err) + return + end + + for _, event in ipairs(events) do + -- TODO as mentioned in a paired TODO in utils.lua, we could try to add special-case handling + -- here to detect batched button releases. This would let us support multi-press capability + -- events. + if event.type == "update" then + for _, update_data in ipairs(event.data) do + log.debug("Received update event with type " .. update_data.type) + local resource_ids = {} + if update_data.type == "zigbee_connectivity" and update_data.owner ~= nil then + for rid, rtype in pairs(driver.services_for_device_rid[update_data.owner.rid] or {}) do + if hue_identifier_to_device_record[rid] then + log.debug(string.format("Adding RID %s to resource_ids", rid)) + table.insert(resource_ids, rid) + end + end + elseif grouped_utils.GROUP_TYPES[update_data.type] then + grouped_utils.group_update(driver, bridge_device, update_data) + else + --- for a regular message from a device doing something normal, + --- you get the resource id of the device service for that device in + --- the data field + table.insert(resource_ids, update_data.id) + end + for _, resource_id in ipairs(resource_ids) do + log.debug(string.format("Looking for device record for %s", resource_id)) + local child_device = hue_identifier_to_device_record[resource_id] + if child_device ~= nil and child_device.id ~= nil then + if update_data.type == "zigbee_connectivity" then + log.debug("emitting event for zigbee connectivity") + attribute_emitters.connectivity_update(child_device, update_data) + else + local device_type = utils.determine_device_type(child_device) + log.debug(st_utils.stringify_table({device_type = device_type, update_data}, "updating", true)) + attribute_emitters.emitter_for_device_type(device_type)(child_device, update_data) + end + end + end + end + elseif event.type == "delete" then + for _, delete_data in ipairs(event.data) do + if HueDeviceTypes.can_join_device_for_service(delete_data.type) then + local resource_id = delete_data.id + local child_device = hue_identifier_to_device_record[resource_id] + if child_device ~= nil and child_device.id ~= nil then + log.info( + string.format( + "Device \"%s\" was deleted from hue bridge %s", + (child_device.label or child_device.id or "unknown device"), + (bridge_device.label or bridge_device.device_network_id or bridge_device.id or "unknown bridge") + ) + ) + child_device.log.trace("Attempting to delete Device UUID " .. tostring(child_device.id)) + driver:do_hue_child_delete(child_device) + end + elseif grouped_utils.GROUP_TYPES[delete_data.type] then + grouped_utils.group_delete(driver, bridge_device, delete_data) + end + end + elseif event.type == "add" then + for _, add_data in ipairs(event.data) do + if add_data.type == "device" then + log.info( + string.format( + "New device added to Hue Bridge \"%s\", device properties: \"%s\"", + bridge_device.label, json.encode(add_data) + ) + ) + --- Move handling the add off the SSE thread + cosock.spawn(function() + ---@cast add_data HueDeviceInfo + Discovery.process_device_service( + driver, + bridge_device.device_network_id, + add_data, + Discovery.handle_discovered_child_device, + "New Device Event", + bridge_device + ) + end, "New Device Event Task") + elseif grouped_utils.GROUP_TYPES[add_data.type] then + grouped_utils.group_add(driver, bridge_device, add_data) + end + end + end + end + end + end + + bridge_device:set_field(Fields.EVENT_SOURCE, eventsource, { persist = false }) + end + bridge_device:set_field(Fields._INIT, true, { persist = false }) + local ids_to_remove = {} + for id, device in pairs(driver._devices_pending_refresh) do + local parent_bridge = utils.get_hue_bridge_for_device(driver, device) + local bridge_id = parent_bridge and parent_bridge.id + if bridge_id == bridge_device.id then + table.insert(ids_to_remove, id) + -- we call the handler directly here because we want to use the return value + local refresh_info = command_handlers.refresh_handler(driver, device) + if refresh_info and device:get_field(Fields.IS_MULTI_SERVICE) then + local hue_device_type = utils.determine_device_type(device) + local hue_device_id = device:get_field(Fields.HUE_DEVICE_ID) + hue_multi_service_device_utils.update_multi_service_device_maps( + driver, device, hue_device_id, refresh_info, hue_device_type + ) + end + end + end + for _, id in ipairs(ids_to_remove) do + driver._devices_pending_refresh[id] = nil + end + driver.stray_device_tx:send({ + type = StrayDeviceHelper.MessageTypes.FoundBridge, + driver = driver, + device = bridge_device + }) +end + +---@param driver HueDriver +---@param bridge_info table +---@param bridge_device HueBridgeDevice +function hue_bridge_utils.update_bridge_fields_from_info(driver, bridge_info, bridge_device) + local bridge_ip = bridge_info.ip + local device_bridge_id = bridge_device.device_network_id + + if bridge_device:get_field(Fields._REFRESH_AFTER_INIT) == nil then + bridge_device:set_field(Fields._REFRESH_AFTER_INIT, true, { persist = true }) + end + + bridge_device:set_field(Fields.DEVICE_TYPE, "bridge", { persist = true }) + bridge_device:set_field(Fields.MODEL_ID, bridge_info.modelid, { persist = true }) + bridge_device:set_field(Fields.BRIDGE_ID, device_bridge_id, { persist = true }) + bridge_device:set_field(Fields.BRIDGE_SW_VERSION, tonumber(bridge_info.swversion or "0", 10), { persist = true }) + + if Discovery.api_keys[device_bridge_id] then + bridge_device:set_field(HueApi.APPLICATION_KEY_HEADER, Discovery.api_keys[device_bridge_id], { persist = true }) + driver.api_key_to_bridge_id[Discovery.api_keys[device_bridge_id]] = device_bridge_id + end + bridge_device:set_field(Fields.IPV4, bridge_ip, { persist = true }) +end + +---@param driver HueDriver +---@param device HueBridgeDevice +function hue_bridge_utils.spawn_bridge_add_api_key_task(driver, device) + local device_bridge_id = device.device_network_id + cosock.spawn(function() + -- 30 seconds is the typical UX for waiting to hit the link button in the Hue ecosystem + local timeout_time = cosock.socket.gettime() + 30 + + -- we pre-declare these variables in the outer scope so that our gotos work. + -- a sad day that we need these gotos. + local api_key_response, err, api_key, bridge_info, bridge_ip, _ + repeat + local time_remaining = math.max(0, timeout_time - cosock.socket.gettime()) + if time_remaining == 0 then + local _log = device.log or log + _log.error_with({ hub_logs = true }, + string.format( + "Link button not pressed or API key not received for bridge \"%s\" after 30 seconds, sleeping then trying again in a few minutes.", + device.label + ) + ) + cosock.socket.sleep(120) -- two minutes + timeout_time = cosock.socket.gettime() + 30 -- refresh timeout time + goto continue + end + + if not driver.datastore.bridge_netinfo[device_bridge_id] then + goto continue + end + + bridge_info = driver.datastore.bridge_netinfo[device_bridge_id] + bridge_ip = bridge_info.ip + + api_key_response, err, _ = HueApi.request_api_key( + bridge_ip, + utils.labeled_socket_builder((device.label or device.device_network_id or device.id or "unknown bridge")) + ) + + if err ~= nil or not api_key_response then + log.warn("Error while trying to request Bridge API Key: ", err) + goto continue + end + + for _, item in ipairs(api_key_response) do + if item.error ~= nil then + log.warn("Error paylod in bridge API key response: " .. item.error.description) + goto continue + end + + api_key = item.success.username + end + + ::continue:: + -- don't hammer the bridge since we're waiting for the user to hit the link button + if api_key == nil then cosock.socket.sleep(2) end + until api_key ~= nil + + if not api_key then + log.error_with({ hub_logs = true }, "Link button not pressed or API key not received for bridge " .. + (device.label or device.device_network_id or device.id or "unknown")) + return + end + + Discovery.api_keys[device_bridge_id] = api_key + lifecycle_handlers.initialize_device(driver, device) + end, "Hue Bridge Background Join Task") +end + +return hue_bridge_utils diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/init.lua b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/init.lua new file mode 100644 index 0000000000..ccb0153510 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/init.lua @@ -0,0 +1,45 @@ +local log = require "log" +local utils = require "utils" + +local HueDeviceTypes = require "hue_device_types" + +local lazy_disco_handlers = utils.lazy_handler_loader("disco") +local lazy_map_helpers = utils.lazy_handler_loader("utils.hue_multi_service_device_utils") + +local lookup_transforms = { + [HueDeviceTypes.MOTION] = "sensor", + [HueDeviceTypes.CONTACT] = "sensor", + [HueDeviceTypes.BUTTON] = "sensor" +} + +---@class MultiServiceDeviceUtils +local multi_service_device_utils = {} + +-- TODO refactor this to be generalized for all sensors, similar to the multi service map update. +---@param driver HueDriver +---@param sensor_device_type HueDeviceTypes +---@param api_instance PhilipsHueApi +---@param device_service_id string +---@param bridge_network_id string +---@return table? nil on error +---@return string? err nil on success +function multi_service_device_utils.get_all_service_states(driver, sensor_device_type, api_instance, device_service_id, bridge_network_id) + return lazy_disco_handlers[sensor_device_type].update_state_for_all_device_services(driver, api_instance, device_service_id, bridge_network_id) +end + +function multi_service_device_utils.update_multi_service_device_maps(driver, device, hue_device_id, device_info, device_type) + device_type = device_type or utils.determine_device_type(device) + device_type = lookup_transforms[device_type] or device_type + if not lazy_map_helpers[device_type] then + log.warn( + string.format( + "No multi-service device mapping helper for device %s with type %s", + (device and device.label) or "unknown device", + device_type or "unknown type" + ) + ) + end + return lazy_map_helpers[device_type].update_multi_service_device_maps(driver, device, hue_device_id, device_info) +end + +return multi_service_device_utils diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/sensor.lua b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/sensor.lua new file mode 100644 index 0000000000..e9212b2433 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/hue_multi_service_device_utils/sensor.lua @@ -0,0 +1,26 @@ +local utils = require "utils" + +local SensorMultiServiceHelper = {} +function SensorMultiServiceHelper.update_multi_service_device_maps(driver, device, hue_device_id, sensor_info) + local svc_rids_for_device = driver.services_for_device_rid[hue_device_id] or {} + + if type(sensor_info.sensor_list) == "table" then + for id_key, sensor_type in pairs(sensor_info.sensor_list) do + if + sensor_info and + sensor_info[id_key] and + not svc_rids_for_device[sensor_info[id_key]] + then + svc_rids_for_device[sensor_info[id_key]] = sensor_type + end + end + end + + driver.services_for_device_rid[hue_device_id] = svc_rids_for_device + local hue_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, device) + for rid, _ in pairs(driver.services_for_device_rid[hue_device_id]) do + hue_id_to_device[rid] = device + end +end + +return SensorMultiServiceHelper diff --git a/drivers/SmartThings/philips-hue/src/utils/init.lua b/drivers/SmartThings/philips-hue/src/utils/init.lua new file mode 100644 index 0000000000..3f65c9a4d5 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/init.lua @@ -0,0 +1,558 @@ +local log = require "log" +local st_utils = require "st.utils" + +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" + +---@class utils +local utils = { + _lazy_loaders = {} +} + +function utils.lazy_handler_loader(parent_module) + if utils._lazy_loaders[parent_module] then + return utils._lazy_loaders[parent_module] + end + local nils = {} + local loader = setmetatable( + {}, + { + __index = function(tbl, key) + if key == nil then return nil end + if nils[key] then return nil end + if rawget(tbl, key) == nil then + local success, mod = pcall(require, string.format("%s.%s", parent_module, key)) + if success then + rawset(tbl, key, mod) + else + nils[key] = true + return nil + end + end + + return rawget(tbl, key) + end + } + ) + utils._lazy_loaders[parent_module] = loader + return loader +end + +function utils.safe_wrap_handler(handler) + return function(driver, device, ...) + if device == nil or (device and device.id == nil) then + log.warn("Tried to handle capability command for device that has been deleted") + return + end + local success, result = pcall(handler, driver, device, ...) + if not success then + log.error_with({ hub_logs = true }, + string.format("Failed to invoke capability command handler. Reason: %s", result)) + end + return result + end +end + +-- TODO: The Hue API itself doesn't have events for multipresses, however, it will +-- emit batched "short release" eventsource on the SSE stream if they're close together. +-- Right now the SSE stream handling is a relatively dumb pass-through that doesn't inspect +-- the data as it unpacks it; it just dispatches each event in the batch as it encounters it. +-- We could implement 2x-6x press if we add some smarts to the SSE stream handling. +function utils.get_supported_button_values(event_values) + local values = { "pushed" } + for _, event_value in ipairs(event_values) do + if event_value == "long_press" then + table.insert(values, "held") + break + end + end + + return values +end + +function utils.kelvin_to_mirek(kelvin) return 1000000 / kelvin end + +function utils.mirek_to_kelvin(mirek, with_step_size) + local raw_kelvin = 1000000 / mirek + + if type(with_step_size) == "number" then + return with_step_size * math.floor(raw_kelvin / with_step_size) + end + + return st_utils.round(raw_kelvin) +end + +function utils.str_starts_with(str, start) + return str:sub(1, #start) == start +end + +function utils.is_nan(number) + -- IEEE 754 dictates that NaN compares falsey to everything, including itself. + if number ~= number then + return true + end + + -- If someone passes in something that isn't a Number type, it'll pass the above check. + -- Philosophical question: Something that isn't a number can't technicaly have the value + -- of "nan" but "nan" stands for "not a number", so what do we do here? + if type(number) ~= "number" then + log.warn(string.format("utils.is_nan received value of type %s as argument, returning true", type(number))) + return true + end + + -- In the event that something goes wrong with the above two things, + -- we simply compare the tostring against a known NaN value. + return tostring(number) == tostring(0 / 0) +end + +--- Validate whether or not a string is valid UUIDv4. +--- This only validates the format that uses hex digits with hyphen separators; +--- less common formats such as the no sepatator format, or the curly braces format, +--- or a 128-bit integer, are not accounted for. +--- +--- Hue uses what appears to be UUIDv4. But the validation regexes provided in their API +--- reference don't have the typical constraints on the 4-bit version or 2-bit +--- variant fields. So we use a relaxed validation here to match up with their provided +--- regexes, instead of making it a strict UUIDv4 check. +---@param uuid_string string +---@return boolean +function utils.is_hue_id_string(uuid_string) + if type(uuid_string) ~= "string" then + return false + end + local pattern = string.format( + "^%s%%-%s%%-%s%%-%s%%-%s$", + ("%x"):rep(8), + ("%x"):rep(4), + ("%x"):rep(4), + ("%x"):rep(4), + ("%x"):rep(12) + ) + return uuid_string:match(pattern) ~= nil +end + +--- Parse the parent-assigned child key and extract the Hue resource ID and resource type. +--- Not all parent-assigned child keys have a resource type encoded in them. They *do* always +--- have the resource ID encoded in them. +--- +--- The first return value will be true on success, false on failure. The second will be the RID or the error message, +--- and on success the third will be the resource type if it's encoded, or nil if it's not encoded or on error. +---@param device HueDevice +---@return boolean success true if an RID was able to be extracted, false otherwise +---@return string? rid_or_err the hue resource ID, or an error message if success is false +---@return string? maybe_rtype the resource type, if encoded. Some lights that were joined on older driver versions won't have this encoded. +function utils.parse_parent_assigned_key(device) + local uuid_pattern = string.format( + "(%s%%-%s%%-%s%%-%s%%-%s)", + ("%x"):rep(8), + ("%x"):rep(4), + ("%x"):rep(4), + ("%x"):rep(4), + ("%x"):rep(12) + ) + local key = (device and device.parent_assigned_child_key) or "" + local rtype = key:match("([^:]+):") + local rid = key:match(uuid_pattern) + if not rid then + return false, "parent-assigned child key not in the correct format", nil + end + return true, rid, (rtype or HueDeviceTypes.LIGHT) +end + +--- Determine whether or not a string is a valid MAC Address. +--- This function makes the following assumptions: +--- +--- - The input is a string +--- - The address may or may not have separators between the octets +--- - The only valid separators if present are '.', ':', or '-' +--- - If separators are used, then the address must take the form of either +--- six groups of two hex digits (octets) with separators, *or* three groups of four +--- hex digits with separators, which is an odd Cisco format. +--- - In the form with octets, each octet must have two characters. Meaning they +--- must include a leading 0 when necessary. +---@param mac_str string +---@return boolean +function utils.is_valid_mac_addr_string(mac_str) + -- If the argument is not a string, it's not valid + if type(mac_str) ~= "string" then + return false + end + + -- Get the first separator we encounter + local separator = mac_str:match('[:%-%.]') + + local separators_removed = mac_str + -- if there is a separator, make sure that there's only 2 or 5 occurrences. + if separator then + -- escape special characters for use in patterns + if separator == "." then separator = "%." end + if separator == "-" then separator = "%-" end + + -- We strip the first found separator from the string + separators_removed = mac_str:gsub(separator, '') + + -- if other separators remain, we have more than one separator character + -- in use at once, which isn't valid + if separators_removed:match('[:%-%.]') then + return false + end + + -- we count the separator occurrences to make sure it's 2 or 5 as we + -- expect for the appropriate formats + local separator_count = select(2, mac_str:gsub(separator, '')) + if not (separator_count == 2 or separator_count == 5) then + return false + end + end + + -- whatever is left at this point *should* just be 12 hex characters. + -- If that's what we find, then we're a MAC Address based on our + -- given assumptions. + local found = separators_removed:match(('%x'):rep(12)) + return found == separators_removed +end + +--- Get the Hue RID from a Child Device +---@param device HueChildDevice +---@return string? resource_id the Hue RID, or nil on error +---@return string? err +function utils.get_hue_rid(device) + if + device == nil + or (device and (device.id == nil or device.get_field == nil or device.device_network_id == nil)) + then + return nil, string.format("nil or incomplete device record passed to get_hue_rid, device has likely been deleted") + end + + local resource_id = device:get_field(Fields.RESOURCE_ID) + if resource_id then + return resource_id + end + + if utils.is_dth_light(device) then + return nil, + string.format("Can't get the Hue RID of migrated DTH light %s that hasn't completed migration", device.label) + end + + local success, rid, _ = utils.parse_parent_assigned_key(device) + if success and rid then + return rid + end + + return + nil, + string.format( + "error establishing Hue RID from parent assigned key [%s]", + (device and device.parent_assigned_child_key) or "Parent Assigned Key Not Available" + ) +end + +--- Get the HueDeviceType value for a device. If available on the device field, then it +--- will use that, otherwise, it falls back by parsing SmartThings Device Record metadata +--- such as the DNI or parent_assigned_child_key +---@param device HueDevice +---@return string? device_type the device type, which is a CLIPv2 Resource Type string. Nil on error. +---@return string? err +function utils.determine_device_type(device) + -- If the device type is already stored on the device record and it's valid, used that. + if HueDeviceTypes.is_valid_device_type(device:get_field(Fields.DEVICE_TYPE) or "") then + return device:get_field(Fields.DEVICE_TYPE) + end + + -- Devices w/ a MAC Address for their DNI are always Hue Bridges. + if utils.is_valid_mac_addr_string(device.device_network_id) then + return HueDeviceTypes.BRIDGE + end + + -- Special-case lookup for migrated DTH lights. Since their parent-assigned + -- child keys are out of our control, we look at this before we do anything + -- else. + if utils.is_dth_light(device) then + return HueDeviceTypes.LIGHT + end + + local success, rid, rtype = utils.parse_parent_assigned_key(device) + -- If the resource type is encoded in the key, we return that. + if success and rtype then + return rtype + end + + -- If the resource is not encoded but the parent assigned key is a + -- valid UUID, then we can safely assume that we're looking at a Light + -- that was joined when that was the only device type we supported. + if success and rid and utils.is_hue_id_string(rid) then + return HueDeviceTypes.LIGHT + end + + return + nil, + string.format( + "Couldn't determine device type for device %s", + (device and device.label) or "Unknown Device" + ) +end + +--- Attempts an exhaustive check of all the ways a device +--- can indicate that it represents a Hue Bridge. +---@param driver HueDriver +---@param device HueDevice +---@return boolean is_bridge true if the device record represents a Hue Bridge +function utils.is_bridge(driver, device) + return (device:get_field(Fields.DEVICE_TYPE) == "bridge") + or (driver.datastore.bridge_netinfo[device.device_network_id] ~= nil) + or utils.is_edge_bridge(device) or utils.is_dth_bridge(device) + or (device.parent_assigned_child_key == nil) +end + +--- Only checked during `added` callback, or as a later +--- fallback check in the chain of booleans used in `is_bridge`. +--- +---@see utils.is_bridge +---@param device HueDevice +---@return boolean +function utils.is_edge_bridge(device) + return + device.device_network_id and + utils.is_valid_mac_addr_string(device.device_network_id) and + not (device.data and device.data.username) +end + +--- Only checked during `added` callback +---@param device HueDevice +---@return boolean +function utils.is_dth_bridge(device) + return device.data ~= nil + and not device.data.bulbId + and device.data.username ~= nil +end + +--- Only checked during `added` callback +---@param device HueDevice +---@return boolean +function utils.is_dth_light(device) + if string.find(device.device_network_id, "/") then + return true + end + + return device.data ~= nil + and device.data.bulbId ~= nil + and device.data.username ~= nil +end + +--- Get the Hue Bridge for a Device; useful when you don't know if you have a child +--- or a grandchild device and you need to walk up the family tree. Will return the +--- passed argument if the argument is a bridge. +---@param driver HueDriver +---@param device HueDevice +---@param parent_device_id string? +---@param quiet boolean? +---@return HueBridgeDevice? bridge_device +function utils.get_hue_bridge_for_device(driver, device, parent_device_id, quiet) + local _ = quiet or + log.trace(string.format("------------------------ Looking for bridge for %s with parent_device_id %s", device.label, device.parent_device_id)) + if utils.is_bridge(driver, device) then + log.trace(string.format("------------------------- %s is a bridge", device.label)) + return device --[[ @as HueBridgeDevice ]] + end + + local parent_device_id = parent_device_id or device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) + local parent_device = driver:get_device_info(parent_device_id) + if not parent_device then + local _ = quiet or + log.trace(string.format("------------------------- get_device_info for %s was nil", parent_device_id)) + return nil + end + + local _ = quiet or + log.trace(string.format("------------------------- parent_device label is %s, checking if bridge", parent_device.label)) + if parent_device and utils.is_bridge(driver, parent_device) then + return parent_device --[[ @as HueBridgeDevice ]] + end + + return utils.get_hue_bridge_for_device(driver, parent_device, nil, quiet) +end + +--- Get the mapping of hue id to device table by associated bridge. The mapping is separated by bridge to account +--- for devices migrated to a new hue bridge. +---@param driver HueDriver +---@param bridge_or_device HueDevice +---@return table? hue_id_to_device +function utils.get_hue_id_to_device_table_by_bridge(driver, bridge_or_device) + -- If bridge_or_device is a bridge this will just return itself + local bridge = utils.get_hue_bridge_for_device(driver, bridge_or_device) + if not bridge then + log.warn(string.format("Failed to lookup bridge for device %s", bridge_or_device.label)) + return nil + end + local bridge_id = bridge:get_field(Fields.BRIDGE_ID) + if not bridge_id then + log.warn(string.format("Failed to get bridge id for %s", bridge.label)) + return nil + end + driver.hue_identifier_to_device_record_by_bridge[bridge_id] = driver.hue_identifier_to_device_record_by_bridge[bridge_id] or {} + return driver.hue_identifier_to_device_record_by_bridge[bridge_id] +end + +--- build a exponential backoff time value generator +--- +---@param max number the maximum wait interval (not including `rand factor`) +---@param inc number the rate at which to exponentially back off +---@param rand number a randomization range of (-rand, rand) to be added to each interval +---@return fun(): number backoff generator function that returns a growing backoff each time it's called based on the params +function utils.backoff_builder(max, inc, rand) + local count = 0 + inc = inc or 1 + return function() + local randval = 0 + if rand then + randval = math.random() * rand * 2 - rand + end + + local base = inc * (2 ^ count - 1) + count = count + 1 + + -- ensure base backoff (not including random factor) is less than max + if max then base = math.min(base, max) end + + -- ensure total backoff is >= 0 + return math.max(base + randval, 0) + end +end + +---Wrapper around the socket builder pattern that prepends log messages with a label +---@param label string? +---@return fun(host: string, port: number|string, wrap_ssl: boolean?) sock_builder +function utils.labeled_socket_builder(label) + local socket = require "cosock.socket" + local ssl = require "cosock.ssl" + + label = (label or "") + if #label > 0 then + label = label .. " " + end + + local function make_socket(host, port, wrap_ssl) + log.info( + string.format( + "%sCreating TCP socket for Hue REST Connection", label + ) + ) + + local sock, err = socket.tcp() + + if err ~= nil or (not sock) then + return nil, (err or "unknown error creating TCP socket") + end + + log.info( + string.format( + "%sSetting TCP socket timeout for Hue REST Connection", label + ) + ) + err = select(2, sock:settimeout(60)) + if err ~= nil then + return nil, "settimeout error: " .. err + end + + log.info( + string.format( + "%sConnecting TCP socket for Hue REST Connection", label + ) + ) + err = select(2, sock:connect(host, port)) + if err ~= nil then + return nil, "Connect error: " .. err + end + + log.info( + string.format( + "%sSet Keepalive for TCP socket for Hue REST Connection", label + ) + ) + err = select(2, sock:setoption("keepalive", true)) + if err ~= nil then + return nil, "Setoption error: " .. err + end + + if wrap_ssl then + log.info( + string.format( + "%sCreating SSL wrapper for for Hue REST Connection", label + ) + ) + sock, err = + ssl.wrap(sock, { mode = "client", protocol = "any", verify = "none", options = "all" }) + if not sock or err ~= nil then + return nil, (err and "SSL wrap error: " .. err) or "Unexpected nil socket returned from ssl.wrap" + end + log.info( + string.format( + "%sSetting SSL socket timeout for Hue REST Connection", label + ) + ) + -- Re-set timeout due to cosock not carrying timeout over in some Lua library versions + err = select(2, sock:settimeout(60)) + if err ~= nil then + return nil, "settimeout error: " .. err + end + log.info( + string.format( + "%sPerforming SSL handshake for for Hue REST Connection", label + ) + ) + err = select(2, sock:dohandshake()) + if err ~= nil then + return nil, "Error with SSL handshake: " .. err + end + end + + log.info( + string.format( + "%sSuccessfully created TCP connection for Hue", label + ) + ) + return sock, err + end + return make_socket +end + +--- From https://gist.github.com/sapphyrus/fd9aeb871e3ce966cc4b0b969f62f539 +--- MIT licensed +---@param tbl1 table +---@param tbl2 table +---@return boolean +function utils.deep_table_eq(tbl1, tbl2) + if tbl1 == tbl2 then + return true + elseif type(tbl1) == "table" and type(tbl2) == "table" then + for key1, value1 in pairs(tbl1) do + local value2 = tbl2[key1] + + if value2 == nil then + -- avoid the type call for missing keys in tbl2 by directly comparing with nil + return false + elseif value1 ~= value2 then + if type(value1) == "table" and type(value2) == "table" then + if not utils.deep_table_eq(value1, value2) then + return false + end + else + return false + end + end + end + + -- check for missing keys in tbl1 + for key2, _ in pairs(tbl2) do + if tbl1[key2] == nil then + return false + end + end + + return true + end + + return false +end + +return utils diff --git a/drivers/SmartThings/philips-hue/src/utils/kv_counter.lua b/drivers/SmartThings/philips-hue/src/utils/kv_counter.lua new file mode 100644 index 0000000000..1c2bdbe3a2 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/utils/kv_counter.lua @@ -0,0 +1,61 @@ +--- Table with metamethods to track the number of items inside of a table including KV pairs. +local KVCounter = {} + +-- Helper function to implement next on KVCounter. +-- There is no metamethod to do this but is useful for getting a value without knowing the keys. +local function kv_counter_next(t, index) + local mt = getmetatable(t) + return next(mt.inner, index) +end + +local function kv_counter_index(t, k) + local mt = getmetatable(t) + + if k == "next" then + return kv_counter_next + end + + return mt.inner[k] +end + +local function kv_counter_newindex(t, k, v) + assert(k ~= "next", "next is an unallowed key in KVCounter") + + local mt = getmetatable(t) + local existed = mt.inner[k] ~= nil + if existed and v == nil then + mt.count = mt.count - 1 + elseif not existed and v ~= nil then + mt.count = mt.count + 1 + end + mt.inner[k] = v +end + +local function kv_counter_pairs(t) + local mt = getmetatable(t) + return pairs(mt.inner) +end + +local function kv_counter_len(t) + local mt = getmetatable(t) + return mt.count +end + +local function kv_counter_factory() + local mt = { + -- Avoid blowing up lua closures by defining these outside of the function. + __index = kv_counter_index, + __newindex = kv_counter_newindex, + __pairs = kv_counter_pairs, + __len = kv_counter_len, + count = 0, + inner = {} + } + return setmetatable({}, mt) +end + +setmetatable(KVCounter, { + __call = kv_counter_factory +}) + +return KVCounter diff --git a/drivers/SmartThings/samsung-audio/profiles/samsung-audio.yml b/drivers/SmartThings/samsung-audio/profiles/samsung-audio.yml index 0e478688f6..3339be1ac2 100644 --- a/drivers/SmartThings/samsung-audio/profiles/samsung-audio.yml +++ b/drivers/SmartThings/samsung-audio/profiles/samsung-audio.yml @@ -20,8 +20,6 @@ components: version: 1 - id: audioMute version: 1 - - id: audioNotification - version: 1 - id: audioVolume version: 1 - id: audioTrackData diff --git a/drivers/SmartThings/samsung-audio/src/command.lua b/drivers/SmartThings/samsung-audio/src/command.lua index deca7c4416..d03fe07850 100644 --- a/drivers/SmartThings/samsung-audio/src/command.lua +++ b/drivers/SmartThings/samsung-audio/src/command.lua @@ -34,6 +34,19 @@ local function is_empty(t) return not t or (type(t) == "table" and #t == 0) end +local function get_uic_response(ret, command_name) + local root = ret and ret.handler_res and ret.handler_res.root + local uic = root and root.UIC + local response = uic and uic.response + + if not uic then + log.warn(string.format("Missing UIC data in %s response", tostring(command_name))) + return nil, nil + end + + return uic, response +end + local function tr(s,mappings) return string.gsub(s, "(.)", @@ -94,8 +107,9 @@ function Command.volume(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetVolume") local ret = handle_http_request(ip, url) - if ret then - response_map = { volume = ret.handler_res.root.UIC.response.volume, } + local _, response = get_uic_response(ret, "GetVolume") + if response and response.volume ~= nil then + response_map = { volume = response.volume, } end end return response_map @@ -114,8 +128,9 @@ function Command.set_volume(ip, level) local encoded_str_vol = "/UIC?cmd=%3Cpwron%3Eon%3C/pwron%3E%3Cname%3ESetVolume%3C/name%3E%3Cp%20type=%22dec%22%20name=%22volume%22%20val=%22" .. level .. "%22%3E%3C/p%3E" local url = format_url(ip, encoded_str_vol) local ret = handle_http_request(ip, url) - if ret then - response_map = { volume = ret.handler_res.root.UIC.response.volume, } + local _, response = get_uic_response(ret, "SetVolume") + if response and response.volume ~= nil then + response_map = { volume = response.volume, } end end return response_map @@ -326,8 +341,9 @@ function Command.getMute(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetMute") local ret = handle_http_request(ip, url) - if ret then - response_map = { muted = ret.handler_res.root.UIC.response.mute,} + local _, response = get_uic_response(ret, "GetMute") + if response and response.mute ~= nil then + response_map = { muted = response.mute,} end end return response_map @@ -342,8 +358,9 @@ function Command.getPlayStatus(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetPlayStatus") local ret = handle_http_request(ip, url) - if ret then - response_map = { playstatus = ret.handler_res.root.UIC.response.playstatus,} + local _, response = get_uic_response(ret, "GetPlayStatus") + if response and response.playstatus ~= nil then + response_map = { playstatus = response.playstatus,} end end return response_map diff --git a/drivers/SmartThings/samsung-audio/src/handlers.lua b/drivers/SmartThings/samsung-audio/src/handlers.lua index 6233a86326..65a0027bf9 100644 --- a/drivers/SmartThings/samsung-audio/src/handlers.lua +++ b/drivers/SmartThings/samsung-audio/src/handlers.lua @@ -137,7 +137,7 @@ end function CapabilityHandlers.handle_audio_notification(driver, device, cmd) local ip = device:get_field("ip") local mute_status = command.getMute(ip) - if mute_status.muted ~= "off" then + if mute_status and mute_status.muted ~= nil and mute_status.muted ~= "off" then --unmute before playig notification command.unmute(ip) end diff --git a/drivers/SmartThings/samsung-audio/src/init.lua b/drivers/SmartThings/samsung-audio/src/init.lua index 92f78d86ee..2e51b8e0b4 100644 --- a/drivers/SmartThings/samsung-audio/src/init.lua +++ b/drivers/SmartThings/samsung-audio/src/init.lua @@ -94,14 +94,23 @@ local function emit_refresh_data_to_server(driver, device, cmd) -- get volume local vol = command.volume(device:get_field("ip")) - device:emit_event(capabilities.audioVolume.volume(tonumber(vol.volume))) + local current_volume = vol and tonumber(vol.volume) + if current_volume ~= nil then + device:emit_event(capabilities.audioVolume.volume(current_volume)) + else + log.warn("Unable to read speaker volume from refresh response") + end -- get mute status local muteStatus = command.getMute(device:get_field("ip")) - if muteStatus.muted ~= "off" then - device:emit_event(capabilities.audioMute.mute.muted()) + if muteStatus and muteStatus.muted ~= nil then + if muteStatus.muted ~= "off" then + device:emit_event(capabilities.audioMute.mute.muted()) + else + device:emit_event(capabilities.audioMute.mute.unmuted()) + end else - device:emit_event(capabilities.audioMute.mute.unmuted()) + log.warn("Unable to read speaker mute state from refresh response") end end @@ -213,11 +222,6 @@ local samsungAudio = Driver("samsung-audio", { [capabilities.audioVolume.commands.volumeDown.NAME] = handlers.handle_volume_down, [capabilities.audioVolume.commands.setVolume.NAME] = handlers.handle_set_volume, }, - [capabilities.audioNotification.ID] = { - [capabilities.audioNotification.commands.playTrack.NAME] = handlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndResume.NAME] = handlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndRestore.NAME] = handlers.handle_audio_notification, - }, }, }) diff --git a/drivers/SmartThings/sonos/.gitignore b/drivers/SmartThings/sonos/.gitignore index 453828a5e9..a0ba8d6319 100644 --- a/drivers/SmartThings/sonos/.gitignore +++ b/drivers/SmartThings/sonos/.gitignore @@ -1 +1,6 @@ app_key.lua +.vscode/ +.zed/ +.editorconfig +.luarc.json +.stylua.toml diff --git a/drivers/SmartThings/sonos/capabilities/softwareGeneration.yaml b/drivers/SmartThings/sonos/capabilities/softwareGeneration.yaml new file mode 100644 index 0000000000..8a0a9229a4 --- /dev/null +++ b/drivers/SmartThings/sonos/capabilities/softwareGeneration.yaml @@ -0,0 +1,17 @@ +id: stus.softwareGeneration +version: 1 +status: proposed +name: Software Generation +ephemeral: false +attributes: + generation: + schema: + type: object + properties: + value: + type: string + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} diff --git a/drivers/SmartThings/sonos/profiles/sonos-player.yml b/drivers/SmartThings/sonos/profiles/sonos-player.yml index c7fd45c238..a8030db88c 100644 --- a/drivers/SmartThings/sonos/profiles/sonos-player.yml +++ b/drivers/SmartThings/sonos/profiles/sonos-player.yml @@ -30,6 +30,8 @@ components: version: 1 - id: audioVolume version: 1 + - id: stus.softwareGeneration + version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/sonos/src/api/cmd_handlers.lua b/drivers/SmartThings/sonos/src/api/cmd_handlers.lua index a7c3a16e8a..b5ccfe10b9 100644 --- a/drivers/SmartThings/sonos/src/api/cmd_handlers.lua +++ b/drivers/SmartThings/sonos/src/api/cmd_handlers.lua @@ -11,10 +11,10 @@ local CapCommandHandlers = {} local QUEUE_ACTION_PREF = "queueAction" -local function _do_send(device, payload) +local function _do_send(device, payload, use_coordinator) local conn = device:get_field(PlayerFields.CONNECTION) if conn and conn:is_running() then - conn:send_command(payload) + conn:send_command(payload, use_coordinator) else log.warn("No sonos connection for handling capability command") end @@ -24,42 +24,61 @@ local function _do_send_to_group(driver, device, payload) local household_id, group_id = driver.sonos:get_group_for_device(device) payload[1].householdId = household_id payload[1].groupId = group_id + local maybe_token, err = driver:wait_for_oauth_token(30) + if err then + log.warn(string.format("notice: wait_for_oauth_token -> %s", err)) + end + + if maybe_token then + payload[1].authorization = string.format("Bearer %s", maybe_token.accessToken) + end - _do_send(device, payload) + _do_send(device, payload, true) end local function _do_send_to_self(driver, device, payload) local household_id, player_id = driver.sonos:get_player_for_device(device) payload[1].householdId = household_id payload[1].playerId = player_id + local maybe_token, err = driver:wait_for_oauth_token(30) + if err then + log.warn(string.format("notice: wait_for_oauth_token -> %s", err)) + end - _do_send(device, payload) + if maybe_token then + payload[1].authorization = string.format("Bearer %s", maybe_token.accessToken) + end + _do_send(device, payload, false) end function CapCommandHandlers.handle_play(driver, device, _cmd) local payload = { - { namespace = "playback", command = "play" }, {} + { namespace = "playback", command = "play" }, + {}, } _do_send_to_group(driver, device, payload) end function CapCommandHandlers.handle_pause(driver, device, _cmd) local payload = { - { namespace = "playback", command = "pause" }, {} + { namespace = "playback", command = "pause" }, + {}, } _do_send_to_group(driver, device, payload) end function CapCommandHandlers.handle_next_track(driver, device, _cmd) local payload = { - { namespace = "playback", command = "skipToNextTrack" }, {} + { namespace = "playback", command = "skipToNextTrack" }, + {}, } _do_send_to_group(driver, device, payload) end function CapCommandHandlers.handle_previous_track(driver, device, _cmd) local payload = { - { namespace = "playback", command = "skipToPreviousTrack" }, {} + { namespace = "playback", command = "skipToPreviousTrack" }, + {}, } _do_send_to_group(driver, device, payload) end @@ -76,7 +95,7 @@ function CapCommandHandlers.handle_set_mute(driver, device, cmd) local set_mute = (cmd.args and cmd.args.state == "muted") local payload = { { namespace = "playerVolume", command = "setMute" }, - { muted = set_mute } + { muted = set_mute }, } _do_send_to_self(driver, device, payload) end @@ -84,7 +103,7 @@ end function CapCommandHandlers.handle_volume_up(driver, device, cmd) local payload = { { namespace = "playerVolume", command = "setRelativeVolume" }, - { volumeDelta = 5 } + { volumeDelta = 5 }, } _do_send_to_self(driver, device, payload) end @@ -92,7 +111,7 @@ end function CapCommandHandlers.handle_volume_down(driver, device, cmd) local payload = { { namespace = "playerVolume", command = "setRelativeVolume" }, - { volumeDelta = -5 } + { volumeDelta = -5 }, } _do_send_to_self(driver, device, payload) end @@ -101,7 +120,7 @@ function CapCommandHandlers.handle_set_volume(driver, device, cmd) local new_volume = st_utils.clamp_value(cmd.args.volume, 0, 100) local payload = { { namespace = "playerVolume", command = "setVolume" }, - { volume = new_volume } + { volume = new_volume }, } _do_send_to_self(driver, device, payload) end @@ -118,7 +137,7 @@ function CapCommandHandlers.handle_group_set_mute(driver, device, cmd) local set_mute = (cmd.args and cmd.args.state == "muted") local payload = { { namespace = "groupVolume", command = "setMute" }, - { muted = set_mute } + { muted = set_mute }, } _do_send_to_group(driver, device, payload) end @@ -126,7 +145,7 @@ end function CapCommandHandlers.handle_group_volume_up(driver, device, cmd) local payload = { { namespace = "groupVolume", command = "setRelativeVolume" }, - { volumeDelta = 5 } + { volumeDelta = 5 }, } _do_send_to_group(driver, device, payload) end @@ -134,7 +153,7 @@ end function CapCommandHandlers.handle_group_volume_down(driver, device, cmd) local payload = { { namespace = "groupVolume", command = "setRelativeVolume" }, - { volumeDelta = -5 } + { volumeDelta = -5 }, } _do_send_to_group(driver, device, payload) end @@ -143,7 +162,7 @@ function CapCommandHandlers.handle_group_set_volume(driver, device, cmd) local new_volume = st_utils.clamp_value(cmd.args.groupVolume, 0, 100) local payload = { { namespace = "groupVolume", command = "setVolume" }, - { volume = new_volume } + { volume = new_volume }, } _do_send_to_group(driver, device, payload) end @@ -154,8 +173,8 @@ function CapCommandHandlers.handle_play_preset(driver, device, cmd) { favoriteId = cmd.args.presetId, playOnCompletion = true, - action = (device.preferences[QUEUE_ACTION_PREF] or "APPEND") - } + action = (device.preferences[QUEUE_ACTION_PREF] or "APPEND"), + }, } _do_send_to_group(driver, device, payload) end @@ -167,10 +186,10 @@ function CapCommandHandlers.handle_audio_notification(driver, device, cmd) appId = "edge.smartthings.com", name = "SmartThings Audio Notification", streamUrl = cmd.args.uri, - } + }, } - if type(cmd.args.level) == 'number' then + if type(cmd.args.level) == "number" then payload[2].volume = cmd.args.level end _do_send_to_self(driver, device, payload) diff --git a/drivers/SmartThings/sonos/src/api/event_handlers.lua b/drivers/SmartThings/sonos/src/api/event_handlers.lua index 72fa369f03..eeb461f7c9 100644 --- a/drivers/SmartThings/sonos/src/api/event_handlers.lua +++ b/drivers/SmartThings/sonos/src/api/event_handlers.lua @@ -1,44 +1,71 @@ local capabilities = require "st.capabilities" +local swGenCapability = capabilities["stus.softwareGeneration"] + local log = require "log" local st_utils = require "st.utils" +local PlayerFields = require "fields".SonosPlayerFields + local CapEventHandlers = {} CapEventHandlers.PlaybackStatus = { Buffering = "PLAYBACK_STATE_BUFFERING", Idle = "PLAYBACK_STATE_IDLE", Paused = "PLAYBACK_STATE_PAUSED", - Playing = "PLAYBACK_STATE_PLAYING" + Playing = "PLAYBACK_STATE_PLAYING", } +local function _do_emit(device, attribute_event) + local bonded = device:get_field(PlayerFields.BONDED) + if not bonded then + device:emit_event(attribute_event) + end +end + +function CapEventHandlers.handle_sw_gen(device, sw_gen) + _do_emit(device, swGenCapability.generation(string.format("%s", sw_gen))) +end + function CapEventHandlers.handle_player_volume(device, new_volume, is_muted) - device:emit_event(capabilities.audioVolume.volume(new_volume)) + _do_emit(device, capabilities.audioVolume.volume(new_volume)) if is_muted then - device:emit_event(capabilities.audioMute.mute.muted()) + _do_emit(device, capabilities.audioMute.mute.muted()) else - device:emit_event(capabilities.audioMute.mute.unmuted()) + _do_emit(device, capabilities.audioMute.mute.unmuted()) end end function CapEventHandlers.handle_group_volume(device, new_volume, is_muted) - device:emit_event(capabilities.mediaGroup.groupVolume(new_volume)) + _do_emit(device, capabilities.mediaGroup.groupVolume(new_volume)) if is_muted then - device:emit_event(capabilities.mediaGroup.groupMute.muted()) + _do_emit(device, capabilities.mediaGroup.groupMute.muted()) else - device:emit_event(capabilities.mediaGroup.groupMute.unmuted()) + _do_emit(device, capabilities.mediaGroup.groupMute.unmuted()) end end +function CapEventHandlers.handle_group_role_update(device, group_role) + _do_emit(device, capabilities.mediaGroup.groupRole(group_role)) +end + +function CapEventHandlers.handle_group_coordinator_update(device, coordinator_id) + _do_emit(device, capabilities.mediaGroup.groupPrimaryDeviceId(coordinator_id)) +end + +function CapEventHandlers.handle_group_id_update(device, group_id) + _do_emit(device, capabilities.mediaGroup.groupId(group_id)) +end + function CapEventHandlers.handle_group_update(device, group_info) local groupRole, groupPrimaryDeviceId, groupId = table.unpack(group_info) - device:emit_event(capabilities.mediaGroup.groupRole(groupRole)) - device:emit_event(capabilities.mediaGroup.groupPrimaryDeviceId(groupPrimaryDeviceId)) - device:emit_event(capabilities.mediaGroup.groupId(groupId)) + _do_emit(device, capabilities.mediaGroup.groupRole(groupRole)) + _do_emit(device, capabilities.mediaGroup.groupPrimaryDeviceId(groupPrimaryDeviceId)) + _do_emit(device, capabilities.mediaGroup.groupId(groupId)) end function CapEventHandlers.handle_audio_clip_status(device, clips) - for _, clip in ipairs(clips) do + for _, clip in ipairs(clips or {}) do if clip.status == "ACTIVE" then log.debug(st_utils.stringify_table(clip, "Playing Audio Clip: ", false)) elseif clip.status == "DONE" then @@ -49,11 +76,11 @@ end function CapEventHandlers.handle_playback_status(device, playback_state) if playback_state == CapEventHandlers.PlaybackStatus.Playing then - device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) + _do_emit(device, capabilities.mediaPlayback.playbackStatus.playing()) elseif playback_state == CapEventHandlers.PlaybackStatus.Idle then - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) + _do_emit(device, capabilities.mediaPlayback.playbackStatus.stopped()) elseif playback_state == CapEventHandlers.PlaybackStatus.Paused then - device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) + _do_emit(device, capabilities.mediaPlayback.playbackStatus.paused()) elseif playback_state == CapEventHandlers.PlaybackStatus.Buffering then -- TODO the DTH doesn't currently do anything w/ buffering; -- might be worth figuring out what to do with this in the future. @@ -62,7 +89,7 @@ function CapEventHandlers.handle_playback_status(device, playback_state) end function CapEventHandlers.update_favorites(device, new_favorites) - device:emit_event(capabilities.mediaPresets.presets(new_favorites)) + _do_emit(device, capabilities.mediaPresets.presets(new_favorites)) end function CapEventHandlers.handle_playback_metadata_update(device, metadata_status_body) @@ -72,7 +99,12 @@ function CapEventHandlers.handle_playback_metadata_update(device, metadata_statu local is_linein = string.find(metadata_status_body.container.type, "linein", 1, true) ~= nil local is_station = string.find(metadata_status_body.container.type, "station", 1, true) ~= nil local is_show = string.find(metadata_status_body.container.type, "show", 1, true) ~= nil - local is_radio_tracklist = string.find(metadata_status_body.container.type, "trackList.program", 1, true) ~= nil + local is_radio_tracklist = string.find( + metadata_status_body.container.type, + "trackList.program", + 1, + true + ) ~= nil if is_linein then audio_track_data.title = metadata_status_body.container.name @@ -89,8 +121,7 @@ function CapEventHandlers.handle_playback_metadata_update(device, metadata_statu if metadata_status_body.track then track_info = metadata_status_body.track elseif metadata_status_body.currentItem and metadata_status_body.currentItem.track then - track_info = metadata_status_body - .currentItem.track + track_info = metadata_status_body.currentItem.track end if track_info ~= nil then @@ -112,7 +143,7 @@ function CapEventHandlers.handle_playback_metadata_update(device, metadata_statu end if type(audio_track_data.title) == "string" then - device:emit_event(capabilities.audioTrackData.audioTrackData(audio_track_data)) + _do_emit(device, capabilities.audioTrackData.audioTrackData(audio_track_data)) end end diff --git a/drivers/SmartThings/sonos/src/api/init.lua b/drivers/SmartThings/sonos/src/api/init.lua index 4cd5a5262a..81fb93116b 100644 --- a/drivers/SmartThings/sonos/src/api/init.lua +++ b/drivers/SmartThings/sonos/src/api/init.lua @@ -1,6 +1,53 @@ ---- @module 'api.SonosApi' +local log = require "log" +local SonosRestApi = require "api.rest" -return { +local SONOS_API_KEY_HTTP_HEADER_KEY = "X-Sonos-Api-Key" + +local load_success, keys = pcall(require, "app_key") + +if not load_success then + log.error(string.format("Problem loading API keys: %s", keys)) +end + +local __loaded_keys = (load_success and keys) or nil + +---@type { s1_key: string, oauth_key: string }? +local api_keys + +if type(__loaded_keys) == "string" then + api_keys = { + s1_key = __loaded_keys, + } +else + api_keys = __loaded_keys +end + +--- @class SonosApi +local SonosApi = { -- constants DEFAULT_SONOS_PORT = 1443, + SONOS_API_KEY_HTTP_HEADER_KEY = SONOS_API_KEY_HTTP_HEADER_KEY, + + -- data + ---@type { s1_key: string, oauth_key: string }? + api_keys = api_keys, + + -- module re-exports + RestApi = SonosRestApi, } + +--- Get the HTTP Headers with the correct API Key and Bearer Token +--- values. +---@param api_key string +---@param access_token string? if nil, no Authorization header will be set +---@return table +function SonosApi.make_headers(api_key, access_token) + local headers = { [SONOS_API_KEY_HTTP_HEADER_KEY] = api_key or (api_keys and api_keys.s1_key) } + if type(access_token) == "string" then + headers["Authorization"] = string.format("Bearer %s", access_token) + end + + return headers +end + +return SonosApi diff --git a/drivers/SmartThings/sonos/src/api/rest.lua b/drivers/SmartThings/sonos/src/api/rest.lua index d960cabfae..93fbcf0f36 100644 --- a/drivers/SmartThings/sonos/src/api/rest.lua +++ b/drivers/SmartThings/sonos/src/api/rest.lua @@ -1,16 +1,8 @@ +local json = require "st.json" local log = require "log" local st_utils = require "st.utils" -local json = require 'st.json' -local RestClient = require 'lunchbox.rest' - ---- SONOS_API_KEY is a Global added to the environment in the root init.lua. ---- This API key is injected in to the driver at deploy time for production. ---- To use your own API key, add an `app_key.lua` to the `src` ---- directory and have the only code be to `return "YOUR_API_KEY"` -local HEADERS = { - ['X-Sonos-Api-Key'] = SONOS_API_KEY -} +local RestClient = require "lunchbox.rest" --- @param response? Response The raw response to process, which can be nil if error is not nil --- @param err? string the incoming error message @@ -21,39 +13,45 @@ local HEADERS = { local function process_rest_response(response, err, partial, err_callback) if err == nil and response == nil then log.error( - st_utils.stringify_table( - { - resp = response, - maybe_err = err, - maybe_partial = partial - }, - "[SonosRestApi] Unexpected nil for both response and error processing REST reply", - false - ) + st_utils.stringify_table({ + resp = response, + maybe_err = err, + maybe_partial = partial, + }, "[SonosRestApi] Unexpected nil for both response and error processing REST reply", false) ) end if err ~= nil then - if type(err_callback) == "function" then err_callback(err) end + if type(err_callback) == "function" then + err_callback(err) + end return response, err, partial elseif response ~= nil then local headers = response:get_headers() - if not (headers and headers:get_one("content-type") == 'application/json') then - return nil, string.format("Received non-JSON content-type header [%s] when JSON response was expected", ((headers and headers:get_one("content-type")) or "no headers")) + if not (headers and headers:get_one("content-type") == "application/json") then + return nil, + string.format( + "Received non-JSON content-type header [%s] when JSON response was expected", + ((headers and headers:get_one("content-type")) or "no headers") + ) else - local body, err = response:get_body() + local body, get_body_err = response:get_body() if not body then - return nil, err + return nil, get_body_err end local json_result = table.pack(pcall(json.decode, body)) local success = table.remove(json_result, 1) if not success then - return nil, st_utils.stringify_table( - {response_body = body, json = json_result}, "Couldn't decode JSON in SSE callback", false - ) + return nil, + st_utils.stringify_table( + { response_body = body, json = json_result }, + "Couldn't decode JSON in REST API Response", + false + ) end - return table.unpack(json_result) + local rest_response, rest_error, response_partial = table.unpack(json_result) + return rest_response, rest_error, response_partial end else return nil, "no response or error received" @@ -66,27 +64,41 @@ end --- useful to issue in order to gather some system information before establishing --- a websocket connection. This module provides a handful of free functions that --- make it easy to perform those handful of requests. ---- @module 'sonos.api.SonosRestApi' +--- +--- Sonos speakers *do not* support `Connection: Keep-Alive` for REST calls. They will *always* +--- reply with `Connection: Close` in the response headers. So we can't use long-lived TCP sockets +--- or persistent RestClient instances here; we can only use one-shot request/response interactions. +--- @class SonosRestApi local SonosRestApi = {} --- Query a Sonos Group IP address for individual player info ---- @param ip string the IP address of the player ---- @param port integer the port number of the player ---- @return SonosDiscoveryInfo|nil ---- @return string|nil error -function SonosRestApi.get_player_info(ip, port) - local url = "https://" .. ip .. ":" .. port .. "/api/v1/players/local/info" - return process_rest_response(RestClient.one_shot_get(url, HEADERS)) +---@param url table a URL table created by `net_url` +---@param headers table? +---@return SonosDiscoveryInfoObject|SonosErrorResponse|nil +---@return string|nil error +function SonosRestApi.get_player_info(url, headers) + url.path = "/api/v1/players/local/info" + return process_rest_response(RestClient.one_shot_get(url, headers)) end -function SonosRestApi.get_groups_info(ip, port, household) - local url = string.format("https://%s:%s/api/v1/households/%s/groups", ip, port, household) - return process_rest_response(RestClient.one_shot_get(url, HEADERS)) +---@param url table a URL table created by `net_url` +---@param household HouseholdId +---@param headers table? +---@return SonosGroupsResponseBody|SonosErrorResponse|nil response +---@return nil|string error +function SonosRestApi.get_groups_info(url, household, headers) + url.path = string.format("/api/v1/households/%s/groups", household) + return process_rest_response(RestClient.one_shot_get(url, headers)) end -function SonosRestApi.get_favorites(ip, port, household) - local url = string.format("https://%s:%s/api/v1/households/%s/favorites", ip, port, household) - return process_rest_response(RestClient.one_shot_get(url, HEADERS)) +---@param url table a URL table created by `net_url` +---@param household HouseholdId +---@param headers table? +---@return SonosFavoritesResponseBody|SonosErrorResponse|nil response +---@return nil|string error +function SonosRestApi.get_favorites(url, household, headers) + url.path = string.format("/api/v1/households/%s/favorites", household) + return process_rest_response(RestClient.one_shot_get(url, headers)) end return SonosRestApi diff --git a/drivers/SmartThings/sonos/src/api/sonos_connection.lua b/drivers/SmartThings/sonos/src/api/sonos_connection.lua index 9db887d4ea..6a131c3b0c 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_connection.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_connection.lua @@ -1,6 +1,7 @@ +local api_version = require "version".api local cosock = require "cosock" -local log = require "log" local json = require "st.json" +local log = require "log" local st_utils = require "st.utils" local lb_utils = require "lunchbox.util" @@ -10,6 +11,7 @@ local PlayerFields = require "fields".SonosPlayerFields local Router = require "api.sonos_websocket_router" local SonosApi = require "api" local SonosRestApi = require "api.rest" +local utils = require "utils" --- A class-like lua module that allows for instantiation of a SonosConnection --- table providing an abstraction layer above the concept of a Sonos websocket connection. @@ -22,9 +24,13 @@ local SonosRestApi = require "api.rest" --- @class SonosConnection --- @field public driver SonosDriver reference to the Edge Driver --- @field public device SonosDevice the player for this connection ---- @field private _self_listener_uuid string ---- @field private _coord_listener_uuid string ---- @field private _initialized boolean +--- @field package _self_listener_uuid string +--- @field package _coord_listener_uuid string +--- @field package _initialized boolean +--- @field package _reconnecting boolean if a reconnect task is active +--- @field package on_message fun(...)? +--- @field package on_error fun(...)? +--- @field package on_close fun(...)? local SonosConnection = {} SonosConnection.__index = SonosConnection @@ -40,53 +46,100 @@ local favorites_version = "" ---@param sonos_conn SonosConnection ---@param namespaces string[] ---@param command "subscribe"|"unsubscribe" -local _update_subscriptions_helper = function(sonos_conn, householdId, playerId, groupId, namespaces, command) - for _, namespace in ipairs(namespaces) do +local _update_subscriptions_helper = function( + sonos_conn, + householdId, + playerId, + groupId, + namespaces, + command, + reply_tx +) + for _, namespace in ipairs(namespaces or {}) do + local wss_msg_header = { + namespace = namespace, + command = command, + householdId = householdId, + groupId = groupId, + playerId = playerId, + } + local maybe_token, err = sonos_conn.driver:get_oauth_token() + if err then + log.warn(string.format("notice: get_oauth_token -> %s", err)) + end + + if maybe_token then + wss_msg_header.authorization = string.format("Bearer %s", maybe_token.accessToken) + end local payload_table = { - { - namespace = namespace, - command = command, - householdId = householdId, - groupId = groupId, - playerId = playerId - }, - {} + wss_msg_header, + {}, } local payload = json.encode(payload_table) - Router.send_message_to_player(playerId, payload) + local unique_key, bad_key_part = utils.sonos_unique_key(householdId, playerId) + if not unique_key then + local err_msg = string.format("Invalid Sonos Unique Key Part: %s", bad_key_part) + if reply_tx then + reply_tx:send(table.pack(nil, err_msg)) + else + log.warn(string.format("Update Subscriptions Error: %s", err_msg)) + end + return + end + Router.send_message_to_player(unique_key, payload, reply_tx) end end ---@param sonos_conn SonosConnection ---@param namespaces string[] ---@param command "subscribe"|"unsubscribe" -local _update_self_subscriptions = function(sonos_conn, namespaces, command) - local householdId = sonos_conn.device:get_field(PlayerFields.HOUSEHOULD_ID) +local _update_self_subscriptions = function(sonos_conn, namespaces, command, reply_tx) + local householdId = sonos_conn.device:get_field(PlayerFields.HOUSEHOLD_ID) local _, playerId = sonos_conn.driver.sonos:get_player_for_device(sonos_conn.device) local _, groupId = sonos_conn.driver.sonos:get_group_for_device(sonos_conn.device) - _update_subscriptions_helper(sonos_conn, householdId, playerId, groupId, namespaces, command) + _update_subscriptions_helper( + sonos_conn, + householdId, + playerId, + groupId, + namespaces, + command, + reply_tx + ) end ---@param sonos_conn SonosConnection ---@param namespaces string[] ---@param command "subscribe"|"unsubscribe" -local _update_coordinator_subscriptions = function(sonos_conn, namespaces, command) - local householdId = sonos_conn.device:get_field(PlayerFields.HOUSEHOULD_ID) +local _update_coordinator_subscriptions = function(sonos_conn, namespaces, command, reply_tx) + local householdId = sonos_conn.device:get_field(PlayerFields.HOUSEHOLD_ID) local _, coordinatorId = sonos_conn.driver.sonos:get_coordinator_for_device(sonos_conn.device) local _, groupId = sonos_conn.driver.sonos:get_group_for_device(sonos_conn.device) - _update_subscriptions_helper(sonos_conn, householdId, coordinatorId, groupId, namespaces, command) + _update_subscriptions_helper( + sonos_conn, + householdId, + coordinatorId, + groupId, + namespaces, + command, + reply_tx + ) end ---@param sonos_conn SonosConnection ---@param household_id HouseholdId ---@param self_player_id PlayerId -local function _open_coordinator_socket(sonos_conn, household_id, self_player_id) +---@param api_key string +local function _open_coordinator_socket(sonos_conn, household_id, self_player_id, api_key) log.debug("Open coordinator socket for: " .. sonos_conn.device.label) - local _, coordinator_id, err = sonos_conn.driver.sonos:get_coordinator_for_device(sonos_conn.device) + local _, coordinator_id, err = + sonos_conn.driver.sonos:get_coordinator_for_device(sonos_conn.device) if err ~= nil then log.error( string.format( - "Could not look up coordinator info for player %s: %s", sonos_conn.device.label, err + "Could not look up coordinator info for player %s: %s", + sonos_conn.device.label, + err ) ) return @@ -95,30 +148,49 @@ local function _open_coordinator_socket(sonos_conn, household_id, self_player_id if coordinator_id ~= self_player_id then local household = sonos_conn.driver.sonos:get_household(household_id) if household == nil then - log.error(string.format("Cannot open coordinator socket, houshold doesn't exist: %s", household_id)) + log.error( + string.format("Cannot open coordinator socket, houshold doesn't exist: %s", household_id) + ) return end local coordinator = household.players[coordinator_id] if coordinator == nil then - log.error(st_utils.stringify_table( - {household = sonos_conn.driver.sonos:get_household(household_id)}, string.format("Coordinator doesn't exist for player: %s", sonos_conn.device.label), false - )) + log.error( + st_utils.stringify_table( + { household = sonos_conn.driver.sonos:get_household(household_id) }, + string.format("Coordinator doesn't exist for player: %s", sonos_conn.device.label), + false + ) + ) return end - _, err = Router.open_socket_for_player(coordinator_id, coordinator.websocketUrl) + _, err = Router.open_socket_for_player( + household_id, + coordinator_id, + coordinator.player.websocket_url, + api_key + ) if err ~= nil then log.error( string.format( - "Couldn't open connection to coordinator for %s: %s", sonos_conn.device.label, err + "Couldn't open connection to coordinator for %s: %s", + sonos_conn.device.label, + err ) ) return end local listener_id - listener_id, err = Router.register_listener_for_socket(sonos_conn, coordinator_id) + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, coordinator_id) + + if not unique_key then + log.error(string.format("Invalid Sonos Unique Key Part: %s", bad_key_part)) + end + + listener_id, err = Router.register_listener_for_socket(sonos_conn, unique_key) if err ~= nil or not listener_id then log.error(err) else @@ -145,7 +217,9 @@ local function backoff_builder(max, inc, rand) count = count + 1 -- ensure base backoff (not including random factor) is less than max - if max then base = math.min(base, max) end + if max then + base = math.min(base, max) + end -- ensure total backoff is >= 0 return math.max(base + randval, 0) @@ -153,59 +227,159 @@ local function backoff_builder(max, inc, rand) end ---@param sonos_conn SonosConnection -local function _spawn_reconnect_task(sonos_conn) +local function _legacy_reconnect_task(sonos_conn) log.debug("Spawning reconnect task for ", sonos_conn.device.label) cosock.spawn(function() local backoff = backoff_builder(60, 1, 0.1) while not sonos_conn:is_running() do local start_success = sonos_conn:start() - if start_success then return end + if start_success then + sonos_conn._reconnecting = false + return + end cosock.socket.sleep(backoff()) end + sonos_conn._reconnecting = false end, string.format("%s Reconnect Task", sonos_conn.device.label)) end +---@param sonos_conn SonosConnection +local function _oauth_reconnect_task(sonos_conn) + log.debug("Spawning reconnect task for ", sonos_conn.device.label) + -- Subscribe first so we get all updates on the bus + local token_receive_handle, err = sonos_conn.driver:oauth_token_event_subscribe() + if not token_receive_handle then + log.warn(string.format("error creating oauth token receive handle for respawn task: %s", err)) + end + cosock.spawn(function() + local backoff = backoff_builder(60, 1, 0.1) + while not sonos_conn:is_running() do + local check_auth = sonos_conn.driver:check_auth(sonos_conn.device) + local unauthorized = (check_auth == false) + + if unauthorized then + sonos_conn.driver:alert_unauthorized() + local token, channel_error = + (token_receive_handle and token_receive_handle:receive()) or nil, + "no token receive handle" + if not token then + log.warn(string.format("Error requesting token: %s", channel_error)) + local _, get_token_err = sonos_conn.driver:get_oauth_token() + if get_token_err then + log.warn(string.format("notice: get_oauth_token -> %s", get_token_err)) + end + end + else + local start_success = sonos_conn:start() + if start_success then + sonos_conn._reconnecting = false + return + end + end + + cosock.socket.sleep(backoff()) + end + sonos_conn._reconnecting = false + end, string.format("%s Reconnect Task", sonos_conn.device.label)) +end + +---@param sonos_conn SonosConnection +local function _spawn_reconnect_task(sonos_conn) + if sonos_conn._reconnecting then + return + end + sonos_conn._reconnecting = true + if type(api_version) == "number" and api_version >= 14 then + _oauth_reconnect_task(sonos_conn) + else + _legacy_reconnect_task(sonos_conn) + end +end + --- Create a new Sonos connection to manage the given device --- @param driver SonosDriver --- @param device SonosDevice --- @return SonosConnection function SonosConnection.new(driver, device) log.debug(string.format("Creating new SonosConnection for %s", device.label)) - local self = setmetatable({ driver = driver, device = device, _listener_uuids = {}, _initialized = false }, - SonosConnection) + local self = setmetatable({ + driver = driver, + device = device, + _listener_uuids = {}, + _initialized = false, + _reconnecting = false, + }, SonosConnection) -- capture the label here in case something goes wonky like a callback being fired after a -- device is removed local device_name = device.label - self.on_message = function(uuid, msg) + self.on_message = function(_, msg) log.debug(string.format("OnMessage for %s", device_name)) if msg.data then log.debug(string.format("Message for %s has data", device_name)) local json_result = table.pack(pcall(json.decode, msg.data)) local success = table.remove(json_result, 1) if not success then - log.error(st_utils.stringify_table( - {response_body = msg.data, json = json_result}, "Couldn't decode JSON in WebSocket callback:", false - )) + log.error( + st_utils.stringify_table( + { response_body = msg.data, json = json_result }, + "Couldn't decode JSON in WebSocket callback:", + false + ) + ) return end local header, body = table.unpack(table.unpack(json_result)) - if header.type == "groups" then + if header.type == "globalError" then + if body.errorCode == "ERROR_NOT_AUTHORIZED" then + if api_version < 14 then + log.error( + "Unable to authenticate Sonos WebSocket Connection, and current API does not support Sonos OAuth" + ) + self:stop() + else + local household_id, player_id = driver.sonos:get_player_for_device(device) + device.log.warn( + string.format("WebSocket connection no longer authorized, disconnecting") + ) + -- closing the socket directly without calling `:stop()` triggers the reconnect loop, + -- which is where we wait for the token to come in. + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, player_id) + if not unique_key then + log.error(string.format("Invalid Sonos Unique Key Part: %s", bad_key_part)) + else + Router.close_socket_for_player(unique_key) + end + self.driver:alert_unauthorized() + end + end + elseif header.type == "groups" then log.trace(string.format("Groups type message for %s", device_name)) - local household_id, current_coordinator = self.driver.sonos:get_coordinator_for_device(self.device) + local household_id, current_coordinator = + self.driver.sonos:get_coordinator_for_device(self.device) local _, player_id = self.driver.sonos:get_player_for_device(self.device) - self.driver.sonos:update_household_info(header.householdId, body, self.device) + self.driver.sonos:update_household_info(header.householdId, body, self.driver) + self.driver.sonos:update_device_record_from_state(header.householdId, self.device) local _, updated_coordinator = self.driver.sonos:get_coordinator_for_device(self.device) + local bonded = self.device:get_field(PlayerFields.BONDED) + if bonded then + self:stop() + end + Router.cleanup_unused_sockets(self.driver) - if not self:coordinator_running() then - --TODO this is not infallible - _open_coordinator_socket(self, household_id, player_id) - end + if not bonded then + if not self:coordinator_running() then + --TODO this is not infallible + _open_coordinator_socket(self, household_id, player_id) + end - if current_coordinator ~= updated_coordinator then - self:refresh_subscriptions() + if current_coordinator ~= updated_coordinator then + self:refresh_subscriptions() + end + else + self.device:offline() end elseif header.type == "playerVolume" then log.trace(string.format("PlayerVolume type message for %s", device_name)) @@ -224,15 +398,18 @@ function SonosConnection.new(driver, device) if body.volume and (body.muted ~= nil) then local household = self.driver.sonos:get_household(header.householdId) if household == nil or household.groups == nil then - log.error(st_utils.stringify_table( - {response_body = msg.data, household = household or header.householdId}, - "Received groupVolume message for non-existent household or household groups dont exist", false - )) + log.error( + st_utils.stringify_table( + { response_body = msg.data, household = household or header.householdId }, + "Received groupVolume message for non-existent household or household groups dont exist", + false + ) + ) return end - local group = household.groups[header.groupId] or { playerIds = {} } - for _, player_id in ipairs(group.playerIds) do - local device_for_player = self.driver._player_id_to_device[player_id] + local group = household.groups[header.groupId] or { player_ids = {} } + for _, player_id in ipairs(group.player_ids) do + local device_for_player = self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device --- is being deleted so we check for the presence of emit event as a proxy for --- whether or not this device is currently capable of emitting events. @@ -245,15 +422,18 @@ function SonosConnection.new(driver, device) log.trace(string.format("PlaybackStatus type message for %s", device_name)) local household = self.driver.sonos:get_household(header.householdId) if household == nil or household.groups == nil then - log.error(st_utils.stringify_table( - {response_body = msg.data, household = household or header.householdId}, - "Received playbackStatus message for non-existent household or household groups dont exist", false - )) + log.error( + st_utils.stringify_table( + { response_body = msg.data, household = household or header.householdId }, + "Received playbackStatus message for non-existent household or household groups dont exist", + false + ) + ) return end - local group = household.groups[header.groupId] or { playerIds = {} } - for _, player_id in ipairs(group.playerIds) do - local device_for_player = self.driver._player_id_to_device[player_id] + local group = household.groups[header.groupId] or { player_ids = {} } + for _, player_id in ipairs(group.player_ids) do + local device_for_player = self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device --- is being deleted so we check for the presence of emit event as a proxy for --- whether or not this device is currently capable of emitting events. @@ -265,15 +445,18 @@ function SonosConnection.new(driver, device) log.trace(string.format("MetadataStatus type message for %s", device_name)) local household = self.driver.sonos:get_household(header.householdId) if household == nil or household.groups == nil then - log.error(st_utils.stringify_table( - {response_body = msg.data, household = household or header.householdId}, - "Received metadataStatus message for non-existent household or household groups dont exist", false - )) + log.error( + st_utils.stringify_table( + { response_body = msg.data, household = household or header.householdId }, + "Received metadataStatus message for non-existent household or household groups dont exist", + false + ) + ) return end - local group = household.groups[header.groupId] or { playerIds = {} } - for _, player_id in ipairs(group.playerIds) do - local device_for_player = self.driver._player_id_to_device[player_id] + local group = household.groups[header.groupId] or { player_ids = {} } + for _, player_id in ipairs(group.player_ids) do + local device_for_player = self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device --- is being deleted so we check for the presence of emit event as a proxy for --- whether or not this device is currently capable of emitting events. @@ -286,22 +469,35 @@ function SonosConnection.new(driver, device) if body.version ~= favorites_version then favorites_version = body.version - local household = self.driver.sonos:get_household(header.householdId) or { groups = {} } + local household = self.driver.sonos:get_household(header.householdId) + or self.driver.sonos.EMPTY_HOUSEHOLD - for group_id, group in pairs(household.groups) do - local coordinator_id = self.driver.sonos:get_coordinator_for_group(header.householdId, group_id) + for group_id, group in pairs(household.groups or {}) do + local coordinator_id = + self.driver.sonos:get_coordinator_for_group(header.householdId, group_id) local coordinator_player = household.players[coordinator_id] if coordinator_player == nil then - log.error(st_utils.stringify_table( - {household = household, coordinator_id = coordinator_id}, "Received message for non-existent coordinator player", false - )) + log.error( + st_utils.stringify_table( + { household = household, coordinator_id = coordinator_id }, + "Received message for non-existent coordinator player", + false + ) + ) return end - local url_ip = lb_utils.force_url_table(coordinator_player.websocketUrl).host - + local url_table = lb_utils.force_url_table(coordinator_player.player.websocket_url) + local url_ip = url_table.host + local url_port = url_table.port or SonosApi.DEFAULT_SONOS_PORT + local base_url = lb_utils.force_url_table( + string.format("https://%s:%s", url_ip, url_port) + ) + local _, api_key = driver:check_auth(device) + local maybe_token = driver:get_oauth_token() + local headers = SonosApi.make_headers(api_key, maybe_token and maybe_token.accessToken) local favorites_response, err, _ = - SonosRestApi.get_favorites(url_ip, SonosApi.DEFAULT_SONOS_PORT, header.householdId) + SonosRestApi.get_favorites(base_url, header.householdId, headers) if err or not favorites_response then log.error("Error querying for favorites: " .. err) @@ -309,14 +505,19 @@ function SonosConnection.new(driver, device) local new_favorites = {} for _, favorite in ipairs(favorites_response.items or {}) do local new_item = { id = favorite.id, name = favorite.name } - if favorite.imageUrl then new_item.imageUrl = favorite.imageUrl end - if favorite.service and favorite.service.name then new_item.mediaSource = favorite.service.name end + if favorite.imageUrl then + new_item.imageUrl = favorite.imageUrl + end + if favorite.service and favorite.service.name then + new_item.mediaSource = favorite.service.name + end table.insert(new_favorites, new_item) end self.driver.sonos:update_household_favorites(header.householdId, new_favorites) - for _, player_id in ipairs(group.playerIds) do - local device_for_player = self.driver._player_id_to_device[player_id] + for _, player_id in ipairs(group.player_ids) do + local device_for_player = + self.driver:device_for_player(header.householdId, player_id) --- we've seen situations where these messages can be processed while a device --- is being deleted so we check for the presence of emit event as a proxy for --- whether or not this device is currently capable of emitting events. @@ -329,18 +530,28 @@ function SonosConnection.new(driver, device) end end else - log.warn(string.format("WebSocket Message for %s did not have a data payload: %s", device_name, st_utils.stringify_table(msg))) + log.warn( + string.format( + "WebSocket Message for %s did not have a data payload: %s", + device_name, + st_utils.stringify_table(msg) + ) + ) end end - self.on_error = function(uuid, err) + self.on_error = function(_, err) log.error(err or ("unknown websocket error for " .. (self.device.label or "unknown device"))) end - self.on_close = function(uuid) + self.on_close = function(_) log.debug(string.format("OnClose for %s", device_name)) - if self._initialized then self.device:offline() end - if self._keepalive then _spawn_reconnect_task(self) end + if self._initialized then + self.device:offline() + end + if self._keepalive then + _spawn_reconnect_task(self) + end end return self @@ -351,40 +562,80 @@ end function SonosConnection:is_running() local self_running = self:self_running() local coord_running = self:coordinator_running() - log.debug(string.format("%s all connections running? %s", self.device.label, st_utils.stringify_table({coordinator = self_running, mine = self_running}))) - return self_running and coord_running + log.debug( + string.format( + "%s all connections running? %s", + self.device.label, + st_utils.stringify_table({ coordinator = self_running, mine = self_running }) + ) + ) + return self_running and coord_running end --- Whether or not the connection has a live websocket connection --- @return boolean function SonosConnection:self_running() - return Router.is_connected(self.device:get_field(PlayerFields.PLAYER_ID)) and self._initialized + local household_id = self.device:get_field(PlayerFields.HOUSEHOLD_ID) + local player_id = self.device:get_field(PlayerFields.PLAYER_ID) + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, player_id) + if bad_key_part then + self.device.log.warn( + string.format( + "Bad Unique Key Part While Inspecting Connections in 'self_running': %s", + bad_key_part + ) + ) + end + return type(unique_key) == "string" and Router.is_connected(unique_key) and self._initialized end --- Whether or not the connection has a live websocket connection to its coordinator --- @return boolean function SonosConnection:coordinator_running() - local _, coordinator_id = self.driver.sonos:get_coordinator_for_device(self.device) - return Router.is_connected(coordinator_id) and self._initialized + local household_id, coordinator_id = self.driver.sonos:get_coordinator_for_device(self.device) + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, coordinator_id) + if bad_key_part then + self.device.log.warn( + string.format( + "Bad Unique Key Part While Inspecting Connections in 'coordinator_running': %s", + bad_key_part + ) + ) + end + return type(unique_key) == "string" and Router.is_connected(unique_key) end -function SonosConnection:refresh_subscriptions() +function SonosConnection:refresh_subscriptions(maybe_reply_tx) log.debug("Refresh subscriptions on " .. self.device.label) _update_self_subscriptions(self, self_subscriptions, "subscribe") _update_coordinator_subscriptions(self, coordinator_subscriptions, "subscribe") + if maybe_reply_tx then + maybe_reply_tx:send("success") + end end --- Send a Sonos command object to the player for this connection --- @param cmd SonosCommand -function SonosConnection:send_command(cmd) +--- @param use_coordinator boolean +function SonosConnection:send_command(cmd, use_coordinator) log.debug("Sending command over websocket channel for device " .. self.device.label) - local _, coordinator_id = self.driver.sonos:get_coordinator_for_device(self.device) + local household_id, target_id + if use_coordinator then + household_id, target_id = self.driver.sonos:get_coordinator_for_device(self.device) + else + household_id, target_id = self.driver.sonos:get_player_for_device(self.device) + end local json_payload, err = json.encode(cmd) if err or not json_payload then log.error("Json encoding error: " .. err) else - Router.send_message_to_player(coordinator_id, json_payload) + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, target_id) + if not unique_key then + self.device.log.error(string.format("Invalid Sonos Unique Key Part: %s", bad_key_part)) + return + end + Router.send_message_to_player(unique_key, json_payload) end end @@ -394,46 +645,85 @@ function SonosConnection:start() if self:is_running() then log.warn( "Called Start on a Sonos WebSocket that was already connected for device " - .. self.device.label + .. self.device.label ) return false end log.debug(string.format("Starting SonosConnection for %s", self.device.label)) - local household_id = self.device:get_field(PlayerFields.HOUSEHOULD_ID) + local household_id = self.device:get_field(PlayerFields.HOUSEHOLD_ID) local player_id = self.device:get_field(PlayerFields.PLAYER_ID) + local auth_success, api_key = self.driver:check_auth(self.device) + + if not auth_success then + self.device:offline() + _spawn_reconnect_task(self) + return false + end + if not self:self_running() then local url = self.device:get_field(PlayerFields.WSS_URL) or {} - - local _, err = Router.open_socket_for_player(player_id, url) + local _, err = Router.open_socket_for_player(household_id, player_id, url, api_key) if err ~= nil then log.error(err) return false end - local listener_id, err = Router.register_listener_for_socket(self, player_id) - if err ~= nil or not listener_id then - log.error(err) + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, player_id) + if bad_key_part then + self.device.log.error(string.format("Invalid Sonos Unique Key Part: %s", bad_key_part)) else - self._self_listener_uuid = listener_id + local listener_id, register_listener_err = + Router.register_listener_for_socket(self, unique_key) + if register_listener_err ~= nil or not listener_id then + log.error(register_listener_err) + else + self._self_listener_uuid = listener_id + end end end if not self:coordinator_running() then --TODO this is not infallible - _open_coordinator_socket(self, household_id, player_id) + _open_coordinator_socket(self, household_id, player_id, api_key) end self:refresh_subscriptions() local coordinator_id = self.driver.sonos:get_coordinator_for_player(household_id, player_id) - if Router.is_connected(player_id) and Router.is_connected(coordinator_id) then + local self_unique_key, self_bad_key_part = utils.sonos_unique_key(household_id, player_id) + local coordinator_unique_key, coordinator_bad_key_part = + utils.sonos_unique_key(household_id, coordinator_id) + if + self_unique_key + and Router.is_connected(self_unique_key) + and coordinator_unique_key + and Router.is_connected(coordinator_unique_key) + then self.device:online() self._initialized = true self._keepalive = true return true end + if self_bad_key_part then + self.device.log.error( + string.format( + "Invalid Unique Key Part for 'self' Player during connection bring-up: %s", + self_bad_key_part + ) + ) + end + + if coordinator_bad_key_part then + self.device.log.error( + string.format( + "Invalid Unique Key Part for 'coordinator' Player during connection bring-up: %s", + coordinator_bad_key_part + ) + ) + end + return false end @@ -447,7 +737,12 @@ function SonosConnection:stop() local coordinator_id = self.driver.sonos:get_coordinator_for_group(household_id, group_id) if player_id ~= coordinator_id then - Router.close_socket_for_player(player_id) + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, player_id) + if not unique_key then + self.device.log.error(string.format("Invalid Sonos Unique Key Part: %s", bad_key_part)) + return + end + Router.close_socket_for_player(unique_key) end end diff --git a/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua new file mode 100644 index 0000000000..21cbdcd62c --- /dev/null +++ b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua @@ -0,0 +1,474 @@ +local cosock = require "cosock" +local log = require "log" +local net_url = require "net.url" +local ssdp = require "ssdp" +local st_utils = require "st.utils" + +local utils = require "utils" + +local SonosApi = require "api" +local SSDP_SCAN_INTERVAL_SECONDS = 600 + +local SONOS_DEFAULT_PORT = 1443 +local SONOS_DEFAULT_WSS_PATH = "websocket/api" +local SONOS_DEFAULT_REST_PATH = "api/v1" + +--- Cached information gathered during discovery scanning, created from a subset of the +--- found [SonosSSDPInfo](lua://SonosSSDPInfo) and [SonosDiscoveryInfoObject](lua://SonosDiscoveryInfoObject) +--- +--- @class SpeakerDiscoveryInfo +--- @field public unique_key UniqueKey +--- @field public mac_addr string +--- @field public expires_at integer +--- @field public ipv4 string +--- @field public port integer +--- @field public household_id HouseholdId +--- @field public player_id PlayerId +--- @field public name string +--- @field public model string +--- @field public model_display_name string +--- @field public sw_gen integer +--- @field public wss_url table +--- @field public rest_url table +--- @field public is_group_coordinator boolean +--- @field public group_name string? nil if a speaker is the non-primary in a bonded set +--- @field public group_id GroupId? nil if a speaker is the non-primary in a bonded set +--- @field package wss_path string? nil if equivalent to the default value; does not include leading slash! +--- @field package rest_path string? nil if equivalent to the default value; does not include leading slash! +local SpeakerDiscoveryInfo = {} + +local proxy_index = function(self, k) + if k == "rest_url" and not rawget(self, "rest_url") then + rawset( + self, + "rest_url", + net_url.parse( + string.format( + "https://%s:%s/%s", + self.ipv4, + self.port, + self.rest_path or SONOS_DEFAULT_REST_PATH + ) + ) + ) + end + + if k == "wss_url" and not rawget(self, "wss_url") then + rawset( + self, + "wss_url", + net_url.parse( + string.format( + "https://%s:%s/%s", + self.ipv4, + self.port, + self.wss_path or SONOS_DEFAULT_WSS_PATH + ) + ) + ) + end + + return rawget(self, k) +end + +local proxy_newindex = function(_, _, _) + error("attempt to index a read-only table", 2) +end + +---@param ssdp_info SonosSSDPInfo +---@param discovery_info SonosDiscoveryInfoObject +---@return SpeakerDiscoveryInfo info +function SpeakerDiscoveryInfo.new(ssdp_info, discovery_info) + local mac_addr = utils.extract_mac_addr(discovery_info.device) + local port, rest_path = string.match(discovery_info.restUrl, "^.*:(%d*)/(.*)$") + local _, wss_path = string.match(discovery_info.websocketUrl, "^.*:(%d*)/(.*)$") + port = tonumber(port) or SONOS_DEFAULT_PORT + + local ret = { + unique_key = utils.sonos_unique_key_from_ssdp(ssdp_info), + expires_at = ssdp_info.expires_at, + ipv4 = ssdp_info.ip, + port = port, + mac_addr = mac_addr, + household_id = ssdp_info.household_id, + player_id = ssdp_info.player_id, + name = discovery_info.device.name, + model = discovery_info.device.model, + model_display_name = discovery_info.device.modelDisplayName, + sw_gen = discovery_info.device.swGen, + is_group_coordinator = ssdp_info.is_group_coordinator, + } + + if type(ssdp_info.group_name) == "string" and #ssdp_info.group_name > 0 then + ret.group_name = ssdp_info.group_name + end + + if type(ssdp_info.group_id) == "string" and #ssdp_info.group_id > 0 then + ret.group_id = ssdp_info.group_id + end + + if type(wss_path) == "string" and #wss_path > 0 and wss_path ~= SONOS_DEFAULT_WSS_PATH then + ret.wss_path = wss_path + end + + if type(rest_path) == "string" and #rest_path > 0 and rest_path ~= SONOS_DEFAULT_REST_PATH then + ret.rest_path = rest_path + end + + for k, v in pairs(SpeakerDiscoveryInfo or {}) do + rawset(ret, k, v) + end + + return setmetatable(ret, { __index = proxy_index, __newindex = proxy_newindex }) +end + +function SpeakerDiscoveryInfo:is_bonded() + return (self.group_id == nil) +end + +---@return SonosSSDPInfo +function SpeakerDiscoveryInfo:as_ssdp_info() + ---@type SonosSSDPInfo + return { + ip = self.ipv4, + group_id = self.group_id or "", + group_name = self.group_name or "", + expires_at = self.expires_at, + player_id = self.player_id, + wss_url = self.wss_url:build(), + household_id = self.household_id, + is_group_coordinator = self.is_group_coordinator, + } +end + +local sonos_ssdp = {} +sonos_ssdp.SpeakerDiscoveryInfo = SpeakerDiscoveryInfo + +---@module 'luncheon.headers' + +---@param response Headers +---@return boolean is_valid +local function validate_sonos_ssdp_response(response) + local ip = response:get_one("location"):match("http://([^,/]+):[^/]+/.+%.xml") + local is_group_coordinator, group_id, group_name = + response:get_one("groupinfo.smartspeaker.audio"):match('gc=(.*); gid=(.*); gname="(.*)"') + local household_id = response:get_one("household.smartspeaker.audio") + local wss_url = response:get_one("websock.smartspeaker.audio") + local expires_in = response:get_one("cache-control"):match("max%-age%s*=%s*(%d*)") + local has_server = response:get_one("server"):find("Sonos") ~= nil + local player_id, redundant_st = response:get_one("usn"):match("uuid:([^:]*)::(urn:.*)$") + return has_server + and ip + and is_group_coordinator + and group_id + and group_name + and household_id + and wss_url + and expires_in + and tonumber(expires_in) + and player_id + and (redundant_st == sonos_ssdp.SearchTerm) + and true +end + +---We know that these matches have already been validated so we don't need nil checking. +---@param response Headers +---@return SonosSSDPInfo +local function post_process_response(response) + local ip = response:get_one("location"):match("http://([^,/]+):[^/]+/.+%.xml") + local is_group_coordinator, group_id, group_name = + response:get_one("groupinfo.smartspeaker.audio"):match('gc=(.*); gid=(.*); gname="(.*)"') + local household_id = response:get_one("household.smartspeaker.audio") + local wss_url = response:get_one("websock.smartspeaker.audio") + local player_id = response:get_one("usn"):match("uuid:([^:]*)::(urn:.*)$") + local expires_in = tonumber(response:get_one("cache-control"):match("max%-age%s*=%s*(%d*)")) + ---@type SonosSSDPInfo + local ret = setmetatable({ + ip = ip, + is_group_coordinator = (tonumber(is_group_coordinator) == 1), + group_id = group_id, + group_name = group_name, + household_id = household_id, + wss_url = wss_url, + expires_at = os.time() + expires_in, + player_id = player_id, + }, { + __tostring = function(tbl) + return st_utils.stringify_table(tbl, nil, true) + end, + }) + return ret +end + +---@enum _ControlMessageType +local _ControlMessageType = { + SEARCH = 0, +} + +---@param ssdp_search_handle SsdpSearchHandle +---@param control_rx table +---@param status_tx table +---@param on_search_result_cb fun(result: SonosSSDPInfo) +---@return function +local function make_persistent_task_impl( + ssdp_search_handle, + control_rx, + status_tx, + on_search_result_cb +) + return function() + ssdp_search_handle:multicast_m_search() + local interval_timer = cosock.timer.create_interval(SSDP_SCAN_INTERVAL_SECONDS) + while true do + local recv_ready, _, select_err = + cosock.socket.select({ ssdp_search_handle, control_rx, interval_timer }) + + if type(recv_ready) ~= "table" or (select_err and select_err ~= "timeout") then + log.warn(string.format("Select error: %s", select_err)) + end + + for _, receiver in ipairs(recv_ready or {}) do + if receiver == interval_timer then + interval_timer:handled() + ssdp_search_handle:multicast_m_search() + elseif receiver == control_rx then + local recv, recv_err = receiver:receive() + if not recv then + log.warn(string.format("control channel receive error: %s", recv_err)) + else + if recv._type == _ControlMessageType.SEARCH then + ssdp_search_handle:multicast_m_search() + end + end + end + + if receiver == ssdp_search_handle then + local response = ssdp_search_handle:next_msearch_response() + if response then + if response:is_ok() then + local new_info = response:unwrap() + on_search_result_cb(new_info) + end + end + end + end + end + end +end + +sonos_ssdp.SearchTerm = "urn:smartspeaker-audio:service:SpeakerGroup:1" +sonos_ssdp.required_headers = { + "usn", + "server", + "location", + "groupinfo.smartspeaker.audio", + "websock.smartspeaker.audio", + "household.smartspeaker.audio", +} + +---compares two SSDP Info tables, ignoring their expiration time tags that might be different +---@param a SonosSSDPInfo +---@param b SonosSSDPInfo +---@return boolean is_eq +function sonos_ssdp.ssdp_info_eq(a, b) + return (a.group_id == b.group_id) + and (a.group_name == b.group_name) + and (a.household_id == b.household_id) + and (a.ip == b.ip) + and (a.is_group_coordinator == b.is_group_coordinator) + and (a.player_id == b.player_id) + and (a.wss_url == b.wss_url) +end + +---@param disco_info SpeakerDiscoveryInfo +---@param ssdp_info SonosSSDPInfo +function sonos_ssdp.known_speaker_matches_ssdp_info(disco_info, ssdp_info) + return (disco_info.group_id == ssdp_info.group_id) + and (disco_info.group_name == ssdp_info.group_name) + and (disco_info.household_id == ssdp_info.household_id) + and (disco_info.ipv4 == ssdp_info.ip) + and (disco_info.is_group_coordinator == ssdp_info.is_group_coordinator) + and (disco_info.player_id == ssdp_info.player_id) + and (disco_info.wss_url:build() == ssdp_info.wss_url) +end + +---@return SsdpSearchTerm the Sonos ssdp search term +---@return SsdpSearchKwargs the default set of keyword arguments for Sonos +function sonos_ssdp.new_search_term_context() + return sonos_ssdp.SearchTerm, + { + required_headers = sonos_ssdp.required_headers, + validator = validate_sonos_ssdp_response, + post_processor = post_process_response, + } +end + +---@class SonosPersistentSsdpTask +---@field package ssdp_search_handle SsdpSearchHandle +---@field package player_info_by_sonos_ids table +---@field package player_info_by_mac_addrs table +---@field package waiting_for_unique_key table +---@field package waiting_for_mac_addr table +---@field package control_tx table +---@field package status_rx table +---@field package event_bus cosock.Bus.Sender +---@field package cosock_thread_handle table +local SonosPersistentSsdpTask = {} +SonosPersistentSsdpTask.__index = SonosPersistentSsdpTask +sonos_ssdp.SonosPersistentSsdpTask = SonosPersistentSsdpTask + +function SonosPersistentSsdpTask:get_current_mx_seconds() + return self.ssdp_search_handle:get_current_mx_seconds() +end + +function SonosPersistentSsdpTask:refresh() + self.control_tx:send({ + _type = _ControlMessageType.SEARCH, + }) +end + +function SonosPersistentSsdpTask:get_all_known() + -- make a shallow copy of the table so it doesn't get clobbered + -- the player info itself is a read-only proxy table as well + local known = {} + for id, info in pairs(self.player_info_by_sonos_ids or {}) do + known[id] = info + end + return known +end + +---@param reply_tx table +---@param ... unknown +---@override fun(self: SonosPersistentSsdpTask, reply_tx: table, mac_addr: string) +---@override fun(self: SonosPersistentSsdpTask, reply_tx: table, household_id: string, player_id: string) +function SonosPersistentSsdpTask:get_player_info(reply_tx, ...) + local household_id_or_mac = select(1, ...) + local player_id = select(2, ...) + + local wait_table_key, lookup_table, lookup_key, bad_key_part + + if player_id ~= nil and type(player_id) == "string" then + wait_table_key = "waiting_for_unique_key" + lookup_table = self.player_info_by_sonos_ids + lookup_key, bad_key_part = utils.sonos_unique_key(household_id_or_mac, player_id) + else + wait_table_key = "waiting_for_mac_addr" + lookup_table = self.player_info_by_mac_addrs + lookup_key = household_id_or_mac + end + + if not lookup_key and bad_key_part then + log.error(string.format("Invalid Unique Key Part: %s", bad_key_part)) + end + + local maybe_existing = lookup_table[lookup_key] + if maybe_existing and maybe_existing.expires_at > os.time() then + reply_tx:send(maybe_existing) + return + end + + local waiting_for_player = self[wait_table_key][lookup_key] or {} + table.insert(waiting_for_player, reply_tx) + self[wait_table_key][lookup_key] = waiting_for_player + self:refresh() +end + +function SonosPersistentSsdpTask:subscribe() + return self.event_bus:subscribe() +end + +function SonosPersistentSsdpTask:publish(msg) + self.event_bus:send(msg) +end + +---@return SonosPersistentSsdpTask? task nil on failure to spawn task +---@return string? err nil on success +function sonos_ssdp.spawn_persistent_ssdp_task() + local control_tx, control_rx = cosock.channel.new() + local status_tx, status_rx = cosock.channel.new() + local event_bus = cosock.bus() + -- use the default MX value + local ssdp_search_handle, make_handle_err = ssdp.new_search_instance() + if not ssdp_search_handle then + return nil, string.format("start search error: %s", make_handle_err) + end + ssdp_search_handle:register_search_term(sonos_ssdp.new_search_term_context()) + + ---@type SonosPersistentSsdpTask + local task_handle = setmetatable({ + ssdp_search_handle = ssdp_search_handle, + player_info_by_sonos_ids = {}, + player_info_by_mac_addrs = utils.new_mac_address_keyed_table(), + waiting_for_unique_key = {}, + waiting_for_mac_addr = utils.new_mac_address_keyed_table(), + control_tx = control_tx, + status_rx = status_rx, + event_bus = event_bus, + }, SonosPersistentSsdpTask) + + ---@type fun(sonos_ssdp_info: SonosSSDPInfo) + local callback = function(sonos_ssdp_info) + local unique_key = utils.sonos_unique_key_from_ssdp(sonos_ssdp_info) + local maybe_known = task_handle.player_info_by_sonos_ids[unique_key] + local is_new_information = not ( + maybe_known + and maybe_known.expires_at > os.time() + and sonos_ssdp.known_speaker_matches_ssdp_info(maybe_known, sonos_ssdp_info) + ) + + local speaker_info + local event_bus_msg + + if is_new_information then + local headers = SonosApi.make_headers() + local parsed_wss_url = net_url.parse(sonos_ssdp_info.wss_url) or {} + local base_url = net_url.parse( + string.format("https://%s:%s", parsed_wss_url.host, parsed_wss_url.port or SonosApi.DEFAULT_SONOS_PORT) + ) + local discovery_info, err = SonosApi.RestApi.get_player_info(base_url, headers) + if not discovery_info then + log.error(string.format("Error getting discovery info from SSDP response: %s", err)) + elseif discovery_info._objectType == "globalError" then + log.error(string.format("Error message in discovery info: %s", discovery_info.errorCode)) + else + speaker_info = SpeakerDiscoveryInfo.new(sonos_ssdp_info, discovery_info) + task_handle.player_info_by_sonos_ids[unique_key] = speaker_info + event_bus_msg = { speaker_info = speaker_info, force_refresh = true } + end + else + speaker_info = maybe_known + event_bus_msg = { speaker_info = speaker_info, force_refresh = false } + end + + if speaker_info then + event_bus:send(event_bus_msg) + local mac_addr = speaker_info.mac_addr + local waiting_handles = task_handle.waiting_for_unique_key[unique_key] or {} + + log.debug(st_utils.stringify_table(waiting_handles, "waiting for unique keys", true)) + for _, v in pairs(task_handle.waiting_for_mac_addr[mac_addr] or {}) do + table.insert(waiting_handles, v) + end + + log.debug( + st_utils.stringify_table(waiting_handles, "waiting for unique keys and mac addresses", true) + ) + for _, reply_tx in ipairs(waiting_handles or {}) do + reply_tx:send(speaker_info) + end + + task_handle.waiting_for_unique_key[unique_key] = {} + task_handle.waiting_for_mac_addr[mac_addr] = {} + end + end + + local thread = cosock.spawn( + make_persistent_task_impl(ssdp_search_handle, control_rx, status_tx, callback), + "Sonos Persistent SSDP Task" + ) + task_handle.cosock_thread_handle = thread + + return task_handle, nil +end + +return sonos_ssdp diff --git a/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua b/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua index 962536488a..c19e35274b 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua @@ -1,7 +1,7 @@ -local log = require "log" +local channel = require "cosock.channel" local cosock = require "cosock" +local log = require "log" local socket = require "cosock.socket" -local channel = require "cosock.channel" local ssl = require "cosock.ssl" local LustreConfig = require "lustre".Config local WebSocket = require "lustre".WebSocket @@ -10,11 +10,14 @@ local CloseCode = require "lustre.frame.close".CloseCode local lb_utils = require "lunchbox.util" local st_utils = require "st.utils" +local SonosApi = require "api" +local utils = require "utils" + --- A "singleton" module that maintains all of the websockets for a --- home's Sonos players. A player as modeled in the driver will have --- its own connection but it might not use that for all commands, --- since it could be part of a group and need to speak to its coordinator. ---- @module 'api.sonos_websocket_router' +--- @class SonosWebSocketRouter local SonosWebSocketRouter = {} local control_tx, control_rx = channel.new() control_rx:settimeout(0.5) @@ -25,29 +28,39 @@ control_rx:settimeout(0.5) ---@alias ListenerUuid string ---@alias Listener table ---- @type table +--- @type table local websockets = {} --- @type table local listener_ids_for_socket = {} --- @type table local listeners = {} ---- @type PlayerId[] +--- @type UniqueKey[] local pending_close = {} cosock.spawn(function() while true do - for _, player_id in ipairs(pending_close) do -- close any sockets pending close before selecting/receiving on them - local wss = websockets[player_id] + for _, unique_key in ipairs(pending_close or {}) do -- close any sockets pending close before selecting/receiving on them + local wss = websockets[unique_key] if wss ~= nil then - log.trace(string.format("Closing websocket for player_id %s", player_id)) + log.trace(string.format("Closing websocket for player %s", unique_key)) wss:close(CloseCode.normal(), "Shutdown requested by client") + local ws_id = wss.id + for _, uuid in ipairs((listener_ids_for_socket[ws_id] or {})) do + local listener = listeners[uuid] + + if listener ~= nil then + listener.on_close(uuid) + end + listeners[uuid] = nil + end + listener_ids_for_socket[ws_id] = nil end - websockets[player_id] = nil + websockets[unique_key] = nil end pending_close = {} local socks = { control_rx } - for _, wss in pairs(websockets) do + for _, wss in pairs(websockets or {}) do table.insert(socks, wss) end local receivers, _, err = socket.select(socks, nil, 10) @@ -58,7 +71,7 @@ cosock.spawn(function() log.error("Error in Websocket Router event loop: " .. err) end else - for _, recv in ipairs(receivers) do + for _, recv in ipairs(receivers or {}) do if recv.link and recv.link.queue and #recv.link.queue == 0 then -- workaround a bug in receiving log.warn("attempting to receive on empty channel") goto continue @@ -80,9 +93,9 @@ cosock.spawn(function() elseif err == "closed" and recv.id then -- closed websocket log.trace(string.format("Websocket %s closed", tostring(recv.id))) local still_open_sockets = {} - for player_id, wss in pairs(websockets) do + for unique_key, wss in pairs(websockets or {}) do if wss.id ~= recv.id then - still_open_sockets[player_id] = wss + still_open_sockets[unique_key] = wss end end websockets = still_open_sockets @@ -102,12 +115,21 @@ cosock.spawn(function() local wss = websockets[target] if wss == nil then --TODO is this silencing a crash that is an indication of a state management bug in the run loop? - log.error(st_utils.stringify_table({msg = msg}, "Coordinator doesn't exist for player", false)) + log.error( + st_utils.stringify_table( + { msg = msg }, + "Coordinator doesn't exist for player", + false + ) + ) goto continue end log.trace(string.format("Sending message over websocket for target %s", target)) - wss:send(Message.new(Message.TEXT, msg.body)) + local response = table.pack(wss:send(Message.new(Message.TEXT, msg.body))) + if msg.header.reply_tx then + msg.header.reply_tx:send(response) + end end elseif msg.type and msg.data and recv.id then -- websocket message received log.trace(string.format("Received WebSocket message, fanning out to listeners")) @@ -116,7 +138,12 @@ cosock.spawn(function() if listener ~= nil then if listener.device and listener.device.label then - log.debug(string.format("SonosConnection for device %s handling websocket message", listener.device.label)) + log.debug( + string.format( + "SonosConnection for device %s handling websocket message", + listener.device.label + ) + ) end listener.on_message(uuid, msg) end @@ -129,66 +156,92 @@ cosock.spawn(function() end end end -end) +end, "Sonos Websocket Router Task") --- @param url_table table +--- @param api_key string --- @return WebSocket|nil --- @return nil|string error -local function _make_websocket(url_table) - local sock, err = socket.tcp() - if not sock or err ~= nil then return nil, "Could not open TCP socket: " .. err end - local _, err = sock:settimeout(3) - if err ~= nil then return nil, "Could not set TCP socket timeout: " .. err end - - log.trace(string.format( - "Opening up websocket connection for host/port %s %s", - url_table.host, url_table.port)) +local function _make_websocket(url_table, api_key) + local sock, make_socket_err = socket.tcp() + if not sock or make_socket_err ~= nil then + return nil, "Could not open TCP socket: " .. make_socket_err + end + local _, sock_operation_err = sock:settimeout(3) + if sock_operation_err ~= nil then + return nil, "Could not set TCP socket timeout: " .. sock_operation_err + end - _, err = sock:connect(url_table.host, url_table.port) - if err ~= nil then return nil, "Socket connect error: " .. err end + log.trace( + string.format( + "Opening up websocket connection for host/port %s %s", + url_table.host, + url_table.port + ) + ) + + _, sock_operation_err = sock:connect(url_table.host, url_table.port) + if sock_operation_err ~= nil then + return nil, "Socket connect error: " .. sock_operation_err + end - _, err = sock:setoption("keepalive", true) - if err ~= nil then return nil, "Socket set keepalive error: " .. err end + _, sock_operation_err = sock:setoption("keepalive", true) + if sock_operation_err ~= nil then + return nil, "Socket set keepalive error: " .. sock_operation_err + end - sock, err = ssl.wrap(sock, { + sock, sock_operation_err = ssl.wrap(sock, { mode = "client", protocol = "any", verify = "none", - options = "all" + options = "all", }) - if err ~= nil then return nil, "SSL Wrap error: " .. err end + if sock_operation_err ~= nil then + return nil, "SSL Wrap error: " .. sock_operation_err + end - _, err = sock:dohandshake() - if err ~= nil then return nil, "SSL Handhsake error: " .. err end + _, sock_operation_err = sock:dohandshake() + if sock_operation_err ~= nil then + return nil, "SSL Handhsake error: " .. sock_operation_err + end - --- SONOS_API_KEY is a Global added to the environment in the root init.lua. - --- This API key is injected in to the driver at deploy time for production. - --- To use your own API key, add an `app_key.lua` to the `src` - --- directory and have the only code be to `return "YOUR_API_KEY"` - local config = LustreConfig.default():header("X-Sonos-Api-Key", SONOS_API_KEY) - :protocol("v1.api.smartspeaker.audio") + local headers = SonosApi.make_headers(api_key) + local config = LustreConfig.default():protocol("v1.api.smartspeaker.audio") + for k, v in pairs(headers or {}) do + config = config:header(k, v) + end local wss = WebSocket.client(sock, url_table.path, config) - _, err = wss:client_handshake_and_start(url_table.host, url_table.port) - if err ~= nil then return nil, "Error starting websocket: " .. err end + _, sock_operation_err = wss:client_handshake_and_start(url_table.host, url_table.port) + if sock_operation_err ~= nil then + return nil, "Error starting websocket: " .. sock_operation_err + end return wss end -function SonosWebSocketRouter.is_connected(player_id) - local wss = websockets[player_id] +---@param unique_key UniqueKey +---@return boolean +function SonosWebSocketRouter.is_connected(unique_key) + local wss = websockets[unique_key] return wss ~= nil and wss.state ~= nil and wss.state == "Active" end -function SonosWebSocketRouter.register_listener_for_socket(listener, - player_id_for_socket) +---@param listener any +---@param unique_key_for_socket UniqueKey +---@return string? +---@return string? +function SonosWebSocketRouter.register_listener_for_socket(listener, unique_key_for_socket) if listener and listener.device and listener.device.label then - log.debug("Registering SonosConnection for device %s as listener for player_id's %s websocket", listener.device.label, player_id_for_socket) + log.debug( + "Registering SonosConnection for device %s as listener for player %s websocket", + listener.device.label, + unique_key_for_socket + ) end - local ws = websockets[player_id_for_socket] + local ws = websockets[unique_key_for_socket] if ws ~= nil then - ws._player_id = player_id_for_socket local uuid = st_utils.generate_uuid_v4() local listener_ids = listener_ids_for_socket[ws.id] or {} @@ -199,47 +252,49 @@ function SonosWebSocketRouter.register_listener_for_socket(listener, return uuid else - return nil, "Cannot register listener; no websocket opened for " .. player_id_for_socket + return nil, "Cannot register listener; no websocket opened for " .. unique_key_for_socket end - end --- Open a websocket connection with the given look-up information +--- @param household_id HouseholdId --- @param player_id PlayerId --- @param wss_url string|table +--- @param api_key string --- @return boolean|nil success true on success, nil otherwise --- @return nil|string error the error message in the failure case -function SonosWebSocketRouter.open_socket_for_player(player_id, wss_url) - if not websockets[player_id] then - log.debug("Opening websocket for player id " .. player_id) +function SonosWebSocketRouter.open_socket_for_player(household_id, player_id, wss_url, api_key) + local unique_key, bad_key_part = utils.sonos_unique_key(household_id, player_id) + if not websockets[unique_key] then + log.debug("Opening websocket for player id " .. unique_key) local url_table = lb_utils.force_url_table(wss_url) - local wss, err = _make_websocket(url_table) + local wss, err = _make_websocket(url_table, api_key) if err or not wss then - return nil, string.format( - "Could not create websocket connection for %s: %s", player_id, - err) + return nil, string.format("Could not create websocket connection for %s: %s", unique_key, err) + elseif not unique_key then + return nil, string.format("Invalid Sonos Unique Key Part: %s", bad_key_part) else - websockets[player_id] = wss + websockets[unique_key] = wss return true end else - log.debug("Websocket already open for " .. player_id) + log.debug("Websocket already open for " .. unique_key) return true end end -function SonosWebSocketRouter.send_message_to_player(target, json_payload) +function SonosWebSocketRouter.send_message_to_player(target, json_payload, reply_tx) local websocket_message = { - header = { type = "WebSocket", target = target }, - body = json_payload + header = { type = "WebSocket", target = target, reply_tx = reply_tx }, + body = json_payload, } control_tx:send(websocket_message) end --- Close a websocket connection with the given look-up information ---- @param target PlayerId +--- @param target UniqueKey --- @return boolean|nil success true on success, nil otherwise --- @return nil|string error the error message in the failure case function SonosWebSocketRouter.close_socket_for_player(target) @@ -247,17 +302,7 @@ function SonosWebSocketRouter.close_socket_for_player(target) local ws = websockets[target] if ws ~= nil then - local ws_id = ws.id table.insert(pending_close, target) - for _, uuid in ipairs((listener_ids_for_socket[ws_id] or {})) do - local listener = listeners[uuid] - - if listener ~= nil then - listener.on_close(uuid) - end - listeners[uuid] = nil - end - listener_ids_for_socket[ws_id] = nil return true else return nil, string.format("No currently open connection for %s", target) @@ -268,25 +313,36 @@ end function SonosWebSocketRouter.cleanup_unused_sockets(driver) log.trace("Begin cleanup of unused websockets") local should_keep = {} - for player_id, _ in pairs(websockets) do - local is_joined = driver.sonos:is_player_joined(player_id) - log.debug(string.format("Is PlayerID %s joined? %s", player_id, is_joined)) - should_keep[player_id] = is_joined + for unique_key, _ in pairs(websockets or {}) do + local household_id, player_id = unique_key:match("(.*)/(.*)") + local is_joined = driver.sonos:get_device_id_for_player(household_id, player_id) ~= nil + log.debug(string.format("Is Player %s joined? %s", player_id, is_joined)) + should_keep[unique_key] = is_joined end local known_devices = driver:get_devices() - for _, device in ipairs(known_devices) do - local _, coordinator_id = driver.sonos:get_coordinator_for_device(device) - if should_keep[coordinator_id] == false then -- looking for false specifically, not nil + for _, device in ipairs(known_devices or {}) do + local household_id, coordinator_id = driver.sonos:get_coordinator_for_device(device) + local coordinator_unique_key, bad_key_part = + utils.sonos_unique_key(household_id, coordinator_id) + if bad_key_part then + log.warn( + string.format( + "Invalid Sonos Unique Key Part while cleaning up unused websockets: %s", + bad_key_part + ) + ) + end + if coordinator_unique_key and should_keep[coordinator_unique_key] == false then -- looking for false specifically, not nil log.trace("Preserving coordinator socket " .. coordinator_id) - should_keep[coordinator_id] = true + should_keep[coordinator_unique_key] = true end end - for id, keep in pairs(should_keep) do + for unique_key, keep in pairs(should_keep or {}) do if not keep then - SonosWebSocketRouter.close_socket_for_player(id) + SonosWebSocketRouter.close_socket_for_player(unique_key) end end end diff --git a/drivers/SmartThings/sonos/src/cosock/bus.lua b/drivers/SmartThings/sonos/src/cosock/bus.lua new file mode 100644 index 0000000000..f0a248753c --- /dev/null +++ b/drivers/SmartThings/sonos/src/cosock/bus.lua @@ -0,0 +1,227 @@ +---@diagnostic disable: duplicate-doc-alias, duplicate-doc-field, invisible, duplicate-set-field +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +---@package +---@class cosock.Bus.SubscriptionImpl +local __receiver_mt = {} + +---@package +---@class cosock.Bus.SenderImpl +local __sender_mt = {} +local __closed_mt = {} + +---@package +---@alias ReceiverId integer + +---State that describes the link between an individual receiver handle and the +---singular send/broadcast handle. +---@package +---@class cosock.Bus.Link +---A reference to the receiver associated with this link +---@field package receiver cosock.Bus.Subscription +---The mesage queue for the sender +---@field package queue any[] +---The receiver's cosock waker, `nil` if the receiver is not currently pending +---@field package waker fun()? + +---@package +---@class cosock.Bus.Inner +---whether the bus is currently closed. A closed bus will return `nil, "closed"` for +---all operations on any senders or receivers. +---@field package closed boolean +---The set of active/not-closed [Links](lua://cosock.Bus.Link). +---@field package receiver_links table +---Counter for managing generation of [Receiver ID's](lua://ReceiverId) +---@field package next_receiver_id ReceiverId + +---The send/broadcast side of the bus. +---@class cosock.Bus.Sender: cosock.Bus.SenderImpl +---The interior shared state for managing the link between the sender and all receivers +---@field package _bus_inner cosock.Bus.Inner + +---The receive side of the sender/broadcaster for the bus. +---@class cosock.Bus.Subscription: cosock.Bus.SubscriptionImpl +---The interior shared state for managing the link between the sender and all receivers +---@field package _bus_inner cosock.Bus.Inner +---The receiver's ID +---@field package id ReceiverId +---Strong reference to this receiver's receiver link to keep the shared weak reference from +---getting GC'd while the receiver itself is still in scope. +---@field package _link cosock.Bus.Link +---The yield timeout limit, in seconds. If unset, will yield forever. +---@field package timeout number? + +---@type metatable +---@overload fun(): cosock.Bus.Sender +local __ctor_mt = { + __call = function() + local _inner = { + closed = false, + receiver_links = setmetatable({}, { __mode = "v" }), + next_receiver_id = 1, + } + return setmetatable({ _bus_inner = _inner }, { __index = __sender_mt }) + end, +} + +---An implementation of a Single-Producer, Multiple Consumer "bus". +--- +---When the send side of a bus transmits a message, all currently receivers +---that aren't in the `closed` state will receive a copy of the message. +--- +---This bus implementation doesn't maintain any history; new subscriptions created +---will only receive messages that are broadcast after their creation, they will not +---receive messages that were broadcast prior to their creation. +---@class cosock.Bus +---@overload fun(): cosock.Bus.Sender +local m = setmetatable({}, __ctor_mt --[[ @as metatable ]]) + +---Close the receiver. +---@param self cosock.Bus.Subscription +function __receiver_mt:close() + self._bus_inner.receiver_links[self.id] = nil + setmetatable(self, { __index = __closed_mt }) +end + +---Returns the next message unprocessed by this receiver handle. +---If there are no messages available, this receiver will yield +---until a message has been broadcast, or it times out. +--- +---@see cosock.Bus.Subscription.settimeout +--- +---@param self cosock.Bus.Subscription +---@return any? received the next message unhandled by this subscription handle. Nil on error. +---@return string? err the error string. Nil on success. +function __receiver_mt:receive() + local link = self._bus_inner.receiver_links[self.id] + + if not link then + return nil, "closed" + end + + while true do + if #link.queue > 0 then + local event = table.remove(link.queue, 1) + return event.msg + elseif self._bus_inner.closed then + self:close() + return nil, "closed" + else + local _, _, err = coroutine.yield({ self }, nil, self.timeout) + if err then + return nil, err + end + link.waker = nil + end + end +end + +---Set timeout on the receive yield. +---@param self cosock.Bus.Subscription +---@param timeout number? the timeout value in seconds. Nil will allow this handle to yield forever until a message arrives. +function __receiver_mt:settimeout(timeout) + self.timeout = timeout +end + +---Interface method utilized by [cosock](lua://cosock) to manage waking. +---@param self cosock.Bus.Subscription +---@param kind "recvr"|"sendr" the receiver kind +---@param waker fun()? the waker +function __receiver_mt:setwaker(kind, waker) + local existing_waker = self._bus_inner.receiver_links[self.id].waker + assert(kind == "recvr", "unsupported wake kind: " .. tostring(kind)) + assert( + existing_waker == nil or waker == nil, + "waker already set, receive can't be waited on from multiple places at once" + ) + self._bus_inner.receiver_links[self.id].waker = waker + + -- if messages waiting, immediately wake + if #self._bus_inner.receiver_links[self.id].queue > 0 and waker then + waker() + end +end + +---Close the bus. This will also close all currently active subscriptions. +---@param self cosock.Bus.Sender +function __sender_mt:close() + self._bus_inner.closed = true + local existing_links = self._bus_inner.receiver_links + for _, link in pairs(existing_links or {}) do + if link.waker then + link.waker() + end + end + self._bus_inner.receiver_links = setmetatable({}, { __mode = "v" }) + setmetatable(self, { __index = __closed_mt }) +end + +---Broadcast a message to all receivers. +---@param self cosock.Bus.Sender +---@param msg any the message to broadcast +---@return boolean success whether the send succeeded +---@return string? error the error string if `success` is `false`. +function __sender_mt:send(msg) + if not self._bus_inner.closed then + -- wapping in table allows `nil` to be sent as a message + for _, link in pairs(self._bus_inner.receiver_links or {}) do + table.insert(link.queue, { msg = msg }) + if link.waker then + link.waker() + end + end + return true + else + return false, "closed" + end +end + +---Create a new subscription handle for this bus. +---@param self cosock.Bus.Sender +---@return cosock.Bus.Subscription? receiver the subscription handle. `nil` if this bus is closed. +---@return string? error "closed" if the bus is closed. +function __sender_mt:subscribe() + if self._bus_inner.closed then + return nil, "closed" + end + + ---@type cosock.Bus.SubscriptionImpl + local rx = setmetatable( + { _bus_inner = self._bus_inner, id = self._bus_inner.next_receiver_id }, + { __index = __receiver_mt } + ) + local my_link = { receiver = rx, queue = {}, waker = nil } + + ---@cast rx cosock.Bus.Subscription + rx._link = my_link + self._bus_inner.receiver_links[rx.id] = my_link + self._bus_inner.next_receiver_id = self._bus_inner.next_receiver_id + 1 + return rx +end + +local function _closed() + return nil, "closed" +end + +-- no-ops +__closed_mt.settimeout = function() end +__closed_mt.close = function() end + +-- methods that should error +__closed_mt.send = _closed +__closed_mt.receive = _closed +__closed_mt.subscribe = _closed + +return m diff --git a/drivers/SmartThings/sonos/src/cosock/stream.lua b/drivers/SmartThings/sonos/src/cosock/stream.lua new file mode 100644 index 0000000000..2d713984f3 --- /dev/null +++ b/drivers/SmartThings/sonos/src/cosock/stream.lua @@ -0,0 +1,92 @@ +---@module 'result' + +---@class cosock.Stream +local Stream = {} + +---@class cosock.Stream.AsyncReceiveStream +---@field protected terminated boolean +---@field protected selectable table +---@field protected receive_fn fun(self: table): any? +local _stream_mt = {} +_stream_mt.__index = _stream_mt + +local function __call_impl(stream) + if stream.terminated then + return nil, "closed" + end + + local receive_result = table.pack(stream.receive_fn(stream.selectable)) + if receive_result[1] == nil then + stream.terminated = true + end + return table.unpack(receive_result) +end + +---Create a stream from a receive-position selectable, cosock-enabled type; and a function +---that will get the next item from the selectable when it reports that it's +---receive ready. +--- +---The stream itself is also select-able; however, the primary intended use for the stream is +---as an async iterator/generator type; the returned table is callable, and each call will return +---a value until the stream has been terminated, in which case subsequent call operations on the able +---will result in a `nil` value. +--- +---The function specification can either be a string, in which case it will be treated as +---a key on the selectable table, or it can be a callback that takes the selectable as the +---first argument. +--- +---There is also a convenience shorthand for using the string function key format, in the form of dynamically +---currying table keys of the form `wrap_`, where the function name resolves to a method on +---the target selectable. +--- +---For example, if wrapping a table that uses `tbl:receive()` when it is read-ready, you can use the shorthand +---`Stream.wrap_receive(tbl)`. +--- +---The item yielding function should return a non-nil for as long as the stream is still valid. +---If the underlying selectable has an error case *that does not invalidate the stream*, then it should +---*not* return `nil, err`, or the stream will be treated as terminated. +--- +---@param selectable any a type that implements `setwaker` for `recvr` +---@param receive_fn string|fun(self: table): any? +---@return cosock.Stream.AsyncReceiveStream +function Stream.wrap(selectable, receive_fn) + if type(receive_fn) == "string" then + receive_fn = selectable[receive_fn] + end + local base_stream = setmetatable({ + terminated = false, + selectable = selectable, + receive_fn = receive_fn, + }, _stream_mt) + + -- We create an index that can pass through to the selectable object, which is + -- what allows the stream to interact with the cosock runtime via its wrapped + -- component + local function index(tbl, key) + return rawget(tbl, key) or base_stream[key] or selectable[key] + end + + return setmetatable(base_stream, { __index = index, __call = __call_impl }) +end + +local function curried_wrap(receive_fn_key) + return function(selectable) + return Stream.wrap(selectable, receive_fn_key) + end +end + +Stream = setmetatable(Stream, { + __index = function(_, key) + if rawget(Stream, key) ~= nil then + return rawget(Stream, key) + end + + local maybe_wrapped_fn = key:match("wrap_([^%s]+)") + + if maybe_wrapped_fn then + return curried_wrap(maybe_wrapped_fn) + end + end, +}) + +return Stream diff --git a/drivers/SmartThings/sonos/src/disco.lua b/drivers/SmartThings/sonos/src/disco.lua index 7a9b42fc51..474d5049c0 100644 --- a/drivers/SmartThings/sonos/src/disco.lua +++ b/drivers/SmartThings/sonos/src/disco.lua @@ -1,69 +1,28 @@ local log = require "log" local cosock = require "cosock" -local ssdp = require "ssdp" ---- @module 'sonos.Discovery' +--- @class SonosDiscovery local Discovery = {} ---- @param driver SonosDriver ---- @param ssdp_group_info SonosSSDPInfo ---- @param known_devices_dnis table ---- @param found_ip_addrs table -local ssdp_discovery_callback = function(driver, ssdp_group_info, known_devices_dnis, found_ip_addrs) - if not found_ip_addrs[ssdp_group_info.ip] then - found_ip_addrs[ssdp_group_info.ip] = true - - local function add_device_callback(dni, inner_ssdp_group_info, player_info, group_info) - if not known_devices_dnis[dni] then - local name = player_info.device.name or player_info.device.modelDisplayName or "Unknown Sonos Player" - local model = player_info.device.modelDisplayName or "Unknown Sonos Model" - - local field_cache = { - household_id = inner_ssdp_group_info.household_id, - player_id = player_info.playerId, - wss_url = player_info.websocketUrl - } - - driver._field_cache[dni] = field_cache - - driver.sonos:update_household_info(player_info.householdId, group_info) - - local create_device_msg = { - type = "LAN", - device_network_id = dni, - manufacturer = "Sonos", - label = name, - profile = 'sonos-player', - model = model, - vendor_provided_label = player_info.device.model - } - - driver:try_create_device(create_device_msg) - end - end - - driver:handle_ssdp_discovery(ssdp_group_info, add_device_callback) - end -end - +---The discovery loop's only job is to refresh the SSDP search task constantly while +---discovery is active. Actual handling of discovery events happens on a background thread. +--- +---@see SonosDriver.ssdp_event_thread_handle +--- +---@param driver SonosDriver +---@param _ table +---@param should_continue fun(): boolean function Discovery.discover(driver, _, should_continue) - log.info("Starting Sonos discovery") - driver.found_ips = {} - + log.info("Starting Sonos Discovery") while should_continue() do - local known_devices_dnis = {} - local device_list = driver:get_devices() - for _, device in ipairs(device_list) do - local id = device.device_network_id - known_devices_dnis[id] = true + if not driver.ssdp_task then + log.warn("No SSDP task for driver currently running, cannot perform discovery") + else + log.trace("Refreshing search queries on background ssdp task") + driver.ssdp_task:refresh() end - - ssdp.search(SONOS_SSDP_SEARCH_TERM, function(group_info) - ssdp_discovery_callback(driver, group_info, known_devices_dnis, driver.found_ips) - end) - cosock.socket.sleep(0.1) + cosock.socket.sleep(5) end - log.info("Ending Sonos discovery") end return Discovery diff --git a/drivers/SmartThings/sonos/src/fields.lua b/drivers/SmartThings/sonos/src/fields.lua index 6bf880033f..11b658a835 100644 --- a/drivers/SmartThings/sonos/src/fields.lua +++ b/drivers/SmartThings/sonos/src/fields.lua @@ -1,14 +1,21 @@ ---- @module 'fields' +--- @class Fields local Fields = {} ---@enum SonosPlayerFields Fields.SonosPlayerFields = { _IS_INIT = "init", _IS_SCANNING = "scanning", + BONDED = "bonded", CONNECTION = "conn", - HOUSEHOULD_ID = "householdId", + UNIQUE_KEY = "unique_key", + HOUSEHOLD_ID = "householdId", PLAYER_ID = "playerId", + GROUP_ID = "groupId", + GROUP_ROLE = "groupRole", + COORDINATOR_ID = "coordinatorId", WSS_URL = "wss_url", + REST_URL = "rest_url", + SW_GEN = "sw_gen", } return Fields diff --git a/drivers/SmartThings/sonos/src/init.lua b/drivers/SmartThings/sonos/src/init.lua index 07e38f3b92..db4e5f6013 100644 --- a/drivers/SmartThings/sonos/src/init.lua +++ b/drivers/SmartThings/sonos/src/init.lua @@ -1,401 +1,24 @@ -SONOS_API_KEY = require 'app_key' -local old_log = require "log" - --- Print all errors, warnings, and info to hubcore for now -old_log.error = function(...) - old_log.error_with({ hub_logs = true }, ...) -end - -old_log.warn = function(...) - old_log.warn_with({ hub_logs = true }, ...) -end - -old_log.info = function(...) - old_log.info_with({ hub_logs = true }, ...) -end - -local log = {} - -log.warn = old_log.warn -log.error = old_log.error -log.debug = old_log.debug -log.trace = old_log.trace -log.info = old_log.info - -local Driver = require "st.driver" - +local api_version = require("version").api local cosock = require "cosock" -local capabilities = require "st.capabilities" -local st_utils = require "st.utils" - -local CmdHandlers = require "api.cmd_handlers" -local PlayerFields = require "fields".SonosPlayerFields -local SonosApi = require "api" -local SonosConnection = require "api.sonos_connection" -local SonosDisco = require "disco" -local SonosRestApi = require "api.rest" -local SonosState = require "types".SonosState -local SSDP = require "ssdp" -local utils = require "utils" - ---- @param driver SonosDriver ---- @param device SonosDevice ---- @param should_continue function|nil -local function find_player_for_device(driver, device, should_continue) - device.log.info(string.format("Looking for Sonos Player on network for device (%s)", device.device_network_id)) - local player_found = false - - -- Because SSDP is UDP/unreliable, sometimes we can miss a broadcast. - -- If the user doesn't provide a should_continue condition then we - -- make one that just attempts it a handful of times. - if type(should_continue) ~= "function" then - should_continue = function() return false end - end - - local dni_equal = driver.is_same_mac_address - repeat - --- @param ssdp_group_info SonosSSDPInfo - SSDP.search(SONOS_SSDP_SEARCH_TERM, function(ssdp_group_info) - driver:handle_ssdp_discovery(ssdp_group_info, function(dni, inner_ssdp_group_info, player_info, group_info) - device.log.info(string.format( - "Found device for Sonos search query with MAC addr %s, comparing to %s", - dni, device.device_network_id - ) - ) - if dni_equal(dni, device.device_network_id) then - device.log.info(string.format("Found Sonos Player match for device")) - - local field_cache = { - household_id = inner_ssdp_group_info.household_id, - player_id = player_info.playerId, - wss_url = player_info.websocketUrl - } - driver._field_cache[dni] = field_cache - - driver.sonos:update_household_info(player_info.householdId, group_info) - player_found = true - end - end) - end) - until (player_found or (not should_continue())) - - return player_found +if type(cosock.bus) == "nil" then + local cosock_bus = require "cosock.bus" + cosock.bus = cosock_bus end -local function update_fields_from_ssdp_scan(driver, device, fields) - device.log.debug("Updating fields from SSDP scan") - local is_initialized = device:get_field(PlayerFields._IS_INIT) - - local current_player_id, current_url, current_household_id, sonos_conn - - if is_initialized then - current_player_id =device:get_field(PlayerFields.PLAYER_ID) - current_url = device:get_field(PlayerFields.WSS_URL) - current_household_id = device:get_field(PlayerFields.HOUSEHOULD_ID) - sonos_conn = device:get_field(PlayerFields.CONNECTION) - end - - local already_connected = sonos_conn ~= nil - local refresh = false - - if current_player_id ~= fields.player_id then - if current_player_id ~= nil then - driver._player_id_to_device[current_player_id] = nil - driver.sonos:mark_player_as_removed(current_player_id) - refresh = true - end - driver._player_id_to_device[fields.player_id] = device -- quickly look up device from player id string - driver.sonos:mark_player_as_joined(fields.player_id) - device:set_field(PlayerFields.PLAYER_ID, fields.player_id, { persist = true }) - end - - if current_household_id ~= fields.household_id then - if current_household_id ~= nil then - refresh = true - end - device:set_field(PlayerFields.HOUSEHOULD_ID, fields.household_id, { persist = true }) - end - - if current_url ~= fields.wss_url then - if current_url ~= nil and sonos_conn ~= nil then - sonos_conn:stop() - sonos_conn = nil - device:set_field(PlayerFields.CONNECTION, nil) - refresh = true - end - device:set_field(PlayerFields.WSS_URL, fields.wss_url, { persist = true }) - end - - if refresh and already_connected then - if not sonos_conn then - sonos_conn = SonosConnection.new(driver, device) - device:set_field(PlayerFields.CONNECTION, sonos_conn) - -- calls refresh subscriptions for us - sonos_conn:start() - return - end - - sonos_conn:refresh_subscriptions() - end -end - -local function scan_for_ssdp_updates(driver) - SSDP.search(SONOS_SSDP_SEARCH_TERM, function(ssdp_group_info) - driver:handle_ssdp_discovery(ssdp_group_info, function(dni, inner_ssdp_group_info, player_info, group_info) - local current_cached_fields = driver._field_cache[dni] or {} - local updated_fields = { - household_id = inner_ssdp_group_info.household_id, - player_id = player_info.playerId, - wss_url = player_info.websocketUrl - } - - driver._field_cache[dni] = updated_fields - driver.sonos:update_household_info(player_info.householdId, group_info) - - if not utils.deep_table_eq(current_cached_fields, updated_fields) then - local device = driver:get_device_by_dni(dni) - if device then - update_fields_from_ssdp_scan(driver, device, updated_fields) - end - end - end) - end) -end +---@module 'result' +require "result" { + register_globals = true, +} --- We use the same handler for added and init here because at the time of authoring --- this driver, there is a bug with LAN Edge Drivers where `init` may not be called --- on every device that gets created using `try_create_device`. This makes sure that --- a device is fully initialized whether we come from fresh join or restart. --- See: https://smartthings.atlassian.net/browse/CHAD-9683 -local function _initialize_device(driver, device) - if not ( - driver:get_device_by_dni(device.device_network_id) - and driver:get_device_by_dni(device.device_network_id).id == device.id - ) - then - driver.dni_to_device_id[device.device_network_id] = device.id - end - if not device:get_field(PlayerFields._IS_SCANNING) then - device.log.debug("Starting Scan in _initialize_device for %s", device.label) - device:set_field(PlayerFields._IS_SCANNING, true) - cosock.spawn( - function() - if not device:get_field(PlayerFields._IS_INIT) then - log.trace(string.format("%s setting up device", device.label)) - local is_already_found = - ((driver.found_ips and driver.found_ips[device.device_network_id]) - or driver.sonos:is_player_joined(device.device_network_id)) and - driver._field_cache[device.device_network_id] - - if not is_already_found then - device.log.debug(string.format("Rescanning for player with DNI %s", device.device_network_id)) - device:offline() - local success = false - - local backoff = utils.backoff_builder(360, 1) - while not success do - success = find_player_for_device(driver, device) - if not success then - device.log.warn_with({ hub_logs = true }, string.format( - "Couldn't find Sonos Player [%s] during SSDP scan, trying again shortly", - device.label - )) - cosock.socket.sleep(backoff()) - end - end - end - - device.log.trace("Setting persistent fields") - local fields = driver._field_cache[device.device_network_id] - update_fields_from_ssdp_scan(driver, device, fields) - - device:set_field(PlayerFields._IS_INIT, true) - end - - local sonos_conn = device:get_field(PlayerFields.CONNECTION) --- @type SonosConnection - - if not sonos_conn then - log.trace("Setting transient fields") - -- device is offline until the websocket connection is established - device:offline() - sonos_conn = SonosConnection.new(driver, device) - device:set_field(PlayerFields.CONNECTION, sonos_conn) - end - - if not sonos_conn:is_running() then - -- device is offline until the websocket connection is established - device:offline() - sonos_conn:start() - else - sonos_conn:refresh_subscriptions() - end - end, - string.format("%s device init and SSDP scan", device.label)) - end -end - ---- @param driver SonosDriver ---- @param device SonosDevice -local function device_added(driver, device) - log.trace(string.format("%s device added", device.label)) - _initialize_device(driver, device) -end - ---- @param driver SonosDriver ---- @param device SonosDevice -local function device_init(driver, device) - log.trace(string.format("%s device init", device.label)) - _initialize_device(driver, device) - - device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands({ - capabilities.mediaPlayback.commands.play.NAME, - capabilities.mediaPlayback.commands.pause.NAME, - capabilities.mediaPlayback.commands.stop.NAME, - })) - - device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands({ - capabilities.mediaTrackControl.commands.nextTrack.NAME, - capabilities.mediaTrackControl.commands.previousTrack.NAME, - })) -end - ---- @param driver SonosDriver ---- @param device SonosDevice -local function device_removed(driver, device) - log.trace(string.format("%s device removed", device.label)) - driver.dni_to_device_id[device.device_network_id] = nil - local player_id = device:get_field(PlayerFields.PLAYER_ID) - local sonos_conn = device:get_field(PlayerFields.CONNECTION) - if sonos_conn and sonos_conn:is_running() then sonos_conn:stop() end - driver.sonos:mark_player_as_removed(device:get_field(PlayerFields.PLAYER_ID)) - driver._player_id_to_device[player_id] = nil -end - -local function do_refresh(driver, device, cmd) - log.trace("Refreshing " .. device.label) - local sonos_conn = device:get_field(PlayerFields.CONNECTION) - if sonos_conn == nil then - log.error(string.format("Failed to do refresh, no sonos connection for device: [%s]", device.label)) - return - end - - if not sonos_conn:is_running() then - sonos_conn:start() - end - - -- refreshing subscriptions should refresh all relevant data on those channels as well - -- via the subscription confirmation events. - sonos_conn:refresh_subscriptions() -end - ----@param self SonosDriver ----@param header SonosResponseHeader ----@param body SonosGroupsResponseBody -local function update_group_state(self, header, body) - self.sonos:update_household_info(header.householdId, body) -end - ---- @param self SonosDriver ---- @param ssdp_group_info SonosSSDPInfo ---- @param callback? fun(dni: string, ssdp_group_info: SonosSSDPInfo, player_info: SonosDiscoveryInfo, group_info: SonosGroupsResponseBody) -local function handle_ssdp_discovery(self, ssdp_group_info, callback) - log.debug(string.format("Looking for player info for SSDP search results %s", st_utils.stringify_table(ssdp_group_info))) - local player_info, err = SonosRestApi.get_player_info(ssdp_group_info.ip, SonosApi.DEFAULT_SONOS_PORT) - - if err then - log.error("Error querying player info: " .. err) - elseif player_info and player_info.playerId and player_info.householdId then - log.debug(string.format("Looking for group info for player info %s", st_utils.stringify_table(player_info))) - local group_info, err = SonosRestApi.get_groups_info( - ssdp_group_info.ip, - SonosApi.DEFAULT_SONOS_PORT, - player_info.householdId - ) - - if err or not group_info then - log.error("Error querying group info: " .. err) - return - end +local Driver = require "st.driver" +local SonosDriver = require "sonos_driver" +local log = require "log" - log.trace(string.format("Device %s serial number: %s", player_info.device.name, player_info.device.serialNumber)) - -- Extract the MAC Address from the serial number - local mac_addr, _ = player_info.device.serialNumber:match("(.*):.*"):gsub("-", "") - local dni = mac_addr - log.trace(string.format("MAC of %s computed from serial number: %s", player_info.device.name, mac_addr)) +local driver_template = SonosDriver.new_driver_template() - if type(callback) == "function" then - callback(dni, ssdp_group_info, player_info, group_info) - end - end -end - ---- @type SonosDriver -local driver = Driver("Sonos", { - discovery = SonosDisco.discover, - lifecycle_handlers = { - added = device_added, - init = device_init, - removed = device_removed, - }, - capability_handlers = { - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = do_refresh, - }, - [capabilities.audioMute.ID] = { - [capabilities.audioMute.commands.mute.NAME] = CmdHandlers.handle_mute, - [capabilities.audioMute.commands.unmute.NAME] = CmdHandlers.handle_unmute, - [capabilities.audioMute.commands.setMute.NAME] = CmdHandlers.handle_set_mute, - }, - [capabilities.audioNotification.ID] = { - [capabilities.audioNotification.commands.playTrack.NAME] = CmdHandlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndResume.NAME] = CmdHandlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndRestore.NAME] = CmdHandlers.handle_audio_notification, - }, - [capabilities.audioVolume.ID] = { - [capabilities.audioVolume.commands.volumeUp.NAME] = CmdHandlers.handle_volume_up, - [capabilities.audioVolume.commands.volumeDown.NAME] = CmdHandlers.handle_volume_down, - [capabilities.audioVolume.commands.setVolume.NAME] = CmdHandlers.handle_set_volume, - }, - [capabilities.mediaGroup.ID] = { - [capabilities.mediaGroup.commands.groupVolumeUp.NAME] = CmdHandlers.handle_group_volume_up, - [capabilities.mediaGroup.commands.groupVolumeDown.NAME] = CmdHandlers.handle_group_volume_down, - [capabilities.mediaGroup.commands.setGroupVolume.NAME] = CmdHandlers.handle_group_set_volume, - [capabilities.mediaGroup.commands.muteGroup.NAME] = CmdHandlers.handle_group_mute, - [capabilities.mediaGroup.commands.unmuteGroup.NAME] = CmdHandlers.handle_group_unmute, - [capabilities.mediaGroup.commands.setGroupMute.NAME] = CmdHandlers.handle_group_set_mute, - }, - [capabilities.mediaPlayback.ID] = { - [capabilities.mediaPlayback.commands.play.NAME] = CmdHandlers.handle_play, - [capabilities.mediaPlayback.commands.pause.NAME] = CmdHandlers.handle_pause, - [capabilities.mediaPlayback.commands.stop.NAME] = CmdHandlers.handle_pause, - }, - [capabilities.mediaPresets.ID] = { - [capabilities.mediaPresets.commands.playPreset.NAME] = CmdHandlers.handle_play_preset, - }, - [capabilities.mediaTrackControl.ID] = { - [capabilities.mediaTrackControl.commands.nextTrack.NAME] = CmdHandlers.handle_next_track, - [capabilities.mediaTrackControl.commands.previousTrack.NAME] = CmdHandlers.handle_previous_track, - } - }, - sonos = SonosState.new(), - update_group_state = update_group_state, - handle_ssdp_discovery = handle_ssdp_discovery, - _player_id_to_device = {}, - _field_cache = {}, - dni_to_device_id = {}, - is_same_mac_address = function(dni, other) - if not (type(dni) == "string" and type(other) == "string") then return false end - local dni_normalized = dni:gsub("-", ""):gsub(":", ""):lower() - local other_normalized = other:gsub("-", ""):gsub(":", ""):lower() - return dni_normalized == other_normalized - end, - get_device_by_dni = function(self, dni) - local device_uuid = self.dni_to_device_id[dni] - if not device_uuid then return nil end - return self:get_device_info(device_uuid) - end -}) +---@type SonosDriver +local driver = Driver("Sonos", driver_template) -- Clean these up, as we no longer want them persisting. if driver.datastore["_field_cache"] ~= nil then @@ -407,12 +30,14 @@ if driver.datastore["dni_to_device_id"] ~= nil then driver.datastore["dni_to_device_id"] = nil end --- Kick off a scan right away to attempt to populate some information -driver:call_with_delay(3, scan_for_ssdp_updates, "Sonos SSDP Initial Scan") - --- re-scan every 10 minutes -local SSDP_SCAN_INTERVAL_SECONDS = 600 -driver:call_on_schedule(SSDP_SCAN_INTERVAL_SECONDS, scan_for_ssdp_updates, "Sonos SSDP Scan Task") +-- API Version 14 was the version that came out with 0.57.x +-- +-- In API >= 14, the SSDP task will start when the driver receives the startup state. +-- To support older versions of hub core, we start the SSDP task before the run loop, +-- where we won't be using the startup state handling. +if api_version < 14 then + driver:start_ssdp_event_task() +end log.info("Starting Sonos run loop") driver:run() diff --git a/drivers/SmartThings/sonos/src/lifecycle_handlers.lua b/drivers/SmartThings/sonos/src/lifecycle_handlers.lua new file mode 100644 index 0000000000..1055d53020 --- /dev/null +++ b/drivers/SmartThings/sonos/src/lifecycle_handlers.lua @@ -0,0 +1,212 @@ +local api_version = require("version").api +local capabilities = require "st.capabilities" + +local cosock = require "cosock" +local log = require "log" +local utils = require "utils" +local PlayerFields = require "fields".SonosPlayerFields + +---@class SonosDriverLifecycleHandlers +local SonosDriverLifecycleHandlers = {} + +local function emit_component_event_no_cache(device, component, capability_event) + if not device:supports_capability(capability_event.capability, component.id) then + local err_msg = string.format( + "Attempted to generate event for %s.%s but it does not support capability %s", + device.id, + component.id, + capability_event.capability.NAME + ) + log.warn_with({ hub_logs = true }, err_msg) + return false, err_msg + end + local event, err = + capabilities.emit_event(device, component.id, device.capability_channel, capability_event) + if err ~= nil then + log.warn_with({ hub_logs = true }, err) + end + return event, err +end + +---@param driver SonosDriver +---@param device SonosDevice +function SonosDriverLifecycleHandlers.initialize_device(driver, device) + -- Remove usage of the state cache for sonos devices to avoid large datastores + device:set_field("__state_cache", nil, { persist = true }) + device:extend_device("emit_component_event", emit_component_event_no_cache) + + device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands({ + capabilities.mediaPlayback.commands.play.NAME, + capabilities.mediaPlayback.commands.pause.NAME, + capabilities.mediaPlayback.commands.stop.NAME, + })) + + device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands({ + capabilities.mediaTrackControl.commands.nextTrack.NAME, + capabilities.mediaTrackControl.commands.previousTrack.NAME, + })) + + if api_version >= 14 and not driver:has_received_startup_state() then + device.log.debug("Driver startup state not yet received, delaying initialization of device.") + driver:queue_device_init_for_startup_state(device) + return + end + + -- spawn a task to handle initialization to avoid blocking the main driver or device + -- threads, as this may involve long-yielding operations. + cosock.spawn( + function() + local mac_addr = device.device_network_id + local player_info_tx, player_info_rx = cosock.channel.new() + while true do + if driver.ssdp_task then + driver.ssdp_task:get_player_info(player_info_tx, mac_addr) + local recv_ready, _, select_err = cosock.socket.select({ player_info_rx }, nil, nil) + + if type(recv_ready) == "table" and recv_ready[1] == player_info_rx then + local info, recv_err = player_info_rx:receive() + if not info then + device.log.warn(string.format("error receiving device info: %s", recv_err)) + else + ---@cast info SpeakerDiscoveryInfo + local auth_success, api_key_or_err = driver:check_auth(info) + if not auth_success then + device:offline() + if auth_success == false and api_version >= 14 then + local token_event_receive = driver:oauth_token_event_subscribe() + if not token_event_receive then + log.error("token event bus closed, aborting initialization") + return + end + token_event_receive:settimeout(30) + local token, token_recv_err + -- max 30 mins + local backoff_builder = utils.backoff_builder(60 * 30, 30, 2) + driver:alert_unauthorized() + + local backoff_timer = nil + while not token do + -- we use the backoff to create a timer and utilize a select loop here, instead of + -- utilizing a sleep, so that we can create a long delay on our polling of the cloud + -- without putting ourselves in a situation where we're sleeping for an extended period + -- of time so that we don't sleep through the users's log-in attempt and fail to resume + -- our connection attempts in a timely manner. + -- + -- The backoff caps at 30 mins, as commented above + if not backoff_timer then + backoff_timer = cosock.timer.create_oneshot(backoff_builder()) + end + local token_recv_ready, _, token_select_err = + cosock.socket.select({ token_event_receive, backoff_timer }, nil, nil) + + if token_select_err then + log.warn(string.format("select error: %s", token_select_err)) + end + + token, token_recv_err = nil, nil + for _, receiver in pairs(token_recv_ready or {}) do + if receiver == backoff_timer then + -- we just make a note that the backoff has elapsed, rather than + -- put a request in flight immediately. + -- + -- This is just in case both receivers are ready, so that we can prioritize + -- handling the token instead of putting another request in flight. + backoff_timer:handled() + backoff_timer = nil + end + + if receiver == token_event_receive then + token, token_recv_err = token_event_receive:receive() + end + end + + if token_recv_err == "timeout" then + log.debug("timeout waiting for OAuth token in reconnect task") + elseif token_recv_err and not token then + log.warn( + string.format( + "Unexpected error on token event receive bus: %s", + token_recv_err + ) + ) + end + end + else + device.log.error( + string.format( + "error while checking authentication: %s, marking device offline", + api_key_or_err + ) + ) + end + else + local success, error, error_code = + driver:handle_player_discovery_info(api_key_or_err, info, device) + if success then + return + end + log.error_with( + { hub_logs = false }, + string.format( + "Error handling Sonos player initialization: %s, error code: %s", + error, + (error_code or "N/A") + ) + ) + end + end + else + device.log.warn( + string.format("select error waiting for initialization device info: %s", select_err) + ) + end + else + device.log.error_with( + { hub_logs = true }, + string.format("Driver wasn't able to spin up SSDP task, cannot initialize devices.") + ) + cosock.socket.sleep(30) + end + end + end, + string.format( + "%s initialization task", + (device and (device.label or device.id) or "") + ) + ) +end + +---@param driver SonosDriver +---@param device SonosDevice +---@param event "INIT"|"ADDED" +---@param _args table? +function SonosDriverLifecycleHandlers.handle_initialize_lifecycle_event( + driver, + device, + event, + _args +) + device.log.trace(string.format("handling lifecycle event %s", event)) + local field_changed = utils.update_field_if_changed(device, PlayerFields._IS_INIT, true) + if field_changed then + device.log.trace("initializing device in response to lifecycle event") + SonosDriverLifecycleHandlers.initialize_device(driver, device) + end +end + +---@param driver SonosDriver +---@param device SonosDevice +function SonosDriverLifecycleHandlers.removed(driver, device) + log.trace(string.format("%s device removed", device.label)) + driver.sonos:remove_device_record_association(device) + driver.dni_to_device_id[device.device_network_id] = nil + local sonos_conn = device:get_field(PlayerFields.CONNECTION) + if sonos_conn and sonos_conn:is_running() then + sonos_conn:stop() + end +end + +SonosDriverLifecycleHandlers.added = SonosDriverLifecycleHandlers.handle_initialize_lifecycle_event +SonosDriverLifecycleHandlers.init = SonosDriverLifecycleHandlers.handle_initialize_lifecycle_event + +return SonosDriverLifecycleHandlers diff --git a/drivers/SmartThings/sonos/src/lunchbox/rest.lua b/drivers/SmartThings/sonos/src/lunchbox/rest.lua index 4977aaf2f2..6436326aeb 100644 --- a/drivers/SmartThings/sonos/src/lunchbox/rest.lua +++ b/drivers/SmartThings/sonos/src/lunchbox/rest.lua @@ -1,12 +1,18 @@ +---@class ChunkedResponse : Response +---@field package _received_body boolean +---@field package _parsed_headers boolean +---@field public new fun(status_code: number, socket: table?): ChunkedResponse +---@field public fill_body fun(self: ChunkedResponse): string? +---@field public append_body fun(self: ChunkedResponse, next_chunk_body: string): ChunkedResponse + local socket = require "cosock.socket" -local st_utils = require "st.utils" -local lb_utils = require "lunchbox.util" local utils = require "utils" +local lb_utils = require "lunchbox.util" local Request = require "luncheon.request" -local Response = require "luncheon.response" +local Response = require "luncheon.response" --[[@as ChunkedResponse]] -local api_version = require("version").api +local api_version = require "version".api local RestCallStates = { SEND = "Send", @@ -17,15 +23,14 @@ local RestCallStates = { } local function connect(client) - local port = 80 + local port = client.base_url.port or 80 local use_ssl = false if client.base_url.scheme == "https" then - port = 443 + port = client.base_url.port or 443 use_ssl = true end - if client.base_url.port ~= port then port = client.base_url.port end local sock, err = client.socket_builder(client.base_url.host, port, use_ssl) if sock == nil then @@ -45,34 +50,44 @@ local function reconnect(client) return connect(client) end +---@param client RestClient +---@param request HttpMessage +---@return integer? bytes_sent +---@return string? err_msg +---@return integer idx local function send_request(client, request) if client.socket == nil then - return nil, "no socket available" + return nil, "no socket available", 0 end local payload = request:serialize() local bytes, err, idx = nil, nil, 0 - if client.socket == nil then return nil, "closed", nil end - repeat bytes, err, idx = client.socket:send(payload, idx + 1, #payload) until (bytes == #payload) - or (err ~= nil) + repeat + bytes, err, idx = client.socket:send(payload, idx + 1, #payload) + until (bytes == #payload) + or (err ~= nil) return bytes, err, idx end +---@param original_response Response +---@param sock table +---@return Response? +---@return string? err local function parse_chunked_response(original_response, sock) local ChunkedTransferStates = { EXPECTING_CHUNK_LENGTH = "ExpectingChunkLength", EXPECTING_BODY_CHUNK = "ExpectingBodyChunk", } - local full_response = Response.new(original_response.status, nil) + local full_response = Response.new(original_response.status, nil) --[[@as ChunkedResponse]] for header in original_response.headers:iter() do full_response.headers:append_chunk(header) end local original_body, err = original_response:get_body() - if type(original_body) ~= "string" or err ~= nil then - return original_body, (err or "unexpected nil in error position") + if original_body == nil or err ~= nil then + return nil, (err or "unexpected nil in error position") end local next_chunk_bytes = tonumber(original_body, 16) local next_chunk_body = "" @@ -133,6 +148,10 @@ local function parse_chunked_response(original_response, sock) return full_response end +---@param sock table +---@return Response|nil +---@return string? err +---@return string? partial local function handle_response(sock) if api_version >= 9 then local response, err = Response.tcp_source(sock) @@ -154,25 +173,6 @@ local function handle_response(sock) end full_response = response else - local content_length_header = (headers and headers:get_one("Content-length")) or nil - if content_length_header then - local content_length = tonumber(content_length_header) - if type(content_length) ~= "number" then - return nil, string.format("content_length is not a number: %s", st_utils.stringify_table(content_length_header)) - end - - body, err, partial = sock:receive(content_length) - if err ~= nil then - return nil, err, partial - end - - if body == nil then - return nil, string.format("Unexpected nil body response, partial response receive: %s", partial) - end - - initial_recv:append_body(body) - initial_recv._received_body = true - end full_response = initial_recv end @@ -182,25 +182,32 @@ local function handle_response(sock) end end +---@param client RestClient +---@param request HttpMessage +---@param retry_fn nil|fun(): boolean +---@return Response? response nil on error +---@return string? err nil on success +---@return string? partial local function execute_request(client, request, retry_fn) if not client._active then - return nil, "Called `execute request` on a terminated REST Client" + return nil, "Called `execute request` on a terminated REST Client", nil end if client.socket == nil then local success, err = connect(client) - if not success then return nil, err end + if not success then return nil, err, nil end end local should_retry = retry_fn + if type(should_retry) ~= "function" then should_retry = function() return false end end -- send output - local _bytes_sent, send_err, _idx = nil, nil, 0 + local bytes_sent, send_err, _idx = nil, nil, 0 -- recv output - local response, recv_err, _partial = nil, nil, nil + local response, recv_err, partial = nil, nil, nil -- return values local ret, err = nil, nil @@ -211,11 +218,11 @@ local function execute_request(client, request, retry_fn) local retry = should_retry() if current_state == RestCallStates.SEND then backoff = utils.backoff_builder(60, 1, 0.1) - _bytes_sent, send_err, _idx = send_request(client, request) + bytes_sent, send_err, _idx = send_request(client, request) if not send_err then current_state = RestCallStates.RECEIVE - elseif should_retry() then + elseif retry then if string.lower(send_err) == "closed" or string.lower(send_err):match("broken pipe") then current_state = RestCallStates.RECONNECT else @@ -227,13 +234,13 @@ local function execute_request(client, request, retry_fn) current_state = RestCallStates.COMPLETE end elseif current_state == RestCallStates.RECEIVE then - response, recv_err, _partial = handle_response(client.socket) + response, recv_err, partial = handle_response(client.socket) if not recv_err then ret = response err = nil current_state = RestCallStates.COMPLETE - elseif should_retry() then + elseif retry then if string.lower(recv_err) == "closed" or string.lower(recv_err):match("broken pipe") then current_state = RestCallStates.RECONNECT else @@ -263,7 +270,7 @@ local function execute_request(client, request, retry_fn) end until current_state == RestCallStates.COMPLETE - return ret, err + return ret, err, partial end ---@class RestClient @@ -275,19 +282,17 @@ RestClient.__index = RestClient function RestClient.one_shot_get(full_url, additional_headers, socket_builder) local url_table = lb_utils.force_url_table(full_url) - local client = RestClient.new(url_table.scheme .. "://" .. url_table.host .. ":" .. url_table.port, socket_builder) + local client = RestClient.new(url_table, socket_builder) local ret, err = client:get(url_table.path, additional_headers) client:shutdown() - client = nil return ret, err end function RestClient.one_shot_post(full_url, body, additional_headers, socket_builder) local url_table = lb_utils.force_url_table(full_url) - local client = RestClient.new(url_table.scheme .. "://" .. url_table.host .. ":" .. url_table.port, socket_builder) + local client = RestClient.new(url_table, socket_builder) local ret, err = client:post(url_table.path, body, additional_headers) client:shutdown() - client = nil return ret, err end @@ -312,38 +317,54 @@ function RestClient:update_base_url(new_url) self.base_url = lb_utils.force_url_table(new_url) end -local function _build_request(method, path, host, additional_headers) - local request = Request.new(method, path, nil):add_header("user-agent", "smartthings-lua-edge-driver") - :add_header("host", string.format("%s", host)) +---@param path string +---@param additional_headers table +---@param retry_fn nil|fun(): boolean +---@return Response? the response, nil on error +---@return string? err error, nil on success +---@return string? partial +function RestClient:get(path, additional_headers, retry_fn) + local request = Request.new("GET", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) if additional_headers ~= nil and type(additional_headers) == "table" then for k, v in pairs(additional_headers) do request = request:add_header(k, v) end end - return request -end - -local function _build_request_with_body(method, path, host, additional_headers, body_string) - local request = _build_request(method, path, host, additional_headers) - request:append_body(body_string) - return request -end - -function RestClient:get(path, additional_headers, retry_fn) - local request = _build_request("GET", path, self.base_url.host, additional_headers) return execute_request(self, request, retry_fn) end function RestClient:post(path, body_string, additional_headers, retry_fn) - local request = - _build_request_with_body("POST", path, self.base_url.host, additional_headers, body_string) + local request = Request.new("POST", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) return execute_request(self, request, retry_fn) end function RestClient:put(path, body_string, additional_headers, retry_fn) - local request = - _build_request_with_body("PUT", path, self.base_url.host, additional_headers, body_string) + local request = Request.new("PUT", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) return execute_request(self, request, retry_fn) end @@ -351,9 +372,10 @@ end function RestClient.new(base_url, sock_builder) base_url = lb_utils.force_url_table(base_url) - if type(sock_builder) ~= "function" then sock_builder = utils.labeled_socket_builder("Sonos REST API") end + if type(sock_builder) ~= "function" then sock_builder = utils.labeled_socket_builder() end - return setmetatable({ base_url = base_url, socket_builder = sock_builder, socket = nil, _active = true }, RestClient) + return + setmetatable({ base_url = base_url, socket_builder = sock_builder, socket = nil, _active = true }, RestClient) end return RestClient diff --git a/drivers/SmartThings/sonos/src/lunchbox/util.lua b/drivers/SmartThings/sonos/src/lunchbox/util.lua index 54a421047f..bdfc3f0964 100644 --- a/drivers/SmartThings/sonos/src/lunchbox/util.lua +++ b/drivers/SmartThings/sonos/src/lunchbox/util.lua @@ -16,6 +16,8 @@ util.force_url_table = function(url) return url end +---@param tbl table the table to make read only +---@return table read_only read-only version of the table util.read_only = function(tbl) if type(tbl) == "table" then local proxy = {} @@ -30,17 +32,4 @@ util.read_only = function(tbl) end end -util.iter_string_lines = function(str) - if str:sub(-1) ~= "\n" then str = str .. "\n" end - - return str:gmatch("(.-)\n") -end - -util.copy_data = function(tbl) - local ret = {} - for k, v in pairs(tbl) do ret[k] = v end - - return ret -end - return util diff --git a/drivers/SmartThings/sonos/src/result.lua b/drivers/SmartThings/sonos/src/result.lua new file mode 100644 index 0000000000..9daed8864b --- /dev/null +++ b/drivers/SmartThings/sonos/src/result.lua @@ -0,0 +1,135 @@ +---@generic T +---@class Ok: { is_ok: (fun(self): true), is_err: (fun(self): false), unwrap: (fun(self): T ) } + +---@generic E +---@class Err: { is_ok: (fun(self): false), is_err: (fun(self): true), unwrap: (fun(self): nil,E ) } + +---@generic T,E +---@alias Result +---| Ok +---| Err + +local Result = {} + +local fallback = setmetatable({}, { __metatable = { variant = "N/A", _discriminant = false } }) + +local _value_mt + +_value_mt = { + __newindex = function() end +} + + +function _value_mt:is_ok() + return getmetatable(self).variant == "ok" +end + +function _value_mt:is_err() + return not self:is_ok() +end + +function _value_mt:unwrap() + local hidden = getmetatable(self) + if hidden.variant == "ok" then + return hidden._inner_ok + end + + return nil, hidden._inner_err +end + +_value_mt.__index = _value_mt + +local function _get_variant_mt(tbl) + return getmetatable(tbl or {}) or getmetatable(fallback) +end + +local function _get_variant(tbl) + return _get_variant_mt(tbl).variant +end + +local function _is_result(tbl) + local variant_mt = _get_variant_mt(tbl) + local maybe_value_mt = getmetatable(variant_mt) + return variant_mt.variant ~= "N/A" and maybe_value_mt == _value_mt +end + +_value_mt.__tostring = function(self) + local maybe_ok, maybe_err = self:unwrap() + if type(maybe_ok) == "string" then + maybe_ok = string.format("\"%s\"", maybe_ok) + end + if type(maybe_err) == "string" then + maybe_err = string.format("\"%s\"", maybe_err) + end + return string.format("%s(%s)", _get_variant(self):gsub("^%l", string.upper), maybe_ok or maybe_err) +end + +---@generic T,E +---@param variant "ok"|"err" +---@param other T|E +---@return Result +local __enum_call = function(variant, other) + assert(other ~= nil, debug.traceback()) + local inner_key = string.format("_inner_%s", variant) + local inner = { + variant = variant, + [inner_key] = other + } + return setmetatable({}, + { __index = _value_mt, __tostring = _value_mt.__tostring, __metatable = setmetatable(inner, _value_mt) }) +end + +---@generic T +---@param other T +---@return Ok +function Result.Ok(other) return __enum_call("ok", other) end + +---@generic E +---@param other E +---@return Err +function Result.Err(other) return __enum_call("err", other) end + +---@generic T, E +---@param let_tbl { ["Ok"]: Result } | { ["Err"]: Result } +---@return fun(): T|E|nil +Result.let = function(let_tbl) + local variant_key, result = next(let_tbl) + assert(variant_key == "Ok" or variant_key == "Err") + + local type_variant = variant_key:lower() + local value_variant = _get_variant(result) + local is_result = _is_result(result) + + if type_variant == value_variant and result and is_result then + local done = false + return function() + if done then return nil end + + done = true + local maybe_ok, maybe_err = result:unwrap() + if maybe_ok then return maybe_ok end + return maybe_err + end + end + + return function() + return nil + end +end + +local _mod_mt = { + __metatable = "Result", + __index = Result, + __call = function(mod_tbl, kwargs) + if type(kwargs) == "table" and kwargs.register_globals == true then + _G["Ok"] = Result.Ok + _G["Err"] = Result.Err + _G["let"] = Result.let + end + end, + __tostring = function(mod_tbl) + return "Lua Result Type" + end +} + +return setmetatable(Result, _mod_mt) diff --git a/drivers/SmartThings/sonos/src/sonos_driver.lua b/drivers/SmartThings/sonos/src/sonos_driver.lua new file mode 100644 index 0000000000..56ccb2335f --- /dev/null +++ b/drivers/SmartThings/sonos/src/sonos_driver.lua @@ -0,0 +1,776 @@ +local version = require "version" +local api_version = version.api +local rpc_version = version.rpc +local capabilities = require "st.capabilities" +local cosock = require "cosock" +local json = require "st.json" +local log = require "log" +local net_url = require "net.url" +local st_utils = require "st.utils" + +local sonos_ssdp = require "api.sonos_ssdp_discovery" +local utils = require "utils" + +local CmdHandlers = require "api.cmd_handlers" +local PlayerFields = require("fields").SonosPlayerFields +local SonosApi = require "api" +local SonosDisco = require "disco" +local SonosDriverLifecycleHandlers = require "lifecycle_handlers" +local SonosState = require "sonos_state" + +local security_load_success, security = pcall(require, "st.security") +if not security_load_success then + log.warn_with( + { hub_logs = true }, + string.format("Unable to load `st.security` module: %s", security) + ) + security = nil +end + +---@class SonosDriver: Driver +--- +---@field public datastore table driver persistent store +---@field public hub_augmented_driver_data table augmented data store +---@field public dni_to_device_id table +---@field public get_devices fun(self: SonosDriver): SonosDevice[] +---@field public get_device_info fun(self: SonosDriver, id: string): SonosDevice? +--- +---@field public sonos SonosState Local state related to the sonos systems +---@field public discovery fun(driver: SonosDriver, opts: table, should_continue: fun(): boolean) +---@field private oauth_token_bus cosock.Bus.Sender bus for broadcasting new oauth tokens that arrive on the environment channel +---@field private oauth_info_bus cosock.Bus.Sender bus for broadcasting new endpoint app info that arrives on the environment channel +---@field private oauth { token: {accessToken: string, expiresAt: number}, endpoint_app_info: { state: "connected"|"disconnected" }, force_oauth: boolean? } cached OAuth info +---@field private startup_state_received boolean +---@field private devices_waiting_for_startup_state SonosDevice[] +---@field private have_alerted_unauthorized boolean Used to track if we have requested an oauth token, this will trigger the notification used for account linking +---@field package bonded_devices table map of Device device_network_id to a boolean indicating if the device is currently known as a bonded device. +--- +---@field public ssdp_task SonosPersistentSsdpTask? +---@field private ssdp_event_thread_handle table? +local SonosDriver = {} + +---@param device SonosDevice +function SonosDriver:update_bonded_device_tracking(device) + local already_bonded = self.bonded_devices[device.device_network_id] + local currently_bonded = device:get_field(PlayerFields.BONDED) + self.bonded_devices[device.device_network_id] = currently_bonded + + if currently_bonded and not already_bonded then + device:offline() + end + + if already_bonded and not currently_bonded then + SonosDriverLifecycleHandlers.initialize_device(self, device) + end +end + +function SonosDriver:has_received_startup_state() + return self.startup_state_received +end + +function SonosDriver:queue_device_init_for_startup_state(device) + table.insert(self.devices_waiting_for_startup_state, device) +end + +---@param household_id HouseholdId +---@param player_id PlayerId +---@return SonosDevice? +function SonosDriver:device_for_player(household_id, player_id) + local maybe_device_id = self.sonos:get_device_id_for_player(household_id, player_id) + if maybe_device_id then + return self:get_device_info(maybe_device_id) + end +end + +---@param dni string +---@return SonosDevice|nil +---@return string? +function SonosDriver:get_device_by_dni(dni) + local cached_device_id = self.dni_to_device_id[dni] + if cached_device_id then + return self:get_device_info(cached_device_id) + end + + for _, device in ipairs(self:get_devices() or {}) do + if utils.mac_address_eq(device.device_network_id, dni) then + self.dni_to_device_id[dni] = device.id + return device + end + end + + return nil, string.format("Unable to find device record for DNI %s", dni) +end + +---@param update_key string +---@param decoded any +function SonosDriver:handle_augmented_data_change(update_key, decoded) + if update_key == "endpointAppInfo" then + self.oauth.endpoint_app_info = decoded + self.oauth_info_bus:send(decoded) + elseif update_key == "sonosOAuthToken" then + self.oauth.token = decoded + self.oauth_token_bus:send(decoded) + elseif update_key == "force_oauth" then + self.oauth.force_oauth = decoded + else + log.debug(string.format("received upsert of unexpected key: %s", update_key)) + end +end + +---@return (cosock.Bus.Subscription)? receiver the subscription receiver if the bus hasn't been closed, nil if closed +---@return nil|"not supported"|"closed" err_msg "not supported" on old API versions, "closed" if the bus is closed, nil on success +function SonosDriver:oauth_token_event_subscribe() + if api_version < 14 or security == nil then + return nil, "not supported" + end + return self.oauth_token_bus:subscribe() +end + +---@return (cosock.Bus.Subscription)? receiver the subscription receiver if the bus hasn't been closed, nil if closed +---@return nil|"not supported"|"closed" err_msg "not supported" on old API versions, "closed" if the bus is closed, nil on success +function SonosDriver:oauth_info_event_subscribe() + if api_version < 14 or security == nil then + return nil, "not supported" + end + return self.oauth_info_bus:subscribe() +end + +function SonosDriver:update_after_startup_state_received() + for k, v in pairs(self.hub_augmented_driver_data or {}) do + local decode_success, decoded = pcall(json.decode, v) + if decode_success then + self:handle_augmented_data_change(k, decoded) + end + end +end + +---@param update_key "endpointAppInfo"|"sonosOAuthToken"|string +function SonosDriver:handle_augmented_store_delete(update_key) + if update_key == "endpointAppInfo" then + if update_key == "endpointAppInfo" then + log.trace("deleting endpoint app info") + self.oauth.endpoint_app_info = nil + self.oauth_info_bus:send(nil) + elseif update_key == "sonosOAuthToken" then + log.trace("deleting OAuth Token") + self.oauth.token = nil + self.oauth_token_bus:send(nil) + elseif update_key == "force_oauth" then + log.trace("deleting Force OAuth") + self.oauth.force_oauth = nil + else + log.debug(string.format("received delete of unexpected key: %s", update_key)) + end + end +end + +---@param update_key "endpointAppInfo"|"sonosOAuthToken"|string +---@param update_value string +function SonosDriver:handle_augmented_store_upsert(update_key, update_value) + local decode_success, decoded = pcall(json.decode, update_value) + if not decode_success then + log.warn( + string.format( + "Unable to decode augmented data update payload: %s", + st_utils.stringify_table { + key = update_key, + decode_result = decoded, + } + ) + ) + return + end + self:handle_augmented_data_change(update_key, decoded) +end + +---@param update_kind "snapshot"|"upsert"|"delete" +---@param update_key "endpointAppInfo"|"sonosOAuthToken" +---@param update_value string +function SonosDriver:notify_augmented_data_changed(update_kind, update_key, update_value) + local already_connected = self:oauth_app_connected() + log.info(string.format("Already connected? %s", already_connected)) + if update_kind == "snapshot" then + self:update_after_startup_state_received() + elseif update_kind == "delete" then + self:handle_augmented_store_delete(update_key) + elseif update_kind == "upsert" then + self:handle_augmented_store_upsert(update_key, update_value) + else + log.debug( + st_utils.stringify_table( + { kind = update_kind, key = update_key, value = "..." }, + "unexpected event kind in augmented data change event", + true + ) + ) + end +end + +function SonosDriver:handle_startup_state_received() + self:start_ssdp_event_task() + self:notify_augmented_data_changed "snapshot" + if api_version >= 14 and security ~= nil then + local token_refresher = require "token_refresher" + token_refresher.spawn_token_refresher(self) + end + self.startup_state_received = true + for _, device in pairs(self.devices_waiting_for_startup_state or {}) do + SonosDriverLifecycleHandlers.initialize_device(self, device) + end + self.devices_waiting_for_startup_state = {} +end + +function SonosDriver:get_fallback_api_key() + if api_version < 14 or security == nil then + return SonosApi.api_keys.s1_key + end + + if self.oauth and self.oauth.force_oauth then + return SonosApi.api_keys.oauth_key + end + + return SonosApi.api_keys.s1_key +end + +--- Check if the driver is able to authenticate against the given household_id +--- with what credentials it currently possesses. +---@param info_or_device SonosDevice | SpeakerDiscoveryInfo +---@return boolean? auth_success true if the driver can authenticate against the provided arguments, false otherwise +---@return string? api_key_or_err if `auth_success` is true, this will be the API key that is known to auth. If `auth_success` is false, this will be nil. If `auth_success` is `nil`, this will be an error message. +function SonosDriver:check_auth(info_or_device) + local maybe_token, _ = self:get_oauth_token() + + if maybe_token then + return true, SonosApi.api_keys.oauth_key + elseif self.oauth.force_oauth then + return false + end + + local rest_url, household_id, sw_gen + if type(info_or_device) == "table" then + if + type(info_or_device.get_field) == "function" + and type(info_or_device.set_field) == "function" + and info_or_device.id + then + ---@cast info_or_device SonosDevice + rest_url = net_url.parse(info_or_device:get_field(PlayerFields.REST_URL)) + household_id = self.sonos:get_sonos_ids_for_device(info_or_device) + sw_gen = info_or_device:get_field(PlayerFields.SW_GEN) + else + ---@cast info_or_device SpeakerDiscoveryInfo + rest_url = info_or_device.rest_url + household_id = info_or_device.household_id + sw_gen = info_or_device.sw_gen + end + end + + if not (rest_url and household_id) then + return nil, + string.format( + "unable to determine REST API call to check auth for %s", + ( + ( + type(info_or_device) == "table" + and (info_or_device.label or info_or_device.id or info_or_device.name) + ) or "" + ) + ) + end + + if sw_gen == nil or sw_gen == 1 then + local api_key = SonosApi.api_keys.s1_key + local headers = SonosApi.make_headers(api_key) + local response, response_err = SonosApi.RestApi.get_groups_info(rest_url, household_id, headers) + if not response or response_err then + return nil, + string.format("Error while making REST API call: %s", (response_err or "")) + end + + if type(response) == "table" and response.groups and response.players then + return true, api_key + end + end + + local unauthorized = false + for _, api_key in pairs(SonosApi.api_keys or {}) do + local headers = SonosApi.make_headers(api_key, maybe_token and maybe_token.accessToken) + local response, response_err = SonosApi.RestApi.get_groups_info(rest_url, household_id, headers) + + if response and response._objectType == "globalError" then + unauthorized = (response.errorCode == "ERROR_NOT_AUTHORIZED") + if not unauthorized then + return nil, string.format("Unexpected error body: %s", st_utils.stringify_table(response)) + end + end + + if not response or response_err then + return nil, + string.format("Error while making REST API call: %s", (response_err or "")) + end + + if response._objectType == "groups" then + return true, api_key + end + end + + if unauthorized then + return false + end + + return nil, + string.format( + "Unable to determine Authentication Status for %s", + st_utils.stringify_table(info_or_device) + ) +end + +---@return { accessToken: string, expiresAt: number }? the token if a currently valid token is available, nil if not +---@return "token expired"|"no token"|"not supported"|"not connected"|nil reason the reason a token was not provided, nil if there is a valid token available +function SonosDriver:get_oauth_token() + if api_version < 14 or security == nil then + return nil, "not supported" + end + + if not self:oauth_app_connected() then + return nil, "not connected" + end + + if self.oauth.token then + local expiration = math.floor(self.oauth.token.expiresAt / 1000) + local now = os.time() + -- token has not expired yet + if now < expiration then + return self.oauth.token + else + return nil, "token expired" + end + end + + return nil, "no token" +end + +function SonosDriver:wait_for_oauth_token(timeout) + if api_version < 14 or security == nil then + return nil, "not supported" + end + + if not self:oauth_app_connected() then + return nil, "not connected" + end + + -- See if a valid token is already available + local maybe_token, _ = self:get_oauth_token() + if maybe_token then + -- return the valid token + return maybe_token + end + -- Subscribe to the token event bus. A new token has been/will be requested + -- by the token refresher task. + local token_bus, err = self:oauth_token_event_subscribe() + if token_bus then + token_bus:settimeout(timeout) + -- Wait for the new token to come in + token_bus:receive() + -- Call `SonosDriver:get_oauth_token` again to ensure the token is valid. + return self:get_oauth_token() + end + return nil, err +end + +function SonosDriver:oauth_app_connected() + return (api_version >= 14 and security ~= nil) + and self.oauth + and self.oauth.endpoint_app_info + and self.oauth.endpoint_app_info.state == "connected" +end + +--- Used to trigger the notification that the user must link their sonos account. +--- Will request a token a single time which will trigger preinstall isa flow. +function SonosDriver:alert_unauthorized() + if api_version < 14 or security == nil then + return + end + if self.have_alerted_unauthorized then + return + end + -- Do the request regardless if we think oauth is connected, because + -- there is a possibility that we have stale data. + local result, err = security.get_sonos_oauth() + if not result then + log.warn(string.format("Failed to alert unauthorized: %s", err)) + return + end + self.have_alerted_unauthorized = true +end + +---Create a cosock task that handles events from the persistent SSDP task. +---@param driver SonosDriver +---@param discovery_event_subscription cosock.Bus.Subscription +---@param oauth_token_subscription cosock.Bus.Subscription? +local function make_ssdp_event_handler( + driver, + discovery_event_subscription, + oauth_token_subscription +) + return function() + local unauthorized = {} + local discovered = {} + local receivers = { discovery_event_subscription } + if oauth_token_subscription ~= nil then + table.insert(receivers, oauth_token_subscription) + end + while true do + local recv_ready, _, select_err = cosock.socket.select(receivers, nil, nil) + + if recv_ready then + for _, receiver in ipairs(recv_ready or {}) do + if oauth_token_subscription ~= nil and receiver == oauth_token_subscription then + local token_evt, receive_err = oauth_token_subscription:receive() + if not token_evt then + log.warn(string.format("Error on token event bus receive: %s", receive_err)) + else + for _, event in pairs(unauthorized or {}) do + -- shouldn't need a nil check on the ssdp_task here since this whole function + -- won't get called unless the task is successfully spawned. + driver.ssdp_task:publish(event) + end + unauthorized = {} + end + end + if receiver == discovery_event_subscription then + ---@type { speaker_info: SpeakerDiscoveryInfo, force_refresh: boolean } + local event, recv_err = discovery_event_subscription:receive() + + if event then + local speaker_info = event.speaker_info + if + event.force_refresh + or not ( + unauthorized[speaker_info.unique_key] + or discovered[speaker_info.unique_key] + or driver.bonded_devices[speaker_info.mac_addr] + ) + then + local _, api_key = driver:check_auth(event.speaker_info) + local success, handle_err, err_code = + driver:handle_player_discovery_info(api_key, event.speaker_info) + if not success then + if err_code == "ERROR_NOT_AUTHORIZED" then + unauthorized[speaker_info.unique_key] = event + end + log.warn_with( + { hub_logs = false }, + string.format("Failed to handle discovered speaker: %s", handle_err) + ) + else + discovered[speaker_info.unique_key] = true + end + end + else + log.warn(string.format("Discovery event receive error: %s", recv_err)) + end + end + end + else + log.warn(string.format("SSDP Event Handler Select Error: %s", select_err)) + end + end + end, + "SSDP Event Handler" +end + +function SonosDriver:start_ssdp_event_task() + if self.ssdp_task ~= nil then + return + end + cosock.spawn(function () + while self:start_ssdp_event_task_inner() == false do + cosock.socket.sleep(30) + end + end) +end + +function SonosDriver:start_ssdp_event_task_inner() + local ssdp_task, err = sonos_ssdp.spawn_persistent_ssdp_task() + if err then + log.error_with({ hub_logs = true }, string.format("Unable to create SSDP task: %s", err)) + end + if ssdp_task then + self.ssdp_task = ssdp_task + local ssdp_task_subscription = ssdp_task:subscribe() + local oauth_token_subscription, subscribe_err = self:oauth_token_event_subscribe() + if subscribe_err then + log.warn(string.format("Couldn't subscribe to OAuth Token Events: %s", subscribe_err)) + end + self.ssdp_event_thread_handle = + cosock.spawn(make_ssdp_event_handler(self, ssdp_task_subscription, oauth_token_subscription)) + return true + end + return false +end + +---@param api_key string +---@param info SpeakerDiscoveryInfo +---@param device SonosDevice? +---@return boolean|nil response nil or false on failure +---@return nil|string error the error reason on failure, nil on success +---@return nil|string error_code the Sonos error code, if available +function SonosDriver:handle_player_discovery_info(api_key, info, device) + local discovery_info_mac_addr = info.mac_addr + local bonded = info:is_bonded() + self.bonded_devices[discovery_info_mac_addr] = bonded + + local maybe_device = self:get_device_by_dni(discovery_info_mac_addr) + if maybe_device then + maybe_device:set_field(PlayerFields.BONDED, bonded, { persist = false }) + self:update_bonded_device_tracking(maybe_device) + end + + api_key = api_key or self:get_fallback_api_key() + + local rest_url = info.rest_url + local maybe_token, no_token_reason = self:get_oauth_token() + local headers = SonosApi.make_headers(api_key, maybe_token and maybe_token.accessToken) + local response, response_err = + SonosApi.RestApi.get_groups_info(rest_url, info.household_id, headers) + + if response_err then + return nil, string.format("Error while making REST API call: %s", response_err) + end + + if response and response._objectType == "globalError" then + local additional_info = response.reason or response.wwwAuthenticate + local error_string = string.format( + '`getGroups` response error for player "%s":\n\tError Code: %s', + info.name, + response.errorCode + ) + + if additional_info then + error_string = + string.format("%s\n\tadditional information: %s", error_string, additional_info) + end + + if no_token_reason then + error_string = + string.format("%s\n\tinvalid token information: %s", error_string, no_token_reason) + end + + return nil, error_string, response.errorCode + end + + local sw_gen = info.sw_gen + local is_s1 = sw_gen == 1 + local response_valid + if is_s1 then + response_valid = type(response) == "table" + and type(response.groups) == "table" + and type(response.players) == "table" + else + response_valid = response and response._objectType == "groups" + end + if not response_valid then + return nil, + string.format( + "Unexpected response type to group info request: %s", + st_utils.stringify_table(response) + ) + end + + --- @cast response SonosGroupsResponseBody + self.sonos:update_household_info(info.household_id, response, self) + + local device_to_update, device_mac_addr + + local maybe_device_id = self.sonos:get_device_id_for_player(info.household_id, info.player_id) + + if device then + device_to_update = device + device_mac_addr = device_to_update.device_network_id + elseif maybe_device_id then + local maybe_device_from_uuid = self:get_device_info(maybe_device_id) + if maybe_device_from_uuid then + device_to_update = maybe_device_from_uuid + device_mac_addr = device_to_update.device_network_id + end + end + + if not device_mac_addr then + if not (info and info.mac_addr) then + return nil, st_utils.stringify_table(info, "Sonos Discovery Info has unexpected structure") + end + device_mac_addr = discovery_info_mac_addr + end + + if not device_to_update then + local maybe_device_from_dni = self:get_device_by_dni(device_mac_addr) + if maybe_device_from_dni then + device_to_update = maybe_device_from_dni + end + end + + if device_to_update then + self.dni_to_device_id[device_mac_addr] = device_to_update.id + self.sonos:associate_device_record(device_to_update, info) + elseif not bonded then + local name = info.name or info.model_display_name or "Unknown Sonos Player" + local model = info.model_display_name or "Unknown Sonos Model" + local try_create_message = { + type = "LAN", + device_network_id = device_mac_addr, + manufacturer = "Sonos", + label = name, + model = model, + profile = "sonos-player", + vendor_provided_label = info.model, + } + + self:try_create_device(try_create_message) + end + return true +end + +---@param driver SonosDriver +---@param device SonosDevice +---@param cmd any +local function do_refresh(driver, device, cmd) + log.trace("Refreshing " .. device.label) + local sonos_conn = device:get_field(PlayerFields.CONNECTION) + if sonos_conn == nil then + log.error( + string.format("Failed to do refresh, no sonos connection for device: [%s]", device.label) + ) + return + end + + if not sonos_conn:is_running() then + sonos_conn:start() + end + + -- refreshing subscriptions should refresh all relevant data on those channels as well + -- via the subscription confirmation events. + sonos_conn:refresh_subscriptions() +end + +--- In RPC version 100, the events for augmented driver store were changed +--- to no longer match the parsing done in API version 18 and below. +--- +--- API version 19 will handle both before and after RPC 100 changes so this only needs +--- to be applied for RPC version >= 100 and API version <= 18. +if rpc_version >= 100 and api_version <= 18 then + log.info_with({ hub_logs = true }, "Overriding environment info handler for RPC >= 100 and API <= 18") + function SonosDriver:environment_info_handler(channel) + log.info_with({ hub_logs = true }, "Starting environment info handler for RPC >= 100 and API <= 18") + local msg_type, msg_val = channel:receive() + -- This driver only cares about augmentDriverStore messages currently. + -- Previously, this was augmentDatastore msg_type. + if msg_type == "augmentDriverStore" then + if type(msg_val.payload) ~= "table" then + log.warn( + string.format( + "Unexpected augmentDriverStore payload type: %s", + type(msg_val.payload) + ) + ) + return + end + -- The field evt_kind was renamed to kind and is a string of the enum rather than + -- the integer value of the enum. + if msg_val.kind == "Upsert" then + -- This type was changed to table of u8s instead of a string + local data = "" + for _, v in pairs(msg_val.payload.data_value) do + data = data .. string.char(v) + end + self.hub_augmented_driver_data[msg_val.payload.data_key] = data + -- notify with the updated record + if self.notify_augmented_data_changed ~= nil then + self:notify_augmented_data_changed("upsert", msg_val.payload.data_key, data) + end + elseif msg_val.kind == "Delete" then + self.hub_augmented_driver_data[msg_val.payload.data_key] = nil + -- notify with just the key that got deleted + if self.notify_augmented_data_changed ~= nil then + self:notify_augmented_data_changed("delete", msg_val.payload.data_key) + end + else + log.warn( + string.format( + "Unexpected augmentDriverStore kind: %s", + msg_val.kind + ) + ) + end + end + end +end + +function SonosDriver.new_driver_template() + local oauth_token_bus = cosock.bus() + local oauth_info_bus = cosock.bus() + + local template = { + sonos = SonosState.instance(), + discovery = SonosDisco.discover, + oauth_token_bus = oauth_token_bus, + oauth_info_bus = oauth_info_bus, + oauth = {}, + have_alerted_unauthorized = false, + startup_state_received = false, + devices_waiting_for_startup_state = {}, + bonded_devices = utils.new_mac_address_keyed_table(), + dni_to_device_id = utils.new_mac_address_keyed_table(), + lifecycle_handlers = SonosDriverLifecycleHandlers, + -- Only overriden in the case of RPC version 100+ with API version <= 18 + environment_info_handler = SonosDriver.environment_info_handler, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + }, + [capabilities.audioMute.ID] = { + [capabilities.audioMute.commands.mute.NAME] = CmdHandlers.handle_mute, + [capabilities.audioMute.commands.unmute.NAME] = CmdHandlers.handle_unmute, + [capabilities.audioMute.commands.setMute.NAME] = CmdHandlers.handle_set_mute, + }, + [capabilities.audioNotification.ID] = { + [capabilities.audioNotification.commands.playTrack.NAME] = CmdHandlers.handle_audio_notification, + [capabilities.audioNotification.commands.playTrackAndResume.NAME] = CmdHandlers.handle_audio_notification, + [capabilities.audioNotification.commands.playTrackAndRestore.NAME] = CmdHandlers.handle_audio_notification, + }, + [capabilities.audioVolume.ID] = { + [capabilities.audioVolume.commands.volumeUp.NAME] = CmdHandlers.handle_volume_up, + [capabilities.audioVolume.commands.volumeDown.NAME] = CmdHandlers.handle_volume_down, + [capabilities.audioVolume.commands.setVolume.NAME] = CmdHandlers.handle_set_volume, + }, + [capabilities.mediaGroup.ID] = { + [capabilities.mediaGroup.commands.groupVolumeUp.NAME] = CmdHandlers.handle_group_volume_up, + [capabilities.mediaGroup.commands.groupVolumeDown.NAME] = CmdHandlers.handle_group_volume_down, + [capabilities.mediaGroup.commands.setGroupVolume.NAME] = CmdHandlers.handle_group_set_volume, + [capabilities.mediaGroup.commands.muteGroup.NAME] = CmdHandlers.handle_group_mute, + [capabilities.mediaGroup.commands.unmuteGroup.NAME] = CmdHandlers.handle_group_unmute, + [capabilities.mediaGroup.commands.setGroupMute.NAME] = CmdHandlers.handle_group_set_mute, + }, + [capabilities.mediaPlayback.ID] = { + [capabilities.mediaPlayback.commands.play.NAME] = CmdHandlers.handle_play, + [capabilities.mediaPlayback.commands.pause.NAME] = CmdHandlers.handle_pause, + [capabilities.mediaPlayback.commands.stop.NAME] = CmdHandlers.handle_pause, + }, + [capabilities.mediaPresets.ID] = { + [capabilities.mediaPresets.commands.playPreset.NAME] = CmdHandlers.handle_play_preset, + }, + [capabilities.mediaTrackControl.ID] = { + [capabilities.mediaTrackControl.commands.nextTrack.NAME] = CmdHandlers.handle_next_track, + [capabilities.mediaTrackControl.commands.previousTrack.NAME] = CmdHandlers.handle_previous_track, + }, + }, + } + + for k, v in pairs(SonosDriver or {}) do + template[k] = v + end + + return template +end + +return SonosDriver diff --git a/drivers/SmartThings/sonos/src/sonos_state.lua b/drivers/SmartThings/sonos/src/sonos_state.lua new file mode 100644 index 0000000000..faf466c6a3 --- /dev/null +++ b/drivers/SmartThings/sonos/src/sonos_state.lua @@ -0,0 +1,610 @@ +local log = require "log" +local st_utils = require "st.utils" + +local utils = require "utils" + +local CapEventHandlers = require "api.event_handlers" +local PlayerFields = require "fields".SonosPlayerFields +local SonosConnection = require "api.sonos_connection" + +--- @class SonosHousehold +--- Information on an entire Sonos system ("household"), such as its current groups, list of players, etc. +--- @field public id HouseholdId +--- @field public groups table All of the current groups in the system +--- @field public players table All of the current players in the system +--- @field public bonded_players table PlayerID's in this map that map to true are non-primary bonded players, and not controllable. +--- @field public player_to_group table quick lookup from Player ID -> Group ID +--- @field public st_devices table Player ID -> ST Device Record UUID information for the household +--- @field public favorites SonosFavorites all of the favorites/presets in the system +local _household_mt = {} + +function _household_mt:reset() + self.groups = utils.new_case_insensitive_table() + self.players = utils.new_case_insensitive_table() + self.player_to_group = utils.new_case_insensitive_table() + -- previously bonded devices should not be un-bonded after a reset since these should + -- not be treated as distinct devices + if not self.bonded_players then + self.bonded_players = utils.new_case_insensitive_table() + end +end + +_household_mt.__index = _household_mt +_household_mt.__metatable = "SonosHousehold" + +local function make_sonos_household(id) + local ret = setmetatable({ + id = id, + st_devices = utils.new_case_insensitive_table(), + favorites = {}, + }, _household_mt) + ret:reset() + return ret +end + +--- @class Households: { [HouseholdId]: SonosHousehold } +--- @field public get_or_init fun(self: Households, id: HouseholdId): SonosHousehold + +---@return Households +local function make_households_table() + local households_table_inner = utils.new_case_insensitive_table() + + local households_table = setmetatable({}, { + __index = function(_, key) + return households_table_inner[key] + end, + __newindex = function(_, key, value) + households_table_inner[key] = value + end, + __metatable = "SonosHouseholds", + }) + + function households_table:get_or_init(id) + local household = self[id] + if household then + return household + end + + household = make_sonos_household(id) + self[id] = household + return household + end + + return households_table +end + +-- state singleton +local _STATE = { + ---@type Households + households = make_households_table(), + ---@type table + device_record_map = {}, +} + +--- Sonos systems local state +--- @class SonosState +local SonosState = {} +SonosState.__index = SonosState +SonosState.EMPTY_HOUSEHOLD = _STATE.households:get_or_init("__EMPTY") + +---@param device SonosDevice +---@param info SpeakerDiscoveryInfo +function SonosState:associate_device_record(device, info) + local household_id = info.household_id + local group_id = info.group_id + -- This is the device id even if the device is a seondary in a bonded set + local player_id = info.player_id + + local household = _STATE.households[household_id] + if not household then + log.error( + string.format( + "No record of Sonos household for device %s", + (device.label or device.id or "") + ) + ) + return + end + + if not group_id or #group_id == 0 then + group_id = household.player_to_group[player_id or ""] or "" + end + + local group = household.groups[group_id] + if not group then + log.error( + string.format( + "No record of Sonos group for device %s", + (device.label or device.id or "") + ) + ) + return + end + + local player_tbl = household.players[player_id] + local player = (player_tbl or {}).player + local sonos_device = (player_tbl or {}).device + + if not player then + log.error( + string.format( + "No record of Sonos player for device %s", + (device.label or device.id or "") + ) + ) + return + end + + household.st_devices[player_id] = device.id + + _STATE.device_record_map[device.id] = { + sonos_device_id = player_id, + group = group, + player = player, + household = household, + sonos_device = sonos_device, + } + + local bonded = household.bonded_players[player_id] and true or false + + local sw_gen_changed = + utils.update_field_if_changed(device, PlayerFields.SW_GEN, info.sw_gen, { persist = true }) + + if sw_gen_changed then + CapEventHandlers.handle_sw_gen(device, info.sw_gen) + end + + device:set_field(PlayerFields.REST_URL, info.rest_url:build(), { persist = true }) + + local sonos_conn = device:get_field(PlayerFields.CONNECTION) + local connected = sonos_conn ~= nil + local websocket_url_changed = utils.update_field_if_changed( + device, + PlayerFields.WSS_URL, + info.wss_url:build(), + { persist = true } + ) + + local should_stop_conn = connected and (bonded or websocket_url_changed) + + if should_stop_conn then + sonos_conn:stop() + sonos_conn = nil + device:set_field(PlayerFields.CONNECTION, nil) + end + + local household_id_changed = utils.update_field_if_changed( + device, + PlayerFields.HOUSEHOLD_ID, + household.id, + { persist = true } + ) + + local player_id_changed = + utils.update_field_if_changed(device, PlayerFields.PLAYER_ID, player_id, { persist = true }) + + local need_refresh = connected + and (websocket_url_changed or household_id_changed or player_id_changed) + + if not bonded and sonos_conn == nil then + sonos_conn = SonosConnection.new(device.driver, device) + device:set_field(PlayerFields.CONNECTION, sonos_conn) + sonos_conn:start() + need_refresh = false + end + + if need_refresh and sonos_conn then + sonos_conn:refresh_subscriptions() + end + + self:update_device_record_group_info(household, group, device) + + -- device can't be controlled, mark the device as being offline. + if bonded then + device:offline() + end +end + +---@param household SonosHousehold +---@param group SonosGroupInfo +---@param device SonosDevice +function SonosState:update_device_record_group_info(household, group, device) + local player_id = device:get_field(PlayerFields.PLAYER_ID) + local bonded = ((household or {}).bonded_players or {})[player_id] and true or false + local group_role + if bonded then + group_role = "auxilary" + elseif + ( + type(household) == "table" + and type(household.groups) == "table" + and player_id + and group + and group.id + and group.coordinator_id + ) and player_id == group.coordinator_id + then + local player_ids_list = (household.groups[group.id] or {}).player_ids or {} + if #player_ids_list > 1 then + group_role = "primary" + else + group_role = "ungrouped" + end + else + group_role = "auxilary" + end + + local field_changed = + utils.update_field_if_changed(device, PlayerFields.GROUP_ID, group.id, { persist = true }) + if not bonded and field_changed then + CapEventHandlers.handle_group_id_update(device, group.id) + end + + field_changed = + utils.update_field_if_changed(device, PlayerFields.GROUP_ROLE, group_role, { persist = true }) + if not bonded and field_changed then + CapEventHandlers.handle_group_role_update(device, group_role) + end + + field_changed = utils.update_field_if_changed( + device, + PlayerFields.COORDINATOR_ID, + group.coordinator_id, + { persist = true } + ) + if not bonded and field_changed then + CapEventHandlers.handle_group_coordinator_update(device, group.coordinator_id) + end + + if bonded then + device:offline() + end +end + +function SonosState:remove_device_record_association(device) + _STATE.device_record_map[device.id] = nil +end + +---@param household_id HouseholdId +---@param player_id PlayerId +---@return string? device_id +function SonosState:get_device_id_for_player(household_id, player_id) + if type(player_id) ~= "string" then + log.error(string.format("invalid player id provided: %s", player_id)) + return nil + end + local household = _STATE.households[household_id] + if not household then + log.error(string.format("No record of Sonos household with id %s", household_id)) + return + end + return household.st_devices[player_id] +end + +---@param id HouseholdId +---@return SonosHousehold +function SonosState:get_household(id) + if type(id) ~= "string" then + log.error(string.format("invalid household id provided: %s", id)) + return nil + end + return _STATE.households[id] +end + +--- @param id HouseholdId +--- @param favorites SonosFavorites +function SonosState:update_household_favorites(id, favorites) + local household = _STATE.households:get_or_init(id) + household.favorites = favorites or {} +end + +--- @param household_id HouseholdId +--- @param device SonosDevice +function SonosState:update_device_record_from_state(household_id, device) + local current_mapping = _STATE.device_record_map[device.id] or {} + local household = _STATE.households:get_or_init(household_id) + self:update_device_record_group_info(household, current_mapping.group, device) +end + +--- Helper function for when updating household info +---@param driver SonosDriver +---@param player SonosPlayerObject +---@param household SonosHousehold +---@param known_bonded_players table +---@param sonos_device_id PlayerId +local function update_device_info(driver, player, household, known_bonded_players, sonos_device_id) + ---@type SonosDeviceInfo + local device_info = { id = sonos_device_id, primary_device_id = player.id } + ---@type SonosPlayerInfo + local player_info = { id = player.id, websocket_url = player.websocketUrl } + household.players[sonos_device_id] = { + player = player_info, + device = device_info, + } + local previously_bonded = known_bonded_players[sonos_device_id] and true or false + local currently_bonded + local group_id + + -- The primary bonded player will have the same id as the top level player id + if sonos_device_id == player.id then + currently_bonded = false + else + currently_bonded = true + end + group_id = household.player_to_group[player.id] + household.player_to_group[sonos_device_id] = group_id + household.bonded_players[sonos_device_id] = currently_bonded + + local maybe_device_id = household.st_devices[sonos_device_id] + if maybe_device_id then + _STATE.device_record_map[maybe_device_id] = _STATE.device_record_map[maybe_device_id] or {} + _STATE.device_record_map[maybe_device_id].household = household + _STATE.device_record_map[maybe_device_id].group = household.groups[group_id] + _STATE.device_record_map[maybe_device_id].player = player_info + _STATE.device_record_map[maybe_device_id].sonos_device = device_info + if previously_bonded ~= currently_bonded then + local target_device = driver:get_device_info(maybe_device_id) + if target_device then + target_device:set_field(PlayerFields.BONDED, currently_bonded, { persist = false }) + driver:update_bonded_device_tracking(target_device) + end + end + end +end + +--- @param id HouseholdId +--- @param groups_event SonosGroupsResponseBody +--- @param driver SonosDriver +function SonosState:update_household_info(id, groups_event, driver) + local household = _STATE.households:get_or_init(id) + local known_bonded_players = household.bonded_players or {} + household:reset() + + local groups, players = groups_event.groups, groups_event.players + + for _, group in ipairs(groups or {}) do + household.groups[group.id] = + { id = group.id, coordinator_id = group.coordinatorId, player_ids = group.playerIds } + for _, playerId in ipairs(group.playerIds or {}) do + household.player_to_group[playerId] = group.id + end + end + + -- Iterate through the players and track all the devices associated with them + -- for bonded set tracking. + local log_devices_error = false + for _, player in ipairs(players or {}) do + -- Prefer devices because deviceIds is deprecated but all we care about is + -- the ID so either way is fine. + if type(player.devices) == "table" then + for _, device in ipairs(player.devices or {}) do + update_device_info(driver, player, household, known_bonded_players, device.id) + end + elseif type(player.deviceIds) == "table" then + for _, device_id in ipairs(player.deviceIds or {}) do + update_device_info(driver, player, household, known_bonded_players, device_id) + end + else + log_devices_error = true + -- We can still track the primary player in this case + update_device_info(driver, player, household, known_bonded_players, player.id) + end + end + if log_devices_error then + log.warn_with( + { hub_logs = true }, + "Group event contained neither devices nor deviceIds in player" + ) + end + + household.id = id + _STATE.households[id] = household +end + +--- @param household_id HouseholdId +--- @param player_id PlayerId +--- @return GroupId? group ID, nil for an invalid household ID +--- @return string? error nil on success +function SonosState:get_group_for_player(household_id, player_id) + log.debug_with( + { hub_logs = false }, + st_utils.stringify_table( + { household_id = household_id, player_id = player_id }, + "Get Group For Player", + true + ) + ) + if type(player_id) ~= "string" then + log.error(string.format("invalid player id provided: %s", player_id)) + return nil + end + local household = _STATE.households[household_id] + if household == nil then + log.error( + st_utils.stringify_table({ household = household }, "Get group for invalid household", false) + ) + return nil, + st_utils.stringify_table({ household = household }, "Get group for invalid household", false) + end + return household.player_to_group[player_id] +end + +--- @param household_id HouseholdId +--- @param player_id PlayerId +--- @return PlayerId?,string? +function SonosState:get_coordinator_for_player(household_id, player_id) + log.debug_with( + { hub_logs = false }, + st_utils.stringify_table( + { household_id = household_id, player_id = player_id }, + "Get Coordinator For Player", + true + ) + ) + if type(player_id) ~= "string" then + log.error(string.format("invalid player id provided: %s", player_id)) + return nil + end + return self:get_coordinator_for_group( + household_id, + self:get_group_for_player(household_id, player_id) + ) +end + +--- @param household_id HouseholdId +--- @param group_id GroupId +--- @return PlayerId?,string? +function SonosState:get_coordinator_for_group(household_id, group_id) + log.debug_with( + { hub_logs = false }, + st_utils.stringify_table( + { household_id = household_id, group_id = group_id }, + "Get Coordinator For Group", + true + ) + ) + if type(group_id) ~= "string" then + log.error(string.format("invalid group id provided: %s", group_id)) + return nil + end + local household = _STATE.households[household_id] + if household == nil then + log.error( + st_utils.stringify_table( + { household = household }, + "Get coordinator for invalid household", + false + ) + ) + return nil, + st_utils.stringify_table( + { household = household }, + "Get coordinator for invalid household", + false + ) + end + + local group = (household.groups or {})[group_id] + if type(group) ~= "table" then + log.error(string.format("No known group for id %s in household %s", group_id, household_id)) + return + end + + return group.coordinator_id +end + +--- @param device SonosDevice +--- @return HouseholdId|nil household_id nil on error +--- @return GroupId|nil group_id nil on error +--- @return PlayerId|nil player_id nil on error +--- @return nil|string error nil on success +function SonosState:get_sonos_ids_for_device(device) + local household_id, group_id, player_id = + device:get_field(PlayerFields.HOUSEHOLD_ID), + device:get_field(PlayerFields.GROUP_ID), + device:get_field(PlayerFields.PLAYER_ID) + + if household_id and group_id and player_id then + return household_id, group_id, player_id + end + + local sonos_objects = _STATE.device_record_map[device.id] + if not sonos_objects then + return nil, + nil, + nil, + string.format( + "No mapping to Sonos State for Device %s", + (device.label or device.id or "") + ) + end + + -- household id *should* be stable + if not household_id then + household_id = sonos_objects.household.id + device:set_field(PlayerFields.HOUSEHOLD_ID, household_id, { persist = true }) + end + + -- player id *should* be stable + if not player_id then + player_id = sonos_objects.sonos_device_id + device:set_field(PlayerFields.PLAYER_ID, player_id, { persist = true }) + end + + -- group id is ephemeral so we always update it here if it wasn't cached. + group_id = sonos_objects.group.id + device:set_field(PlayerFields.GROUP_ID, group_id, { persist = true }) + + return household_id, group_id, player_id +end + +--- @param device SonosDevice +--- @return HouseholdId|nil household_id nil on error +--- @return PlayerId|nil player_id nil on error +--- @return nil|string error nil on success +function SonosState:get_player_for_device(device) + if type(device) ~= "table" then + return nil, string.format("Invalid device argument for get_player_for_device: %s", device) + end + local household_id, _, player_id, err = self:get_sonos_ids_for_device(device) + if err then + return nil, nil, err + end + return household_id, player_id, nil +end + +--- @param device SonosDevice +--- @return HouseholdId|nil household_id nil on error +--- @return GroupId|nil group_id nil on error +--- @return nil|string error nil on success +function SonosState:get_group_for_device(device) + if type(device) ~= "table" then + return nil, string.format("Invalid device argument for get_group_for_device: %s", device) + end + local household_id, group_id, _, err = self:get_sonos_ids_for_device(device) + if err then + return nil, nil, err + end + return household_id, group_id, nil +end + +--- @param device SonosDevice +--- @return HouseholdId|nil household_id nil on error +--- @return PlayerId|nil coordinator_id nil on error +--- @return nil|string error nil on success +function SonosState:get_coordinator_for_device(device) + if type(device) ~= "table" then + return nil, string.format("Invalid device argument for get_coordinator_for_device: %s", device) + end + local household_id, group_id, _, err = self:get_sonos_ids_for_device(device) + if err then + return nil, nil, err + end + local household = _STATE.households[household_id] + if not household then + return nil, + nil, + string.format( + "Couldn't determine Sonos Household for device %s", + (device.label or device.id or "") + ) + end + local group = household.groups[group_id] + if not group then + return nil, + nil, + string.format( + "Couldn't determine Sonos Group for device %s", + (device.label or device.id or "") + ) + end + + return household_id, group.coordinator_id, nil +end + +---@return SonosState +function SonosState.instance() + return setmetatable({}, SonosState) +end + +return SonosState diff --git a/drivers/SmartThings/sonos/src/ssdp.lua b/drivers/SmartThings/sonos/src/ssdp.lua index d2308687ad..3e11b92d04 100644 --- a/drivers/SmartThings/sonos/src/ssdp.lua +++ b/drivers/SmartThings/sonos/src/ssdp.lua @@ -1,194 +1,459 @@ -local socket = require "cosock.socket" +-- luacheck: globals Ok Err +local Headers = require "luncheon.headers" +local Stream = require "cosock.stream" local log = require "log" -local st_utils = require "st.utils" +local socket = require "cosock.socket" + +local SSDP_MULTICAST_IP = "239.255.255.250" +local SSDP_MULTICAST_PORT = 1900 +local SSDP_DEFAULT_NUM_SENDS = 5 +local SSDP_DEFAULT_MX = 5 -SONOS_SSDP_SEARCH_TERM = "urn:smartspeaker-audio:service:SpeakerGroup:1" +--- @alias SsdpSearchTerm string +--- @alias SsdpSearchResponse Headers +--- @alias SsdpSearchMatches SsdpSearchResponse[] ---- @module 'sonos.SSDP' -local SSDP = {} +--- @class SsdpSearchTermContext +--- @field public enabled boolean +--- @field public required_headers string[]? +--- @field public validator (fun(match: SsdpSearchResponse): boolean?,string?)? +--- @field public post_processor (fun(match: SsdpSearchResponse): any)? -local function process_response(val) +---@class (exact) SsdpSearchKwargs +--- +--- A list of strings containing headers that must be present for the search response to be considered +--- as applying to this search request. These both required search terms and a validator are provided, the +--- required search terms will be checked first. +---@field public required_headers string[]? +--- +--- An optional callback that will be run over every response whose search term matches the one provided. +--- It should return `true` if the response is valid. Otherwise, it should return `nil, string` where string is the reason +--- that the validation failed. +---@field public validator (fun(match: SsdpSearchResponse): boolean?,string?) +--- +--- An optional callback that will be called on any response to this search term that passes the required headers and +--- validator checks, to transform it in to something else. +---@field public post_processor (fun(match: SsdpSearchResponse): any)? + +--- @class (exact) Ssdp +local Ssdp = {} + +---Parses the raw UDP response following the SSDP M-Search response spec, +---which is effectively an HTTP/1.1 Header payload. +---@param val string the raw response +---@return Headers? the parsed headers, minus the status line, nil on failure +---@return string? error error message, nil on success +---@diagnostic disable-next-line: inject-field +function Ssdp.parse_raw_response(val) -- check first line assuming it's the HTTP Status Line, which if not is invalid local status_line = string.match(val, "([^\r\n]*)\r\n") - if not (status_line and string.match(status_line, "HTTP/1.1 200 OK")) then - return nil, string.format("SSDP Response HTTP Status Line missing or not '200 OK': %q", status_line) + if not (status_line and string.match(status_line, "HTTP/1.1 200 OK")) then + return nil, + string.format("SSDP Response HTTP Status Line missing or not '200 OK': %q", status_line) end -- strip status line from payload val = string.gsub(val, "HTTP/1.1 200 OK\r\n", "", 1) - local info = {} - -- iterate line-by-line by splitting on `\r\n` - for l in string.gmatch(val, "([^\r\n]*)\r\n") do - if l == nil or l == "" then - break + return Headers.from_chunk(val) +end + +---@param headers Headers the headers to check +---@param keys_to_check string[] list of header keys whose presence should be validated +---@return boolean success true if all headers are present, false if not +---@return string[] missing the headers that are missing +---@diagnostic disable-next-line: inject-field +function Ssdp.check_headers_contain(headers, keys_to_check) + local missing = {} + for _, header_key in ipairs(keys_to_check or {}) do + if headers:get_one(header_key) == nil then + table.insert(missing, header_key) end - -- SSDP Messages use the HTTP/1.1 Header Field rules described in RFC 2616, 4.2: https://datatracker.ietf.org/doc/html/rfc2616#section-4.2 - -- This pattern extracts the Key/Value pairs in to a Lua table via the two capture groups. - -- The key capture group is composed entirely of a negating matcher to exclude illegal characters, ending at the `:`. - -- The RFC states that after the colon there may be any arbitrary amount of leading space between the colon - -- and the value, and that the value shouldn't have any trailing whitespace, so we exclude those as well. - -- The original Luncheon implementation of this Lua Pattern used iteration and detected the `;` separator - -- that indicates key/value parameters, however, we don't make that distinction here and instead leave parsing - -- values with parameters to the consumers of the output of this function. - local k, v = string.match(l, '([^%c()<>@,;:\\"/%[%]?={} \t]+):%s*(.-)%s*$') - if k == nil or k == "" then - return nil, string.format("Couldn't parse header/value pair for line %q", l) + end + + return (#missing == 0), missing +end + +--- @class AsyncSearchResultStream +--- @field package scanning boolean +--- @field package handle SsdpSearchHandle +--- @field package queries_sent integer +--- @field package num_multicast_queries integer +--- @field package search_start_time integer +--- @field package elapsed_since_last_send integer +--- @field package prev_send_timestamp integer +local _stream_mt = {} +_stream_mt.__index = _stream_mt + +---@param self AsyncSearchResultStream +---@return Result? Will return Ok(...) with new results, or Err(...) for an internal error that shouldn't terminate the generator +---@return nil|string reason reason for iteration termination +function _stream_mt:next() + if not (self.scanning and self.handle) then + return nil, "invalid state" + end + + -- SSDP spec recommends sending 3-5 queries, 1 second apart. We allow for + -- the number of sends to be configurable via `self.num_multicast_queries. + -- We retain an interval of 1 second. Since this is an iterator implementation, + -- we statefully update some time-keeping in the table every time the iterator is called, + -- opening the loop with a multicast query if one second has elapsed. + local should_send = self.queries_sent <= self.num_multicast_queries + and self.elapsed_since_last_send >= 1 + if should_send then + log.trace("Performing M-SEARCH Multicast") + self.handle:multicast_m_search() + self.queries_sent = self.queries_sent + 1 + self.elapsed_since_last_send = 0 + self.prev_send_timestamp = socket.gettime() + else + self.elapsed_since_last_send = (socket.gettime() - self.prev_send_timestamp) + end + + local recv_ready, _, select_err = socket.select({ self.handle }, nil, self.handle.time_remaining) + + if select_err == "timeout" then + local next_time_remaining = math.max(0, self.handle.search_end_time - socket.gettime()) + -- timeout + done criteria + if next_time_remaining == 0 and self.queries_sent >= self.num_multicast_queries then + log.trace( + "All queries sent and search time limit has elapsed, terminating search result stream" + ) + return nil, "complete" end - info[string.lower(k)] = v + -- timeout, done criteria *not* satisfied + return Err("timeout"), nil + end + + if select_err and select_err ~= "timeout" then + return Err(string.format("select error: %s", select_err)), nil + end + + if not (type(recv_ready) == "table" and recv_ready[1] == self.handle) then + return Err("SSDP Search Handle not returned in recv table from socket.select"), nil + end + + local maybe_response = self.handle:next_msearch_response() + if maybe_response == nil and self.queries_sent >= self.num_multicast_queries then + return nil, "complete" end - return info + + return maybe_response +end + +---@class SsdpSearchHandle +---@field package sock table the wrapped udp socket +---@field private inner_sock table reference to the raw luasocket held on to by the udp socket +---@field package mx integer the MX value for the search handle, shared by all terms registered with this instance. +---@field package search_terms table +---@field package search_end_time integer? +---@field package time_remaining integer? +local _ssdp_mt = {} +_ssdp_mt.__index = _ssdp_mt + +function _ssdp_mt:get_current_mx_seconds() + return self.mx +end + +---Registers a new search term in the "enabled" configuration, meaning that stepping the searcher will +---multicast that term in requests, when necessary, and look for responses on UDP receive. +---@param term SsdpSearchTerm +---@param kwargs SsdpSearchKwargs +function _ssdp_mt:register_search_term(term, kwargs) + self.search_terms[term] = { + enabled = false, + required_headers = (kwargs and kwargs.required_headers) or {}, + post_processor = kwargs and kwargs.post_processor, + validator = kwargs and kwargs.validator, + } end -function SSDP.check_headers_contain(response, ...) - if not (response and next(response) ~= nil) then return false end - local header_vals = table.pack(...) - for _, header in ipairs(header_vals) do - if header ~= nil then - if not response[header] then - log.warn("No header available for key " .. st_utils.stringify_table(header)) - return false - end +---Sets an already registered search term as enabled. Search requests for this term will be sent, +---and replies that match this term will be processed during respone handling. +---@param term SsdpSearchTerm +function _ssdp_mt:enable_search_term(term) + if self.search_terms[term] == nil then + log.warn(string.format("Attempt to disable unregistered search term %s, ignoring", term)) + end + + self.search_terms[term].enabled = true +end + +---Keeps the search term registered with the instance, but disable it. Search requests will +---not be sent, and replies for this term will be ignored during handling of responses. +---@param term SsdpSearchTerm +function _ssdp_mt:disable_search_term(term) + if self.search_terms[term] == nil then + log.warn(string.format("Attempt to disable unregistered search term %s, ignoring", term)) + end + + self.search_terms[term].enabled = false +end + +--- Multicasts an M-SEARCH query for all registered search terms. If this happens, a timeout +--- based on the MX value will be applied to future select/read calls. +--- +--- This function is semi-reentrant; if you call `multicast_m_search` before the search time +--- of the previous broadcast has elapsed, then another search query will be broadcast and +--- the search end time will be pushed out based on the `mx` parameter, extending the amount of +--- time that `receive_m_search_response` will return results with a timeout. +function _ssdp_mt:multicast_m_search() + for term, _ in pairs(self.search_terms or {}) do + local multicast_msg = table.concat({ + "M-SEARCH * HTTP/1.1", + "HOST: 239.255.255.250:1900", + 'MAN: "ssdp:discover"', -- yes, there are really supposed to be quotes in this one + string.format("MX: %s", self.mx), + string.format("ST: %s", term), + "\r\n", + }, "\r\n") + + local _, send_err = self.sock:sendto(multicast_msg, SSDP_MULTICAST_IP, SSDP_MULTICAST_PORT) + if send_err then + log.error(string.format("Send error broadcasting search terms: %s", send_err)) end end - return true + self.search_end_time = socket.gettime() + (self.mx + 1) + self.time_remaining = self.mx + 1 + self:settimeout(self.time_remaining) end -function SSDP.search(search_term, callback) - log.debug(string.format("Beginning SSDP search for search term %s", search_term)) - local s, err = socket.udp() - if err then - log.error(string.format("udp socket creation failure: %s", err)) - return +--- If `multicast_m_search()` has been called, handle the next M-SEARCH response while the MX +--- window is active. +--- +--- Return an `Ok` with a response on success, and an `Err` with message on failure. +--- If the search is complete, returns `nil`. +--- +---`Ok` on success, `Err` on failure, `nil` if the search is complete. If a post processor is +---registered for the search term, the `Ok` will contain the transformed value. Otherwise, the +---parsed HTTP headers will be returned. +---@return Result? +function _ssdp_mt:next_msearch_response() + local time_remaining = ( + self.search_end_time and math.max(0, self.search_end_time - socket.gettime()) + ) or nil + self.time_remaining = time_remaining + self.sock:settimeout(time_remaining) + + local response, recv_ip_or_err, _ = self.sock:receivefrom() + + if recv_ip_or_err == "timeout" and time_remaining == 0 then + self.search_end_time = nil + return nil end - local listen_ip = "0.0.0.0" - local listen_port = 0 + if response == nil then + return Err(string.format("UDP receive error: %s", recv_ip_or_err)) + end + + local headers, parse_err = Ssdp.parse_raw_response(response) + + if parse_err then + return Err(string.format("parse error: %s", parse_err)) + end - local multicast_ip = "239.255.255.250" - local multicast_port = 1900 - local mx = 5 - local multicast_msg = table.concat({ - "M-SEARCH * HTTP/1.1", - "HOST: 239.255.255.250:1900", - 'MAN: "ssdp:discover"', -- yes, there are really supposed to be quotes in this one - string.format("MX: %s", mx), - string.format("ST: %s", search_term), - "\r\n" - }, "\r\n") - - -- bind local ip and port - -- device will unicast back to this ip and port - local _, err = s:setsockname(listen_ip, listen_port) - if err then - log.error(string.format("udp socket failure setsockname: %s", err)) - return - end - local timeouttime = socket.gettime() + (mx + 1) -- 3 second timeout, `MX` + 1 for network delay - - -- local deviceid = "placeholder" - - log.debug("sending discovery multicast request") - local _, err = s:sendto(multicast_msg, multicast_ip, multicast_port) - if err then - log.error(string.format("udp socket failure sendto: %s", err)) - return - end - - while true do - local time_remaining = math.max(0, timeouttime - socket.gettime()) - s:settimeout(time_remaining) - local val, rip, _ = s:receivefrom() - - if val then - local headers, err = process_response(val) - - if err ~= nil then - log.error(err or "Unknown error while parsing SSDP response headers") - goto continue - end - - if headers == nil then - log.error("No headers found in SSDP response") - goto continue - end - - if headers["st"] ~= search_term then - log.trace("Received SSDP response for different search term, skipping.") - goto continue - end - - -- log all parseable SSDP responses for the search term, - -- even if they don't have proper headers. - log.debug_with({ hub_logs = true }, - string.format("Received response for Sonos search with headers [%s], processing details", - st_utils.stringify_table(headers))) - if - -- we don't explicitly check "st" because we don't index in to the contained - -- value so the equality check suffices as a nil check as well. - SSDP.check_headers_contain( - headers, - "server", - "location", - "groupinfo.smartspeaker.audio", - "websock.smartspeaker.audio", - "household.smartspeaker.audio") and headers["server"]:find("Sonos") - then - local ip = - headers["location"]:match("http://([^,/]+):[^/]+/.+%.xml") - - local is_group_coordinator, group_id, group_name = - headers["groupinfo.smartspeaker.audio"]:match("gc=(.*); gid=(.*); gname=\"(.*)\"") - - local household_id = headers["household.smartspeaker.audio"] - local wss_url = headers["websock.smartspeaker.audio"] - - local group_info = { - ip = ip, - is_group_coordinator = (tonumber(is_group_coordinator) == 1), - group_id = group_id, - group_name = group_name, - household_id = household_id, - wss_url = wss_url - } - - if rip ~= ip then - log.warn(string.format( - "[%s] received discovery response with reported (%s) & source IP (%s) mismatch, ignoring", - group_id, rip, ip)) - log.debug(rip, "!=", ip) - elseif ip and is_group_coordinator and group_id and - group_name and household_id and wss_url then - if #group_id == 0 then - log.debug_with({ hub_logs = true }, string.format( - "Received SSDP response for non-primary Sonos device in a bonded set, skipping; SSDP Response: %s\n", - st_utils.stringify_table(group_info, nil, false))) - elseif callback ~= nil then - if type(callback) == "function" then - callback(group_info) - else - log.warn(string.format( - "Expected a function in callback argument position for `SSDP.search`, found argument of type %s", - type(callback))) - end - end - else - log.warn( - "Received incomplete Sonos SSDP M-SEARCH Reply, retrying search") - log.debug(string.format("%s", st_utils.stringify_table( - group_info, "SSDP Reply", true))) - end - end - elseif rip == "timeout" then - log.warn("SSDP Search Timeout") - break + if headers == nil then + return Err("SSDP M-Search reply contained no header lines") + end + + local response_search_term = headers:get_one("st") + + if not (type(response_search_term) == "string" and self.search_terms[response_search_term]) then + return Err( + string.format( + "SSDP Reply search term %s didn't match any registered search terms", + response_search_term + ) + ) + end + + -- SSDP replies will have a `Location:` header that contains a URI, and *can* contain `AL:` header(s) + -- that point to alternative locations. We'll check them all for an IP address that matches the IP we got + -- back from receivefrom. Technically, Location or AL could be hostnames intead of IP's. But since we + -- don't have access to DNS in edge drivers, we don't generalize our logic for that, and we'd treat + -- a hostname as a mismatch. + local possible_locations = {} + local location_candidates = + table.pack(headers:get_one("location"), table.unpack(headers:get_all("al") or {})) + + if #location_candidates < 1 then + return Err( + string.format( + "Unable to locate any Location or AL header lines in SSDP response for term %s", + response_search_term + ) + ) + end + + for _, location in ipairs(location_candidates or {}) do + local location_host_match = location:match("http://([^,/]+):[^/]+/.+%.xml") + if location_host_match ~= nil then + possible_locations[location_host_match] = location + end + end + + if possible_locations[recv_ip_or_err] == nil then + return Err( + string.format( + "IP address [%s] from socket receivefrom doesn't match any of the reply locations: %s", + recv_ip_or_err, + table.concat(location_candidates, ", ") + ) + ) + end + + local ctx_for_response = self.search_terms[response_search_term] + + if ctx_for_response == nil then + return Err( + string.format("Search term %s doesn't have a valid handler context", response_search_term) + ) + end + + if ctx_for_response.required_headers and #ctx_for_response.required_headers > 0 then + local success, missing = Ssdp.check_headers_contain(headers, ctx_for_response.required_headers) + + if not success then + return Err( + string.format( + "Response for term %s was missing headers: %s", + response_search_term, + table.concat(missing, ", ") + ) + ) + end + end + + if type(ctx_for_response.validator) == "function" then + local valid, reason = ctx_for_response.validator(headers) + if not valid then + return Err( + string.format( + "Response to search term %s failed validation with reason: %s", + response_search_term, + reason + ) + ) + end + end + + if type(ctx_for_response.post_processor) == "function" then + return Ok(ctx_for_response.post_processor(headers)) + end + + return Ok(headers) +end + +---@param kind "recvr"|"sendr" +---@param waker fun()|nil +function _ssdp_mt:setwaker(kind, waker) + assert( + kind == "recvr", + string.format( + "setwaker: SSDP search can only wake on receive readiness, got unsupported wake kind: %s.", + kind + ) + ) + + assert( + self.waker_ref == nil or waker == nil, + "Waker already set, cannot await SSDP search instance from multiple locations." + ) + + self.waker_ref = waker + self._wake = function(ssdp_search_wrapper, wake_kind, ...) + assert( + wake_kind == "recvr", + string.format( + "wake: SSDP search can only wake on receive readiness, got unsupported wake kind: %s.", + wake_kind + ) + ) + if type(ssdp_search_wrapper.waker_ref) == "function" then + ssdp_search_wrapper.waker_ref(...) + ssdp_search_wrapper.waker_ref = nil + return true else - error(string.format( - "error receiving discovery replies for search term: %s", - rip)) + log.warn("Attempted to wake SSDP search socket with no waker set") + return false end - ::continue:: end - s:close() end -return SSDP +function _ssdp_mt:settimeout(timeout) + assert( + timeout == nil or type(timeout) == "number", + string.format("expected number|nil, for timeout, received %s", type(timeout)) + ) + self.timeout = timeout + self.sock:settimeout(timeout) +end + +---Create an async stream of results for a given search term configuration. Requires being +---inside a [`cosock`](lua://cosock) async runtime. +--- +---The returned table is an iterator; it is "callable", and each call is a cosock-enabled +---async function that will yield [`Result`s](lua://Result) until scan is complete. +--- +---It can be used in a `for` loop, just like any other iterator, as long as you're within the +---cosock runtime. +--- +---@return AsyncSearchResultStream stream the search stream if it was constructed successfully, nil on failure +function _ssdp_mt:as_stream() + local now = socket.gettime() + + local ssdp_stream_impl = setmetatable({ + handle = self, + scanning = true, + num_multicast_queries = SSDP_DEFAULT_NUM_SENDS, + queries_sent = 0, + search_start_time = now, + prev_send_timestamp = 0, + elapsed_since_last_send = 1, + }, _stream_mt) + + return Stream.wrap_next(ssdp_stream_impl) --[[ @as AsyncSearchResultStream ]] +end + +---Creates a new SSDP Search Handle Instance. +--- +---Optional MX value. The same MX value will be used for all search terms. If you need different MX values for different terms, +---you should create multiple instances. +---@param mx integer? Defaults to [`SSDP_DEFAULT_MX`](lua://SSDP_DEFAULT_MX) +--- +---@return SsdpSearchHandle? the search handle on success, nil on failure +---@return string? error the error if the instance cannot be created. +---@diagnostic disable-next-line: inject-field +function Ssdp.new_search_instance(mx) + local udp_sock, sock_err = socket.udp() + if sock_err or not udp_sock then + log.error_with( + { hub_logs = true }, + string.format("Error opening UDP socket for SSDP search: %s", sock_err) + ) + return nil, sock_err + end + + local listen_ip = "0.0.0.0" + local listen_port = 0 + + local _, bind_err = udp_sock:setsockname(listen_ip, listen_port) + + if bind_err then + log.error_with( + { hub_logs = true }, + string.format("Unable to bind UDP socket for SSDP search: %s", bind_err) + ) + return nil, bind_err + end + + return setmetatable({ + search_terms = {}, + sock = udp_sock, + inner_sock = udp_sock.inner_sock, + mx = mx or SSDP_DEFAULT_MX, + search_end_time = nil, + }, _ssdp_mt), + nil +end + +return Ssdp diff --git a/drivers/SmartThings/sonos/src/token_refresher.lua b/drivers/SmartThings/sonos/src/token_refresher.lua new file mode 100644 index 0000000000..2ba3ad660e --- /dev/null +++ b/drivers/SmartThings/sonos/src/token_refresher.lua @@ -0,0 +1,152 @@ +local cosock = require "cosock" +local utils = require "utils" +local security = require "st.security" +local log = require "log" + +local module = {} + +local ACTIONS = { + -- This action waits for an event via the oauth endpoint app info bus in order to determine + -- when the driver goes from a disconnected to connected state. + WAIT_FOR_CONNECTED = 1, + -- This action will wait for the current valid token to expire. It will also handle a new token + -- event to redetermine the current action. New tokens that come in during this action will likely + -- be from debug testing. + WAIT_FOR_EXPIRE = 2, + -- This action requests a new token and waits for it to come in. + REQUEST_TOKEN = 3, +} + +local ACTION_STRINGIFY = { + [ACTIONS.WAIT_FOR_CONNECTED] = "wait for connected", + [ACTIONS.WAIT_FOR_EXPIRE] = "wait for expire", + [ACTIONS.REQUEST_TOKEN] = "request token", +} + +local Refresher = {} +Refresher.__index = Refresher + +--- Determine which action the refresher should take. +--- This just depends on: +--- - Is Oauth connected? +--- - Do we have a valid token? +function Refresher:determine_action() + if not self.driver:oauth_app_connected() then + -- Oauth is disconnected so no point in trying to request a token until we are connected. + return ACTIONS.WAIT_FOR_CONNECTED + end + local token, _ = self.driver:get_oauth_token() + if token then + local now = os.time() + local expiration = math.floor(token.expiresAt / 1000) + if (expiration - now) > 60 then + -- Token is valid and not expiring in the next 60 seconds. + return ACTIONS.WAIT_FOR_EXPIRE + end + end + -- We don't have a valid token or it is about to expire soon. + return ACTIONS.REQUEST_TOKEN +end + +--- Waits for a token event with a timeout. +--- @param timeout number How long the function will wait for a new token +function Refresher:try_wait_for_token_event(timeout) + local token_bus, err = self.driver:oauth_token_event_subscribe() + if err == "closed" then + self.token_bus_closed = true + end + if token_bus then + token_bus:settimeout(timeout) + token_bus:receive() + end +end + +--- Waits for the current token to expire or a new token event. +--- +--- The likely outcome of this function is to wait the entire expiration timeout. It will +--- also listen for token events just in case a new token with a new expiration is sent to the driver. +--- A new token would most likely come from developer testing, but since the new token requests are +--- not synchronous one could come from an earlier request. +function Refresher:wait_for_expire_or_token_event() + local maybe_token, err = self.driver:get_oauth_token() + if not maybe_token then + -- Something got funky in the state machine, return and re-determine our next action + log.warn(string.format("Tried to wait for expiration of non-existent token: %s", err)) + return + end + -- The token will be refreshed if requested within 1 minute of expiration + local expiration = math.floor(maybe_token.expiresAt / 1000) - 60 + local now = os.time() + local timeout = math.max(expiration - now, 0) + + log.debug(string.format("Token will refresh in %d seconds", timeout)) + -- Wait while trying to receive a token event in case it gets updated for some reason. + self:try_wait_for_token_event(timeout) +end + +--- Waits for an oauth endpoint app info event indefinitely. +--- +--- A new info event indicates that `Refresher:determine_action` should be called to check if oauth +--- is now connected. +function Refresher:wait_for_info_event() + local info_sub, err = self.driver:oauth_info_event_subscribe() + if err == "closed" then + self.info_bus_closed = true + end + if info_sub then + info_sub:receive() + end +end + +--- Requests a token then waits for a new token event. +function Refresher:request_token() + local result, err = security.get_sonos_oauth() + if not result then + log.warn(string.format("Failed to request oauth token: %s", err)) + end + -- Try to receive token even if the request failed. + self:try_wait_for_token_event(10) + local maybe_token, _ = self.driver:get_oauth_token() + if maybe_token then + -- token is valid, reset backoff + self.token_backoff = utils.backoff_builder(30 * 60, 5, 0.1) + else + -- We either didn't receive a token or it is not valid. + -- Backoff and maybe we will receive it in that time, or we retry. + cosock.socket.sleep(self.token_backoff()) + end +end + +function module.spawn_token_refresher(driver) + local refresher = setmetatable({ driver = driver, + token_backoff = utils.backoff_builder(30 * 60, 5, 0.1), + }, + Refresher) + cosock.spawn(function () + while true do + -- We can always determine what we should be doing based off the information we have, + -- any action can proceed action depending on what needs to be done. + local action = refresher:determine_action() + log.info(string.format("Token refresher action: %s", ACTION_STRINGIFY[action])) + if action == ACTIONS.WAIT_FOR_CONNECTED then + refresher:wait_for_info_event() + elseif action == ACTIONS.WAIT_FOR_EXPIRE then + refresher:wait_for_expire_or_token_event() + elseif action == ACTIONS.REQUEST_TOKEN then + refresher:request_token() + else + log.error(string.format("Token refresher task exiting due to bad token refresher action: %s", action)) + return + end + if refresher.token_bus_closed or refresher.info_bus_closed then + log.error(string.format("Token refresher task exiting. Token bus closed: %s Info bus close: %s", + refresher.token_bus_closed, refresher.info_bus_closed)) + return + end + end + end, "token refresher task") +end + +return module + + diff --git a/drivers/SmartThings/sonos/src/types.lua b/drivers/SmartThings/sonos/src/types.lua index 85d3cb56fa..c404f92a36 100644 --- a/drivers/SmartThings/sonos/src/types.lua +++ b/drivers/SmartThings/sonos/src/types.lua @@ -1,30 +1,38 @@ -local handlers = require "api.event_handlers" -local log = require "log" -local PlayerFields = require "fields".SonosPlayerFields - ---- @module 'sonos.types' -local Types = {} - ---- @enum SonosCapabilities -Types.SonosCapabilities = { - PLAYBACK = "PLAYBACK", --- The player can produce audio. You can target it for playback. - CLOUD = "CLOUD", --- The player can send commands and receive events over the internet. - HT_PLAYBACK = "HT_PLAYBACK", --- The player is a home theater source. It can reproduce the audio from a home theater system, typically delivered by S/PDIF or HDMI. - HT_POWER_STATE = "HT_POWER_STATE", --- The player can control the home theater power state. For example, it can switch a connected TV on or off. - AIRPLAY = "AIRPLAY", --- The player can host AirPlay streams. This capability is present when the device is advertising AirPlay support. - LINE_IN = "LINE_IN", --- The player has an analog line-in. - AUDIO_CLIP = "AUDIO_CLIP", --- The device is capable of playing audio clip notifications. - VOICE = "VOICE", --- The device supports the voice namespace (not yet implemented by Sonos). - SPEAKER_DETECTION = "SPEAKER_DETECTION", --- The component device is capable of detecting connected speaker drivers. - FIXED_VOLUME = "FIXED_VOLUME" --- The device supports fixed volume. -} +---@meta +--- @alias UniqueKey string unique identifier composed of the form `${HouseholdId}/${PlayerId}` --- @alias PlayerId string --- @alias HouseholdId string --- @alias GroupId string +--------- #region Sonos API Types; the following defintions are from the Sonos API +--------- In particular, anything ending in `Object` is an API object. + +--- @alias SonosCapabilities +---| "PLAYBACK" # The player can produce audio. You can target it for playback. +---| "CLOUD" # The player can send commands and receive events over the internet. +---| "HT_PLAYBACK" # The player is a home theater source. It can reproduce the audio from a home theater system, typically delivered by S/PDIF or HDMI. +---| "HT_POWER_STATE" # The player can control the home theater power state. For example, it can switch a connected TV on or off. +---| "AIRPLAY" # The player can host AirPlay streams. This capability is present when the device is advertising AirPlay support. +---| "LINE_IN" # The player has an analog line-in. +---| "AUDIO_CLIP" # The device is capable of playing audio clip notifications. +---| "VOICE" # The device supports the voice namespace (not yet implemented by Sonos). +---| "SPEAKER_DETECTION" # The component device is capable of detecting connected speaker drivers. +---| "FIXED_VOLUME" # The device supports fixed volume. + +--- @class SonosFeatureInfoObject +--- @field public _objectType "feature" +--- @field public name string + +---@class SonosVersionsInfoObject +---@field public _objectType "sdkVersions" +---@field public audioTxProtocol { [1]: integer } +---@field public trueplaySdk { [1]: string } +---@field public controlApi { [1]: string } + --- Lua representation of the Sonos `deviceInfo` JSON Object: https://developer.sonos.com/build/control-sonos-players-lan/discover-lan/#deviceInfo-object ---- @class SonosDeviceInfo +--- @class SonosDeviceInfoObject +--- @field public _objectType "deviceInfo" --- @field public id PlayerId The playerId. Also known as the deviceId. Used to address Sonos devices in the control API. --- @field public primaryDeviceId string Identifies the primary device in bonded sets. Primary devices leave the value blank, which omits the key from the message. The field is expected for secondary devices in stereo pairs and satellites in home theater configurations. --- @field public serialNumber string The device serial number printed on the device. @@ -38,15 +46,20 @@ Types.SonosCapabilities = { --- @field public softwareVersion string Stores the software version the player is running. --- @field public hwVersion string Stores the hardware version the player is running. The format is: `{vendor}.{model}.{submodel}.{revision}-{region}.` --- @field public swGen integer Stores the software generation that the player is running. +--- @field public versions SonosVersionsInfoObject +--- @field public features SonosFeatureInfoObject[] --- Lua representation of the Sonos `discoveryInfo` JSON object: https://developer.sonos.com/build/control-sonos-players-lan/discover-lan/#discoveryInfo-object ---- @class SonosDiscoveryInfo ---- @field public device SonosDeviceInfo The device object. This object presents immutable data that describes a Sonos device. Use this object to uniquely identify any Sonos device. See below for details. +--- @class SonosDiscoveryInfoObject +--- @field public _objectType "discoveryInfo" +--- @field public device SonosDeviceInfoObject The device object. This object presents immutable data that describes a Sonos device. Use this object to uniquely identify any Sonos device. See below for details. --- @field public householdId HouseholdId An opaque identifier assigned to the device during registration. This field may be missing prior to registration. --- @field public playerId PlayerId The identifier used to address this particular device in the control API. --- @field public groupId GroupId The currently assigned groupId, an ephemeral opaque identifier. This value is always correct, including for group members. --- @field public websocketUrl string The URL to the WebSocket server. Use this interface to receive real time updates from the device. --- @field public restUrl string The base URL for REST commands using the control API. You can use the same format as REST commands sent to the Sonos cloud. Sonos exposes all REST commands relative to this URL. +--- @field public allowGuestAccess boolean whether guest access is enabled +--- @field public credentialTypeAllowed string --- Lua representation of the Sonos LAN API response's header structure --- @class SonosResponseHeader @@ -59,12 +72,60 @@ Types.SonosCapabilities = { --- @field public playerId? PlayerId ID of the target if the target was a player. A header will have one of `sessionId`, `groupId`, or `playerId`, --- @field public householdId HouseholdId the Household ID ---- Lua representation of the Sonos `groups` (note the plural) JSON object: https://developer.sonos.com/reference/control-api/groups/groups/ +--- Lua representation of a Sonos error body +--- @class SonosErrorResponse +--- @field public _objectType "globalError" +--- @field public errorCode "ERROR_NOT_CAPABLE"|"ERROR_COMMAND_FAILED"|"ERROR_INVALID_OBJECT_ID"|"ERROR_INVALID_PARAMETER"|"ERROR_UNSUPPORTED_COMMAND"|"ERROR_UNSUPPORTED_NAMESPACE"|"ERROR_INVALID_SYNTAX"|"ERROR_MISSING_PARAMETERS"|"ERROR_NOT_AUTHORIZED" +--- @field public wwwAuthenticate string? Used with `errorCode` is `ERROR_NOT_AUTHORIZED` +--- @field public reason string? Used with `ERROR_MISSING_PARAMETER` + +--- Lua representation of the Sonos `groups` (note the plural) JSON object: https://developer.sonos.com/reference/control-api/groups --- @class SonosGroupsResponseBody +--- @field public _objectType "groups" --- @field public groups SonosGroupObject[] --- @field public players SonosPlayerObject[] +--- @field public partial boolean will be `true` if the API response excluded players/groups because it considered them "invalid" + +--- Lua representation of the Sonos `favorites` (note the plural) JSON object: https://developer.sonos.com/reference/control-api/favorites +--- @class SonosFavoritesResponseBody +--- @field public _objectType "favoritesList" +--- @field public version string +--- @field public items any[] + +--- Lua representation of a Sonos `favorite` (note the singular) JSON object: https://developer.sonos.com/reference/control-api/favorites +--- @class SonosFavoriteObject +--- @field public _objectType "favorite" +--- @field public id string +--- @field public name string +--- @field public description string +--- @field public imageUrl string? deprecated, the `images` array parameter is preferred +--- @field public images SonosImageObject[] +--- @field public service SonosServiceObject +--- @field public resource SonosContentResourceObject + +--- @class SonosImageObject +--- @field public _objectType "image" +--- @field public url string + +--- @class SonosServiceObject +--- @field public _objectType "service" +--- @field public name string +--- @field public id string +--- @field public images SonosImageObject[] + +--- @class SonosContentResourceObject +--- @field public _objectType "contentResource" +--- @field public type string +--- @field public name string +--- @field public images SonosImageObject[] + +--- @class SonosMusicObjectId +--- @field public _objectType "universalMusicObjectId" +--- @field public serviceId string +--- @field public objectId string +--- @field public accountId string ---- Lua representation of the Sonos `group` (note the singular) JSON object: https://developer.sonos.com/reference/control-api/groups/groups/#group +--- Lua representation of the Sonos `group` (note the singular) JSON object: https://developer.sonos.com/reference/control-api/groups --- @class SonosGroupObject --- @field public coordinatorId PlayerId --- @field public id GroupId @@ -83,12 +144,9 @@ Types.SonosCapabilities = { --- @field public softwareVersion string --- @field public websocketUrl string --- @field public capabilities SonosCapabilities[] +--- @field public devices SonosDeviceInfoObject[] ---- Sonos player local state ---- @class PlayerDiscoveryState ---- @field public info_cache SonosDiscoveryInfo Table representation of the JSON returned by the player REST API info endpoint ---- @field public ipv4 string the ipv4 address of the player on the local network ---- @field public is_coordinator boolean whether or not the player was a coordinator (at time of discovery) +--------- #endregion Sonos API Types --- @class SonosSSDPInfo --- Information parsed from Sonos SSDP reply. Contains most of what is needed to uniquely @@ -101,252 +159,17 @@ Types.SonosCapabilities = { --- @field public group_id GroupId --- @field public group_name string --- @field public household_id HouseholdId +--- @field public player_id PlayerId extracted from the USN --- @field public wss_url string +--- @field public expires_at integer --- @alias SonosFavorites { id: string, name: string }[] ---- @class SonosHousehold ---- Information on an entire Sonos system ("household"), such as its current groups, list of players, etc. ---- @field public groups table All of the current groups in the system ---- @field public players table All of the current players in the system ---- @field public favorites SonosFavorites all of the favorites/presets in the system - ---- Sonos systems local state ---- @class SonosState ---- @field public get_household fun(self: SonosState, id: HouseholdId): SonosHousehold ---- @field public update_household_info fun(self: SonosState, id: HouseholdId, groups_event: SonosGroupsResponseBody, device: SonosDevice|nil) ---- @field public update_household_favorites fun(self: SonosState, id: HouseholdId, favorites: SonosFavorites) ---- @field public get_group_for_player fun(self: SonosState, household_id: HouseholdId, player_id: PlayerId): GroupId ---- @field public get_coordinator_for_player fun(self: SonosState, household_id: HouseholdId, player_id: PlayerId): PlayerId ---- @field public get_coordinator_for_group fun(self: SonosState, household_id: HouseholdId, group_id: GroupId): PlayerId ---- @field public get_player_for_device fun(self: SonosState, device: SonosDevice): HouseholdId,PlayerId,string ---- @field public get_coordinator_for_device fun(self: SonosState, device: SonosDevice): HouseholdId,PlayerId,string ---- @field public get_group_for_device fun(self: SonosState, device: SonosDevice): HouseholdId,GroupId,string ---- @field public mark_player_as_joined fun(self: SonosState, player_id: PlayerId) ---- @field public mark_player_as_removed fun(self: SonosState, player_id: PlayerId) ---- @field public is_player_joined fun(self: SonosState, household_id_or_dni: HouseholdId|string, player_id?: PlayerId): boolean -local SonosState = {} -SonosState.__index = SonosState - -function SonosState.new() - local ret = setmetatable({}, SonosState) - - local private = { - households = {}, - joined_players = {} - } - - ret.mark_player_as_joined = function(self, player_id) - log.debug(string.format("Marking Player ID %s as joined", player_id)) - private.joined_players[player_id] = true - end - - ret.mark_player_as_removed = function(self, player_id) - log.debug(string.format("Marking Player ID %s as removed", player_id)) - private.joined_players[player_id] = false - end - - ret.is_player_joined = function(self, dni) - return private.joined_players[dni] ~= nil and private.joined_players[dni] -- we want a boolean return, not nil - end - - ret.get_household = function(self, id) - return private.households[id] - end - - --- @param self SonosState - --- @param id HouseholdId - --- @param favorites SonosFavorites - ret.update_household_favorites = function(self, id, favorites) - local household = private.households[id] or {} --- @type SonosHousehold - household.favorites = favorites or {} - end - - --- @param self SonosState - --- @param id HouseholdId - --- @param groups_event SonosGroupsResponseBody - --- @param device SonosDevice|nil - ret.update_household_info = function(self, id, groups_event, device) - log.debug(string.format("Update household info for household %s", id)) - if device and device.label then - log.debug(string.format("Household update triggered by device %s to update capabilities", device.label)) - end - local household = private.households[id] or {} - local groups, players = groups_event.groups, groups_event.players - - -- We create these maps to avoid having to constantly iterate - -- when performing lookups. We use the various ID's as keys. - household.groups = {} - household.players = {} - household.player_to_group_map = {} - household.group_to_coordinator_map = {} - - for _, group in ipairs(groups) do - household.groups[group.id] = group - household.group_to_coordinator_map[group.id] = group.coordinatorId - for _, playerId in ipairs(group.playerIds) do - household.player_to_group_map[playerId] = group.id - end - end - - for _, player in ipairs(players) do - household.players[player.id] = player - end - - household.player_to_group_map.__newindex = function(_, _, _) - log.warn("Attempted to modify read-only look-up map") - end - - household.group_to_coordinator_map.__newindex = function(_, _, _) - log.warn("Attempted to modify read-only look-up map") - end - - private.households[id] = household - - -- emit group info update [groupId, groupRole, groupPrimaryDeviceId] - if device ~= nil then - log.debug(string.format("Emitting group info update for Device %s", device.label)) - local device_player_id = device:get_field(PlayerFields.PLAYER_ID) - local group_id = self:get_group_for_player(id, device_player_id) - local coordinator_id = self:get_coordinator_for_player(id, device_player_id) - - local role - if device_player_id == coordinator_id then - if #household.groups[group_id].playerIds > 1 then - role = "primary" - else - role = "ungrouped" - end - else - role = "auxilary" - end - - if type(coordinator_id) == "table" then - --info gather since post migration we have seen once that coordinator_id is sometimes a table, - -- which causes a crash when emitting the mediaGroup.groupPrimaryDeviceId capability event - local ut = require "st.utils" - log.warn(ut.stringify_table({ household = household, coordinator_id = coordinator_id }, - "Household update with invalid coordinator_id data:", false - )) - end - handlers.handle_group_update(device, { role, coordinator_id, group_id }) - end - end - - --- @param self SonosState - --- @param household_id HouseholdId - --- @param player_id PlayerId - --- @return GroupId - ret.get_group_for_player = function(self, household_id, player_id) - local household = private.households[household_id] - if household == nil then - local ut = require "st.utils" - log.error(ut.stringify_table({ household = household }, "Get group for invalid household", false)) - return - end - return household.player_to_group_map[player_id] - end - - --- @param self SonosState - --- @param household_id HouseholdId - --- @param group_id GroupId - --- @return PlayerId - ret.get_coordinator_for_group = function(self, household_id, group_id) - local household = private.households[household_id] - if household == nil then - local ut = require "st.utils" - log.error(ut.stringify_table({ household = household }, "Get coordinator for invalid household", false)) - return - end - return household.group_to_coordinator_map[group_id] - end - - --- @param self SonosState - --- @param household_id HouseholdId - --- @param player_id PlayerId - --- @return PlayerId - ret.get_coordinator_for_player = function(self, household_id, player_id) - return self:get_coordinator_for_group( - household_id, - self:get_group_for_player(household_id, player_id) - ) - end - - --- @param self SonosState - --- @param device SonosDevice - --- @return HouseholdId|nil household_id nil on error - --- @return PlayerId|nil player_id nil on error - --- @return nil|string error nil on success - ret.get_player_for_device = function(self, device) - local household_id, player_id = device:get_field(PlayerFields.HOUSEHOULD_ID), - device:get_field(PlayerFields.PLAYER_ID) - - if not (device:get_field(PlayerFields._IS_INIT) or household_id or player_id) then - return nil, nil, - "Player not fully initialized: " .. device.label - end - - return household_id, player_id, nil - end - - --- @param self SonosState - --- @param device SonosDevice - --- @return HouseholdId|nil household_id nil on error - --- @return PlayerId|nil coordinator_id nil on error - --- @return nil|string error nil on success - ret.get_coordinator_for_device = function(self, device) - local household_id, player_id, err = self:get_player_for_device(device) - - if err then - return nil, nil, err - end - - local coordinator_id = self:get_coordinator_for_player(household_id, player_id) - - if not coordinator_id then - return nil, nil, "Couldn't find coordinator ID for device " .. device.label - end - - return household_id, coordinator_id, nil - end - - --- @param self SonosState - --- @param device SonosDevice - --- @return HouseholdId|nil household_id nil on error - --- @return GroupId|nil group_id nil on error - --- @return nil|string error nil on success - ret.get_group_for_device = function(self, device) - local household_id, player_id, err = self:get_player_for_device(device) - - if err then - return nil, nil, err - end - - local group_id = self:get_group_for_player(household_id, player_id) - - if not group_id then - return nil, nil, "Couldn't find group ID for device " .. device.label - end - - return household_id, group_id, nil - end - - return ret -end - -Types.SonosState = SonosState - ---- @alias DiscoCallback fun(dni: string, ssdp_group_info: SonosSSDPInfo, player_info: SonosDiscoveryInfo, group_info: SonosGroupsResponseBody) - ---- Sonos Edge Driver extensions ---- @class SonosDriver : Driver ---- @field public sonos SonosState Local state related to the sonos systems ---- @field private _player_id_to_device table ---- @field public update_group_state fun(self: SonosDriver, header: SonosResponseHeader, body: SonosGroupsResponseBody) ---- @field public handle_ssdp_discovery fun(self: SonosDriver, ssdp_group_info: SonosSSDPInfo, callback?: DiscoCallback) ---- @field public is_same_mac_address fun(dni: string, other: string): boolean +--- @alias DiscoCallback fun(dni: string, ssdp_group_info: SonosSSDPInfo, player_info: SonosDiscoveryInfoObject, group_info: SonosGroupsResponseBody): boolean? --- Sonos Player device --- @class SonosDevice : st.Device +--- @field public id string +--- @field public log table device-scoped logging module --- @field public label string Device label set at `try_create_device` --- @field public device_network_id string the DNI of the device --- @field public type string Network type. Should be "LAN". @@ -356,8 +179,21 @@ Types.SonosState = SonosState --- @field public vendor_provided_label string The vendor's model string --- @field public get_field fun(self: SonosDevice, key: string):any --- @field public set_field fun(self: SonosDevice, key: string, value: any, args?: table) +--- @field public emit_event fun(self: SonosDevice, event: any) +--- @field public driver SonosDriver + +--- @class SonosGroupInfo +--- @field public id GroupId +--- @field public coordinator_id PlayerId +--- @field public player_ids PlayerId[] + +--- @class SonosDeviceInfo +--- @field public id PlayerId +--- @field public primary_device_id PlayerId? + +--- @class SonosPlayerInfo +--- @field public id PlayerId +--- @field public websocket_url string --- Sonos JSON commands --- @class SonosCommand - -return Types diff --git a/drivers/SmartThings/sonos/src/utils.lua b/drivers/SmartThings/sonos/src/utils.lua index dc713dd264..0b646252a2 100644 --- a/drivers/SmartThings/sonos/src/utils.lua +++ b/drivers/SmartThings/sonos/src/utils.lua @@ -1,5 +1,230 @@ ----@module 'utils' +local log = require "log" +local st_utils = require "st.utils" + +local PlayerFields = require "fields".SonosPlayerFields + +---@class utils local utils = {} + +--- Returns the properly concatenated and formatted string for the a key that +--- uniquely identifies a Sonos speaker on the network via its household ID and +--- player ID. +---@param household_id HouseholdId? +---@param player_id PlayerId? +---@return UniqueKey? unique_key the unique key, as long as the inputs are valid, nil otherwise +---@return "HouseholdId"|"PlayerId"|nil invalid_key_name which key argument was bad, nil otherwise +function utils.sonos_unique_key(household_id, player_id) + if type(household_id) ~= "string" then + return nil, "HouseholdId" + end + if type(player_id) ~= "string" then + return nil, "PlayerId" + end + + return string.format("%s/%s", household_id:lower(), player_id:lower()) +end +--- +--- Gets a string that uniquely identifies the player in the Sonos topology by combining +--- both Player ID and Household ID +---@param device SonosDevice +function utils.sonos_unique_key_from_device_record(device) + local cached_key = device:get_field(PlayerFields.UNIQUE_KEY) + if cached_key then + return cached_key + end + local player_id = device:get_field(PlayerFields.PLAYER_ID) + local household_id = device:get_field(PlayerFields.HOUSEHOLD_ID) + if not (player_id or household_id) then + local driver = device.driver + local _player_id, _household_id = driver.sonos:get_player_for_device(device) + if not (player_id or _player_id) then + return nil, + string.format( + "unable to determine Player ID for device %s", + (device.label or device.id or "") + ) + end + player_id = player_id or _player_id + device:set_field(PlayerFields.PLAYER_ID, player_id, { persist = true }) + + if not (household_id or _household_id) then + return nil, + string.format( + "unable to determine Household ID for device %s", + (device.label or device.id or "") + ) + end + household_id = household_id or _household_id + device:set_field(PlayerFields.HOUSEHOLD_ID, household_id, { persist = true }) + end + + local unique_key = utils.sonos_unique_key(household_id, player_id) + device:set_field(PlayerFields.UNIQUE_KEY, unique_key) + return unique_key +end + +--- [TODO:description] +---@param ssdp_info SonosSSDPInfo +function utils.sonos_unique_key_from_ssdp(ssdp_info) + return utils.sonos_unique_key(ssdp_info.household_id, ssdp_info.player_id) +end + +---@param device SonosDevice +---@param field_key string +---@param new_value any +---@param opts table? +---@return boolean +function utils.update_field_if_changed(device, field_key, new_value, opts) + if + not ( + device + and device.id + and type(device.get_field) == "function" + and type(device.set_field) == "function" + ) + then + log.error( + string.format( + "[device %s] table is incomplete", + (device and device.label or device.id or "") + ) + ) + end + + local old_value = device:get_field(field_key) + local changed = (type(old_value) ~= type(new_value)) + or (not utils.deep_table_eq(old_value, new_value)) + if changed then + device:set_field(field_key, new_value, opts) + return true + end + return false +end + +local function __normalize_mac_key_index(tbl, key) + assert(type(key) == "string", "DNI must be a string!") + return rawget(tbl, utils.normalize_mac_address(key)) +end + +local function __normalize_mac_key_newindex(tbl, key, value) + assert(type(key) == "string", "DNI must be a string!") + rawset(tbl, utils.normalize_mac_address(key), value) +end + +local _mac_addr_key_mt = { + __index = __normalize_mac_key_index, + __newindex = __normalize_mac_key_newindex, +} + +---creates a table that takes MAC addresses as a key. The table itself is mostly normal, +---but it uses special `__index` and `__newindex` metamethods to perform MAC address normalization +---on the keys before performing lookups. +function utils.new_mac_address_keyed_table() + return setmetatable({}, _mac_addr_key_mt) +end + +---@param tbl table +---@param key string +local function __case_insensitive_key_index(tbl, key) + if type(key) ~= "string" then + local fmt_val + if type(key) == "table" then + fmt_val = st_utils.stringify_table(key) + else + fmt_val = key or "" + end + log.warn_with( + { hub_logs = false }, + string.format( + "Expected `string` key for CaseInsensitiveKeyTable, received (%s: %s)", + fmt_val, + type(key) + ) + ) + return nil + else + local lowercase = key:lower() + return rawget(tbl, lowercase) + end +end + +local function __case_insensitive_key_newindex(tbl, key, value) + if type(key) ~= "string" then + local fmt_val + if type(key) == "table" then + fmt_val = st_utils.stringify_table(key) + else + fmt_val = key or "" + end + log.warn_with( + { hub_logs = false }, + string.format( + "Expected `string` key for CaseInsensitiveKeyTable, received (%s: %s)", + fmt_val, + type(key) + ) + ) + else + rawset(tbl, key:lower(), value) + end +end + +local _case_insensitive_key_mt = { + __index = __case_insensitive_key_index, + __newindex = __case_insensitive_key_newindex, + __metatable = "CaseInsensitiveKeyTable", +} + +function utils.new_case_insensitive_table() + return setmetatable({}, _case_insensitive_key_mt) +end + +---@param sonos_device_info SonosDeviceInfoObject +function utils.extract_mac_addr(sonos_device_info) + if type(sonos_device_info) ~= "table" or type(sonos_device_info.serialNumber) ~= "string" then + log.error_with( + { hub_logs = false }, + string.format("Bad sonos device info passed to `extract_mac_addr`: %s", sonos_device_info) + ) + end + local mac, _ = sonos_device_info.serialNumber:match("(.*):.*"):gsub("-", "") + return utils.normalize_mac_address(mac) +end + +---normalizes a MAC address by removing all `-` and `:` characters, then +---unifying on uppercase letters. +--- +---@param mac_addr string +---@return string +function utils.normalize_mac_address(mac_addr) + return mac_addr:gsub("-", ""):gsub(":", ""):upper() +end + +function utils.mac_address_eq(a, b) + if not (type(a) == "string" and type(b) == "string") then + return false + end + local a_normalized = utils.normalize_mac_address(a) + local b_normalized = utils.normalize_mac_address(b) + return a_normalized == b_normalized +end + +function utils.read_only(tbl) + if type(tbl) == "table" then + local proxy = {} + local mt = { -- create metatable + __index = tbl, + __newindex = function(t, k, v) + error("attempt to update a read-only table", 2) + end, + } + setmetatable(proxy, mt) + return proxy + else + return tbl + end +end + -- build a exponential backoff time value generator -- -- max: the maximum wait interval (not including `rand factor`) @@ -18,7 +243,9 @@ function utils.backoff_builder(max, inc, rand) count = count + 1 -- ensure base backoff (not including random factor) is less than max - if max then base = math.min(base, max) end + if max then + base = math.min(base, max) + end -- ensure total backoff is >= 0 return math.max(base + randval, 0) @@ -26,7 +253,6 @@ function utils.backoff_builder(max, inc, rand) end function utils.labeled_socket_builder(label) - local log = require "log" local socket = require "cosock.socket" local ssl = require "cosock.ssl" @@ -36,52 +262,46 @@ function utils.labeled_socket_builder(label) end local function make_socket(host, port, wrap_ssl) - log.trace( - string.format( - "%sCreating TCP socket for REST Connection", label - ) - ) + log.trace(string.format("%sCreating TCP socket for REST Connection", label)) local _ = nil local sock, err = socket.tcp() - if err ~= nil or (not sock) then + if err ~= nil or not sock then return nil, (err or "unknown error creating TCP socket") end - log.trace( - string.format( - "%sSetting TCP socket timeout for REST Connection", label - ) - ) + log.trace(string.format("%sSetting TCP socket timeout for REST Connection", label)) _, err = sock:settimeout(60) if err ~= nil then return nil, "settimeout error: " .. err end - log.trace( - string.format( - "%sConnecting TCP socket for REST Connection", label - ) - ) + log.trace(string.format("%sConnecting TCP socket for REST Connection", label)) _, err = sock:connect(host, port) - if err then return nil, err end + if err then + return nil, err + end + + log.trace(string.format("%sSet Keepalive for TCP socket for REST Connection", label)) + err = select(2, sock:setoption("keepalive", true)) + if err ~= nil then + return nil, "Setoption error: " .. err + end if wrap_ssl then - log.trace( - string.format( - "%sCreating SSL wrapper for for REST Connection", label - ) - ) + log.trace(string.format("%sCreating SSL wrapper for for REST Connection", label)) sock, err = - ssl.wrap(sock, { mode = "client", protocol = "any", verify = "none", options = "all" }) + ssl.wrap(sock, { mode = "client", protocol = "any", verify = "none", options = "all" }) if err ~= nil then return nil, "SSL wrap error: " .. err end - log.trace( - string.format( - "%sPerforming SSL handshake for for REST Connection", label - ) - ) + log.trace(string.format("%sSetting SSL socket timeout for REST Connection", label)) + -- Re-set timeout due to cosock not carrying timeout over in some Lua library versions + err = select(2, sock:settimeout(60)) + if err ~= nil then + return nil, "settimeout error: " .. err + end + log.trace(string.format("%sPerforming SSL handshake for for REST Connection", label)) _, err = sock:dohandshake() if err ~= nil then return nil, "Error with SSL handshake: " .. err @@ -99,7 +319,7 @@ function utils.deep_table_eq(tbl1, tbl2) if tbl1 == tbl2 then return true elseif type(tbl1) == "table" and type(tbl2) == "table" then - for key1, value1 in pairs(tbl1) do + for key1, value1 in pairs(tbl1 or {}) do local value2 = tbl2[key1] if value2 == nil then @@ -117,7 +337,7 @@ function utils.deep_table_eq(tbl1, tbl2) end -- check for missing keys in tbl1 - for key2, _ in pairs(tbl2) do + for key2, _ in pairs(tbl2 or {}) do if tbl1[key2] == nil then return false end diff --git a/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer-switch.yml b/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer-switch.yml index b62e0ae1e6..3490d307a2 100644 --- a/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer-switch.yml +++ b/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer-switch.yml @@ -8,3 +8,6 @@ components: version: 1 categories: - name: Switch +preferences: + - preferenceId: certifiedpreferences.forceStateChange + explicit: true diff --git a/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer.yml b/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer.yml index 2d5ebfb578..4784e93bb0 100644 --- a/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer.yml +++ b/drivers/SmartThings/virtual-switch/profiles/virtual-dimmer.yml @@ -6,3 +6,6 @@ components: version: 1 categories: - name: Switch +preferences: + - preferenceId: certifiedpreferences.forceStateChange + explicit: true diff --git a/drivers/SmartThings/virtual-switch/profiles/virtual-switch.yml b/drivers/SmartThings/virtual-switch/profiles/virtual-switch.yml index 4347fde8d0..f6796dd220 100644 --- a/drivers/SmartThings/virtual-switch/profiles/virtual-switch.yml +++ b/drivers/SmartThings/virtual-switch/profiles/virtual-switch.yml @@ -6,3 +6,6 @@ components: version: 1 categories: - name: Switch +preferences: + - preferenceId: certifiedpreferences.forceStateChange + explicit: true diff --git a/drivers/SmartThings/virtual-switch/src/init.lua b/drivers/SmartThings/virtual-switch/src/init.lua index c140b4eb3f..7f5288a9c4 100644 --- a/drivers/SmartThings/virtual-switch/src/init.lua +++ b/drivers/SmartThings/virtual-switch/src/init.lua @@ -1,26 +1,32 @@ local capabilities = require "st.capabilities" local Driver = require "st.driver" -local additional_fields = { - state_change = true -} +local function force_state_change(device) + if device.preferences == nil or device.preferences["certifiedpreferences.forceStateChange"] == nil then + return {state_change = true} + elseif not device.preferences["certifiedpreferences.forceStateChange"] then + return nil + else + return {state_change = true} + end +end local function handle_set_level(driver, device, command) if (command.args.level == 0) then - device:emit_event(capabilities.switch.switch.off(additional_fields)) + device:emit_event(capabilities.switch.switch.off(force_state_change(device))) else - device:emit_event(capabilities.switchLevel.level(command.args.level, additional_fields)) + device:emit_event(capabilities.switchLevel.level(command.args.level, force_state_change(device))) device:emit_event(capabilities.switch.switch.on()) end end local function handle_on(driver, device, command) - device:emit_event(capabilities.switch.switch.on(additional_fields)) + device:emit_event(capabilities.switch.switch.on(force_state_change(device))) end local function handle_off(driver, device, command) - device:emit_event(capabilities.switch.switch.off(additional_fields)) + device:emit_event(capabilities.switch.switch.off(force_state_change(device))) end local virtual_driver = Driver("virtual-switch", { diff --git a/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua b/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua index 498c716f59..5c284b1e6b 100644 --- a/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua +++ b/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua @@ -1,14 +1,27 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local mock_simple_device = test.mock_device.build_test_generic_device( - { profile = t_utils.get_profile_definition("virtual-dimmer-switch.yml") } + { + profile = t_utils.get_profile_definition("virtual-dimmer-switch.yml"), + preferences = { ["certifiedpreferences.forceStateChange"] = true }, + } +) + +local mock_device_no_prefs = test.mock_device.build_test_generic_device( + { + profile = t_utils.get_profile_definition("virtual-dimmer-switch.yml"), + } ) local function test_init() test.mock_device.add_test_device(mock_simple_device) + test.mock_device.add_test_device(mock_device_no_prefs) end test.set_test_init_function(test_init) @@ -31,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -47,6 +63,28 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.on({state_change=true})) } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled: on", + { + { + channel = "capability", + direction = "receive", + message = { mock_device_no_prefs.id, { capability = "switch", component = "main", command = "on", args = {}}} + }, + { + channel = "capability", + direction = "send", + message = mock_device_no_prefs:generate_test_message("main", capabilities.switch.switch.on({state_change=true})) + } + }, + { + min_api_version = 17 } ) @@ -64,6 +102,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.off({state_change=true})) } + }, + { + min_api_version = 17 } ) @@ -80,6 +121,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.off({state_change=true})) } + }, + { + min_api_version = 17 } ) @@ -102,7 +146,31 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) +test.register_coroutine_test( + "State change should not be true when forceStateChange is false", + function() + test.socket.device_lifecycle():__queue_receive({mock_simple_device.id, "init"}) + test.socket.device_lifecycle():__queue_receive(mock_simple_device:generate_info_changed( + { + preferences = { + ["certifiedpreferences.forceStateChange"] = false + } + } + )) + test.wait_for_events() + test.socket.capability:__queue_receive({ mock_simple_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.switch.switch.on())) + end, + { + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/wemo/src/protocol.lua b/drivers/SmartThings/wemo/src/protocol.lua index b8779564fc..799b125ddc 100644 --- a/drivers/SmartThings/wemo/src/protocol.lua +++ b/drivers/SmartThings/wemo/src/protocol.lua @@ -107,7 +107,7 @@ function protocol.subscribe(device, listen_ip, listen_port) return end - local device_facing_local_ip = find_interface_ip_for_remote(ip) + local device_facing_local_ip = find_interface_ip_for_remote(ip) or device.driver.environment_info.hub_ipv4 local response_body = {} local resp, code_or_err, headers, status_line = http.request { diff --git a/drivers/SmartThings/wemo/src/subscription_server.lua b/drivers/SmartThings/wemo/src/subscription_server.lua index 6530e53207..39549e1e83 100644 --- a/drivers/SmartThings/wemo/src/subscription_server.lua +++ b/drivers/SmartThings/wemo/src/subscription_server.lua @@ -93,7 +93,7 @@ function SubscriptionServer.new_server() end) else log.error_with({hub_logs = true}, - "serve| shutting down tcp server due to unexpected accept error: " .. err) + string.format("serve| shutting down tcp server due to unexpected accept error: %s", err)) break end end @@ -114,7 +114,7 @@ function SubscriptionServer.new_server() shutdown_msg = "shutdown requested" break elseif msg.type == ControlMessageType.Subscribe then - log.trace("serve| subscribing to device " .. msg.device.label) + log.trace(string.format("serve| subscribing to device %s", msg.device.label)) if msg.device:get_field("subscription_id") then local res = protocol.unsubscribe(msg.device, self.subscriptions[msg.device.id]) log.trace(string.format("serve| unsubscribed from %s successfully? %s", msg.device.label, res)) @@ -124,20 +124,20 @@ function SubscriptionServer.new_server() local sub_id = protocol.subscribe(msg.device, srv_addr, srv_port) if sub_id == nil then log.warn_with({hub_logs = true}, - "serve| failed to subscribe to device " .. msg.device.label) + string.format("serve| failed to subscribe to device %s", msg.device.label)) else log.info_with({hub_logs=true}, - "serve| successfully subscribed to device " .. msg.device.label) + string.format("serve| successfully subscribed to device %s", msg.device.label)) self.subscriptions[sub_id] = msg.device msg.device:set_field("subscription_id", sub_id) end elseif msg.type == ControlMessageType.Unsubscribe then - log.trace("serve| unsubscribing from device " .. msg.device.label) + log.trace(string.format("serve| unsubscribing from device %s", msg.device and msg.device.label or nil)) if msg.device:get_field("subscription_id") then - log.warn("serve| no existing subscription for device" .. msg.device.label) + log.warn(string.format("serve| no existing subscription for device%s", msg.device and msg.device.label or nil)) else local res = protocol.unsubscribe(msg.device, self.subscriptions[msg.device.id]) - log.trace(string.format("serve| unsubscribed from %s successfully? %s", msg.device.label, res)) + log.trace(string.format("serve| unsubscribed from %s successfully? %s", msg.device and msg.device.label or nil, res)) self.subscriptions[msg.device:get_field("subscription_id")] = nil msg.device:set_field("subscription_id", nil) end @@ -171,7 +171,7 @@ function SubscriptionServer.new_server() log.trace("serve| received notify event from deleted device") self.subscriptions[notification.id] = nil else - log.trace("serve| received notify event from " .. self.subscriptions[notification.id].label) + log.trace(string.format("serve| received notify event from %s", self.subscriptions[notification.id].label)) --Parser emits events for device parser.parse_subscription_resp_xml(self.subscriptions[notification.id], notification.data) end diff --git a/drivers/SmartThings/zigbee-air-quality-detector/config.yml b/drivers/SmartThings/zigbee-air-quality-detector/config.yml new file mode 100755 index 0000000000..30e9dceaff --- /dev/null +++ b/drivers/SmartThings/zigbee-air-quality-detector/config.yml @@ -0,0 +1,6 @@ +name: 'Zigbee Air Quality Detector' +packageKey: 'zigbee-air-quality-detector' +permissions: + zigbee: {} +description: "SmartThings driver for Zigbee air quality detector" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/zigbee-air-quality-detector/fingerprints.yml b/drivers/SmartThings/zigbee-air-quality-detector/fingerprints.yml new file mode 100755 index 0000000000..b20da987e8 --- /dev/null +++ b/drivers/SmartThings/zigbee-air-quality-detector/fingerprints.yml @@ -0,0 +1,6 @@ +zigbeeManufacturer: + - id: "MultiIR/PMT1006S-SGM-ZTN" + deviceLabel: MultiIR Air Quality Detector + manufacturer: MultiIR + model: PMT1006S-SGM-ZTN + deviceProfileName: air-quality-detector-MultiIR diff --git a/drivers/SmartThings/zigbee-air-quality-detector/profiles/air-quality-detector-MultiIR.yml b/drivers/SmartThings/zigbee-air-quality-detector/profiles/air-quality-detector-MultiIR.yml new file mode 100755 index 0000000000..7369e1158a --- /dev/null +++ b/drivers/SmartThings/zigbee-air-quality-detector/profiles/air-quality-detector-MultiIR.yml @@ -0,0 +1,38 @@ +name: air-quality-detector-MultiIR +components: + - id: main + capabilities: + - id: airQualityHealthConcern + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: carbonDioxideMeasurement + version: 1 + - id: carbonDioxideHealthConcern + version: 1 + - id: fineDustSensor + version: 1 + - id: fineDustHealthConcern + version: 1 + - id: veryFineDustSensor + version: 1 + - id: veryFineDustHealthConcern + version: 1 + - id: dustSensor + version: 1 + - id: dustHealthConcern + version: 1 + - id: formaldehydeMeasurement + version: 1 + - id: tvocMeasurement + version: 1 + - id: tvocHealthConcern + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector diff --git a/drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/custom_clusters.lua b/drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/custom_clusters.lua new file mode 100755 index 0000000000..8e8f117f49 --- /dev/null +++ b/drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/custom_clusters.lua @@ -0,0 +1,72 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local data_types = require "st.zigbee.data_types" + +local custom_clusters = { + carbonDioxide = { + id = 0xFCC3, + mfg_specific_code = 0x1235, + attributes = { + measured_value = { + id = 0x0000, + value_type = data_types.Uint16, + } + } + }, + particulate_matter = { + id = 0xFCC1, + mfg_specific_code = 0x1235, + attributes = { + pm2_5_MeasuredValue = { + id = 0x0000, + value_type = data_types.Uint16, + }, + pm1_0_MeasuredValue = { + id = 0x0001, + value_type = data_types.Uint16, + }, + pm10_MeasuredValue = { + id = 0x0002, + value_type = data_types.Uint16, + } + } + }, + unhealthy_gas = { + id = 0xFCC2, + mfg_specific_code = 0x1235, + attributes = { + CH2O_MeasuredValue = { + id = 0x0000, + value_type = data_types.SinglePrecisionFloat, + }, + tvoc_MeasuredValue = { + id = 0x0001, + value_type = data_types.SinglePrecisionFloat, + } + } + }, + AQI = { + id = 0xFCC5, + mfg_specific_code = 0x1235, + attributes = { + AQI_value = { + id = 0x0000, + value_type = data_types.Uint16, + } + } + } +} + +return custom_clusters diff --git a/drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/init.lua new file mode 100755 index 0000000000..3574c06e59 --- /dev/null +++ b/drivers/SmartThings/zigbee-air-quality-detector/src/MultiIR/init.lua @@ -0,0 +1,152 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local custom_clusters = require "MultiIR/custom_clusters" +local cluster_base = require "st.zigbee.cluster_base" + +local RelativeHumidity = clusters.RelativeHumidity +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local MultiIR_SENSOR_FINGERPRINTS = { + { mfr = "MultiIR", model = "PMT1006S-SGM-ZTN" }--This is not a sleep end device +} + +local function can_handle_MultiIR_sensor(opts, driver, device) + for _, fingerprint in ipairs(MultiIR_SENSOR_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function send_read_attr_request(device, cluster, attr) + device:send( + cluster_base.read_manufacturer_specific_attribute( + device, + cluster.id, + attr.id, + cluster.mfg_specific_code + ) + ) +end + +local function do_refresh(driver, device) + device:send(RelativeHumidity.attributes.MeasuredValue:read(device):to_endpoint(0x01)) + device:send(TemperatureMeasurement.attributes.MeasuredValue:read(device):to_endpoint(0x01)) + send_read_attr_request(device, custom_clusters.particulate_matter, custom_clusters.particulate_matter.attributes.pm2_5_MeasuredValue) + send_read_attr_request(device, custom_clusters.particulate_matter, custom_clusters.particulate_matter.attributes.pm1_0_MeasuredValue) + send_read_attr_request(device, custom_clusters.particulate_matter, custom_clusters.particulate_matter.attributes.pm10_MeasuredValue) + send_read_attr_request(device, custom_clusters.unhealthy_gas, custom_clusters.unhealthy_gas.attributes.CH2O_MeasuredValue) + send_read_attr_request(device, custom_clusters.unhealthy_gas, custom_clusters.unhealthy_gas.attributes.tvoc_MeasuredValue) + send_read_attr_request(device, custom_clusters.carbonDioxide, custom_clusters.carbonDioxide.attributes.measured_value) + send_read_attr_request(device, custom_clusters.AQI, custom_clusters.AQI.attributes.AQI_value) +end + +local function airQualityHealthConcern_attr_handler(driver, device, value, zb_rx) + local airQuality_level = "good" + if value.value >= 51 then + airQuality_level = "moderate" + end + if value.value >= 101 then + airQuality_level = "slightlyUnhealthy" + end + if value.value >= 151 then + airQuality_level = "unhealthy" + end + if value.value >= 201 then + airQuality_level = "veryUnhealthy" + end + if value.value >= 301 then + airQuality_level = "hazardous" + end + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.airQualityHealthConcern.airQualityHealthConcern({value = airQuality_level})) +end + +local function carbonDioxide_attr_handler(driver, device, value, zb_rx) + local level = "unhealthy" + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.carbonDioxideMeasurement.carbonDioxide({value = value.value, unit = "ppm"})) + if value.value <= 1500 then + level = "good" + elseif value.value >= 1501 and value.value <= 2500 then + level = "moderate" + end + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern({value = level})) +end + +local function particulate_matter_attr_handler(cap,Concern,good,bad) + return function(driver, device, value, zb_rx) + local level = "unhealthy" + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, cap({value = value.value})) + if value.value <= good then + level = "good" + elseif bad > 0 and value.value > good and value.value < bad then + level = "moderate" + end + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, Concern({value = level})) + end +end + +local function CH2O_attr_handler(driver, device, value, zb_rx) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.formaldehydeMeasurement.formaldehydeLevel({value = value.value, unit = "mg/m^3"})) +end + +local function tvoc_attr_handler(driver, device, value, zb_rx) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.tvocMeasurement.tvocLevel({value = value.value, unit = "ug/m3"})) + local level = "unhealthy" + if value.value < 600.0 then + level = "good" + end + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.tvocHealthConcern.tvocHealthConcern({value = level})) +end + +local function added_handler(self, device) + do_refresh() +end + +local MultiIR_sensor = { + NAME = "MultiIR air quality detector", + lifecycle_handlers = { + added = added_handler + }, + zigbee_handlers = { + attr = { + [custom_clusters.carbonDioxide.id] = { + [custom_clusters.carbonDioxide.attributes.measured_value.id] = carbonDioxide_attr_handler + }, + [custom_clusters.particulate_matter.id] = { + [custom_clusters.particulate_matter.attributes.pm2_5_MeasuredValue.id] = particulate_matter_attr_handler(capabilities.fineDustSensor.fineDustLevel,capabilities.fineDustHealthConcern.fineDustHealthConcern,75,115),--75 115 is a comparative value of good moderate unhealthy, and 0 is no comparison + [custom_clusters.particulate_matter.attributes.pm1_0_MeasuredValue.id] = particulate_matter_attr_handler(capabilities.veryFineDustSensor.veryFineDustLevel,capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern,100,0), + [custom_clusters.particulate_matter.attributes.pm10_MeasuredValue.id] = particulate_matter_attr_handler(capabilities.dustSensor.dustLevel,capabilities.dustHealthConcern.dustHealthConcern,150,0) + }, + [custom_clusters.unhealthy_gas.id] = { + [custom_clusters.unhealthy_gas.attributes.CH2O_MeasuredValue.id] = CH2O_attr_handler, + [custom_clusters.unhealthy_gas.attributes.tvoc_MeasuredValue.id] = tvoc_attr_handler + }, + [custom_clusters.AQI.id] = { + [custom_clusters.AQI.attributes.AQI_value.id] = airQualityHealthConcern_attr_handler + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + can_handle = can_handle_MultiIR_sensor +} + +return MultiIR_sensor diff --git a/drivers/SmartThings/zigbee-air-quality-detector/src/init.lua b/drivers/SmartThings/zigbee-air-quality-detector/src/init.lua new file mode 100755 index 0000000000..49e2f79a88 --- /dev/null +++ b/drivers/SmartThings/zigbee-air-quality-detector/src/init.lua @@ -0,0 +1,42 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local ZigbeeDriver = require "st.zigbee" +local capabilities = require "st.capabilities" +local defaults = require "st.zigbee.defaults" + +local zigbee_air_quality_detector_template = { + supported_capabilities = { + capabilities.airQualityHealthConcern, + capabilities.temperatureMeasurement, + capabilities.relativeHumidityMeasurement, + capabilities.carbonDioxideMeasurement, + capabilities.carbonDioxideHealthConcern, + capabilities.fineDustSensor, + capabilities.fineDustHealthConcern, + capabilities.veryFineDustSensor, + capabilities.veryFineDustHealthConcern, + capabilities.dustSensor, + capabilities.dustHealthConcern, + capabilities.formaldehydeMeasurement, + capabilities.tvocMeasurement, + capabilities.tvocHealthConcern + }, + sub_drivers = { require("MultiIR") }, + shared_device_thread_enabled = true, +} + +defaults.register_for_default_handlers(zigbee_air_quality_detector_template, zigbee_air_quality_detector_template.supported_capabilities) +local zigbee_air_quality_detector_driver = ZigbeeDriver("zigbee-air-quality-detector", zigbee_air_quality_detector_template) +zigbee_air_quality_detector_driver:run() diff --git a/drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua b/drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua new file mode 100644 index 0000000000..606b716dff --- /dev/null +++ b/drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua @@ -0,0 +1,490 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local SinglePrecisionFloat = require "st.zigbee.data_types.SinglePrecisionFloat" + +local profile_def = t_utils.get_profile_definition("air-quality-detector-MultiIR.yml") +local MFG_CODE = 0x1235 + +local mock_device = test.mock_device.build_test_zigbee_device( +{ + label = "air quality detector", + profile = profile_def, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "MultiIR", + model = "PMT1006S-SGM-ZTN", + server_clusters = { 0x0000, 0x0402,0x0405,0xFCC1, 0xFCC2,0xFCC3,0xFCC5} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "capability - refresh", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + local read_RelativeHumidity_messge = clusters.RelativeHumidity.attributes.MeasuredValue:read(mock_device) + local read_TemperatureMeasurement_messge = clusters.TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + local read_pm2_5_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, 0xFCC1, 0x0000, MFG_CODE) + local read_pm1_0_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, 0xFCC1, 0x0001, MFG_CODE) + local read_pm10_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, 0xFCC1, 0x0002, MFG_CODE) + local read_ch2o_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, 0xFCC2, 0x0000, MFG_CODE) + local read_tvoc_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, 0xFCC2, 0x0001, MFG_CODE) + local read_carbonDioxide_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, 0xFCC3, 0x0000, MFG_CODE) + local read_AQI_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, 0xFCC5, 0x0000, MFG_CODE) + + test.socket.zigbee:__expect_send({mock_device.id, read_RelativeHumidity_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_TemperatureMeasurement_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_pm2_5_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_pm1_0_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_pm10_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_ch2o_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_tvoc_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_carbonDioxide_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_AQI_messge}) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Relative humidity reports should generate correct messages", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + clusters.RelativeHumidity.attributes.MeasuredValue:build_test_attr_report(mock_device, 40*100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature reports should generate correct messages", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported carbonDioxide and driver emit carbonDioxide and carbonDioxideHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 1400 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC3, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideMeasurement.carbonDioxide({value = 1400, unit = "ppm"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern({value = "good"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported pm2.5 and driver emit pm2.5 and fineDustHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 74 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustSensor.fineDustLevel({value = 74 }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustHealthConcern.fineDustHealthConcern.good())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported pm1.0 and driver emit pm1.0 and veryFineDustHealthConcern", + function() + local attr_report_data = { + { 0x0001, data_types.Uint16.ID, 69 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.veryFineDustSensor.veryFineDustLevel({value = 69 }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern.good())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported pm10 and driver emit pm10 and dustHealthConcern", + function() + local attr_report_data = { + { 0x0002, data_types.Uint16.ID, 69 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.dustSensor.dustLevel({value = 69 }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.dustHealthConcern.dustHealthConcern.good())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported ch2o and driver emit ch2o", + function() + local attr_report_data = { + { 0x0000, data_types.SinglePrecisionFloat.ID, SinglePrecisionFloat(0, 9, 0.953125) } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC2, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.formaldehydeMeasurement.formaldehydeLevel({value = 1000.0, unit = "mg/m^3"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported tvoc and driver emit tvoc", + function() + local attr_report_data = { + { 0x0001, data_types.SinglePrecisionFloat.ID, SinglePrecisionFloat(0, 9, 0.953125) } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC2, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocMeasurement.tvocLevel({value = 1000.0, unit = "ug/m3"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocHealthConcern.tvocHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported AQI and driver emit airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 50 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "good"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "AQI moderate (51-100) emits moderate airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 75 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "moderate"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "AQI slightlyUnhealthy (101-150) emits slightlyUnhealthy airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 125 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "slightlyUnhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "AQI unhealthy (151-200) emits unhealthy airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 175 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "AQI veryUnhealthy (201-300) emits veryUnhealthy airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 250 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "veryUnhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "AQI hazardous (>=301) emits hazardous airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 350 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "hazardous"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "carbonDioxide moderate (1501-2500) emits moderate carbonDioxideHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 2000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC3, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideMeasurement.carbonDioxide({value = 2000, unit = "ppm"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern({value = "moderate"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "carbonDioxide unhealthy (>2500) emits unhealthy carbonDioxideHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 3000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC3, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideMeasurement.carbonDioxide({value = 3000, unit = "ppm"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "pm2.5 moderate emits moderate fineDustHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 90 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustSensor.fineDustLevel({value = 90}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustHealthConcern.fineDustHealthConcern({value = "moderate"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "pm2.5 unhealthy (>=115) emits unhealthy fineDustHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 120 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustSensor.fineDustLevel({value = 120}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustHealthConcern.fineDustHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "pm1.0 unhealthy (>100) emits unhealthy veryFineDustHealthConcern", + function() + local attr_report_data = { + { 0x0001, data_types.Uint16.ID, 150 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.veryFineDustSensor.veryFineDustLevel({value = 150}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "pm10 unhealthy (>150) emits unhealthy dustHealthConcern", + function() + local attr_report_data = { + { 0x0002, data_types.Uint16.ID, 200 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.dustSensor.dustLevel({value = 200}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.dustHealthConcern.dustHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "tvoc good (<600) emits good tvocHealthConcern", + function() + local attr_report_data = { + { 0x0001, data_types.SinglePrecisionFloat.ID, SinglePrecisionFloat(0, 8, 0.953125) } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC2, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocMeasurement.tvocLevel({value = 500.0, unit = "ug/m3"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocHealthConcern.tvocHealthConcern({value = "good"}))) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-bed/capabilities/aiMode.yaml b/drivers/SmartThings/zigbee-bed/capabilities/aiMode.yaml new file mode 100755 index 0000000000..80e1527d7d --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/capabilities/aiMode.yaml @@ -0,0 +1,57 @@ +id: stse.aiMode +version: 1 +status: proposed +name: AI Mode +ephemeral: false +attributes: + left: + schema: + type: object + properties: + value: + title: left + type: string + enum: + - "on" + - "off" + additionalProperties: false + required: + - value + setter: leftControl + enumCommands: [] + right: + schema: + type: object + properties: + value: + title: right + type: string + enum: + - "on" + - "off" + additionalProperties: false + required: + - value + setter: rightControl + enumCommands: [] +commands: + leftControl: + name: leftControl + arguments: + - name: leftControl + optional: false + schema: + type: string + enum: + - "on" + - "off" + rightControl: + name: rightControl + arguments: + - name: rightControl + optional: false + schema: + type: string + enum: + - "on" + - "off" diff --git a/drivers/SmartThings/zigbee-bed/capabilities/autoInflation.yaml b/drivers/SmartThings/zigbee-bed/capabilities/autoInflation.yaml new file mode 100755 index 0000000000..6ae522ef74 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/capabilities/autoInflation.yaml @@ -0,0 +1,32 @@ +id: stse.autoInflation +version: 1 +status: proposed +name: Auto Inflation +ephemeral: false +attributes: + inflationState: + schema: + type: object + properties: + value: + title: state + type: string + enum: + - "on" + - "off" + additionalProperties: false + required: + - value + setter: stateControl + enumCommands: [] +commands: + stateControl: + name: stateControl + arguments: + - name: stateControl + optional: false + schema: + type: string + enum: + - "on" + - "off" diff --git a/drivers/SmartThings/zigbee-bed/capabilities/leftControl.yaml b/drivers/SmartThings/zigbee-bed/capabilities/leftControl.yaml new file mode 100755 index 0000000000..f373824498 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/capabilities/leftControl.yaml @@ -0,0 +1,85 @@ +id: stse.leftControl +version: 1 +status: proposed +name: Left Control +ephemeral: false +attributes: + leftback: + schema: + type: object + properties: + value: + title: back + type: string + enum: + - idle + - soft + - hard + additionalProperties: false + required: + - value + setter: backControl + enumCommands: [] + leftwaist: + schema: + type: object + properties: + value: + title: waist + type: string + enum: + - idle + - soft + - hard + additionalProperties: false + required: + - value + setter: waistControl + enumCommands: [] + lefthip: + schema: + type: object + properties: + value: + title: hip + type: string + enum: + - idle + - soft + - hard + additionalProperties: false + required: + - value + setter: hipControl + enumCommands: [] +commands: + backControl: + name: backControl + arguments: + - name: backControl + optional: false + schema: + type: string + enum: + - soft + - hard + waistControl: + name: waistControl + arguments: + - name: waistControl + optional: false + schema: + type: string + enum: + - soft + - hard + hipControl: + name: hipControl + arguments: + - name: hipControl + optional: false + schema: + type: string + enum: + - soft + - hard diff --git a/drivers/SmartThings/zigbee-bed/capabilities/mattressHardness.yaml b/drivers/SmartThings/zigbee-bed/capabilities/mattressHardness.yaml new file mode 100755 index 0000000000..cb77907afc --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/capabilities/mattressHardness.yaml @@ -0,0 +1,115 @@ +id: stse.mattressHardness +version: 1 +status: proposed +name: Mattress Hardness +ephemeral: false +attributes: + leftBackHardness: + schema: + type: object + properties: + value: + title: leftBackHardness + type: integer + minimum: 0 + maximum: 100 + unit: + type: string + enum: + - '%' + default: '%' + additionalProperties: false + required: + - value + enumCommands: [] + leftWaistHardness: + schema: + type: object + properties: + value: + title: leftWaistHardness + type: integer + minimum: 0 + maximum: 100 + unit: + type: string + enum: + - '%' + default: '%' + additionalProperties: false + required: + - value + enumCommands: [] + leftHipHardness: + schema: + type: object + properties: + value: + title: leftHipHardness + type: integer + minimum: 0 + maximum: 100 + unit: + type: string + enum: + - '%' + default: '%' + additionalProperties: false + required: + - value + enumCommands: [] + rightBackHardness: + schema: + type: object + properties: + value: + title: rightBackHardness + type: integer + minimum: 0 + maximum: 100 + unit: + type: string + enum: + - '%' + default: '%' + additionalProperties: false + required: + - value + enumCommands: [] + rightWaistHardness: + schema: + type: object + properties: + value: + title: rightWaistHardness + type: integer + minimum: 0 + maximum: 100 + unit: + type: string + enum: + - '%' + default: '%' + additionalProperties: false + required: + - value + enumCommands: [] + rightHipHardness: + schema: + type: object + properties: + value: + title: rightHipHardness + type: integer + minimum: 0 + maximum: 100 + unit: + type: string + enum: + - '%' + default: '%' + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} diff --git a/drivers/SmartThings/zigbee-bed/capabilities/rightControl.yaml b/drivers/SmartThings/zigbee-bed/capabilities/rightControl.yaml new file mode 100755 index 0000000000..3d04e5c03a --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/capabilities/rightControl.yaml @@ -0,0 +1,85 @@ +id: stse.rightControl +version: 1 +status: proposed +name: Right Control +ephemeral: false +attributes: + rightback: + schema: + type: object + properties: + value: + title: back + type: string + enum: + - idle + - soft + - hard + additionalProperties: false + required: + - value + setter: backControl + enumCommands: [] + rightwaist: + schema: + type: object + properties: + value: + title: waist + type: string + enum: + - idle + - soft + - hard + additionalProperties: false + required: + - value + setter: waistControl + enumCommands: [] + righthip: + schema: + type: object + properties: + value: + title: hip + type: string + enum: + - idle + - soft + - hard + additionalProperties: false + required: + - value + setter: hipControl + enumCommands: [] +commands: + backControl: + name: backControl + arguments: + - name: backControl + optional: false + schema: + type: string + enum: + - soft + - hard + waistControl: + name: waistControl + arguments: + - name: waistControl + optional: false + schema: + type: string + enum: + - soft + - hard + hipControl: + name: hipControl + arguments: + - name: hipControl + optional: false + schema: + type: string + enum: + - soft + - hard diff --git a/drivers/SmartThings/zigbee-bed/capabilities/strongExpMode.yaml b/drivers/SmartThings/zigbee-bed/capabilities/strongExpMode.yaml new file mode 100755 index 0000000000..f7f5c519e3 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/capabilities/strongExpMode.yaml @@ -0,0 +1,32 @@ +id: stse.strongExpMode +version: 1 +status: proposed +name: Strong Exp Mode +ephemeral: false +attributes: + expState: + schema: + type: object + properties: + value: + title: state + type: string + enum: + - "on" + - "off" + additionalProperties: false + required: + - value + setter: stateControl + enumCommands: [] +commands: + stateControl: + name: stateControl + arguments: + - name: stateControl + optional: false + schema: + type: string + enum: + - "on" + - "off" diff --git a/drivers/SmartThings/zigbee-bed/capabilities/yoga.yaml b/drivers/SmartThings/zigbee-bed/capabilities/yoga.yaml new file mode 100755 index 0000000000..69a722fa33 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/capabilities/yoga.yaml @@ -0,0 +1,50 @@ +id: stse.yoga +version: 1 +status: proposed +name: Yoga +ephemeral: false +attributes: + state: + schema: + type: object + properties: + value: + title: state + type: string + enum: + - stop + - left + - right + - both + additionalProperties: false + required: + - value + setter: stateControl + enumCommands: [] + supportedYogaState: + schema: + type: object + additionalProperties: false + properties: + value: + type: array + items: + type: string + enum: + - stop + - left + - right + - both +commands: + stateControl: + name: stateControl + arguments: + - name: stateControl + optional: false + schema: + type: string + enum: + - stop + - left + - right + - both diff --git a/drivers/SmartThings/zigbee-bed/config.yml b/drivers/SmartThings/zigbee-bed/config.yml new file mode 100755 index 0000000000..7695755bc7 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/config.yml @@ -0,0 +1,6 @@ +name: 'Zibgee Bed' +packageKey: 'zibgee-bed' +permissions: + zigbee: {} +description: "SmartThings driver for Zigbee Bed devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/zigbee-bed/fingerprints.yml b/drivers/SmartThings/zigbee-bed/fingerprints.yml new file mode 100755 index 0000000000..43eb780bb5 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/fingerprints.yml @@ -0,0 +1,6 @@ +zigbeeManufacturer: + - id: "SHUS/shus.smart.mattress" + deviceLabel: Sleepone Ai SX-1 + manufacturer: SHUS + model: SX-1 + deviceProfileName: shus-smart-mattress diff --git a/drivers/SmartThings/zigbee-bed/profiles/shus-smart-mattress.yml b/drivers/SmartThings/zigbee-bed/profiles/shus-smart-mattress.yml new file mode 100755 index 0000000000..55eda4e1b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/profiles/shus-smart-mattress.yml @@ -0,0 +1,31 @@ +name: shus-smart-mattress +components: + - id: main + capabilities: + - id: firmwareUpdate + ephemeral: false + - id: refresh + ephemeral: false + - id: stse.aiMode + version: 1 + ephemeral: false + - id: stse.autoInflation + version: 1 + ephemeral: false + - id: stse.strongExpMode + version: 1 + ephemeral: false + - id: stse.leftControl + version: 1 + ephemeral: false + - id: stse.rightControl + version: 1 + ephemeral: false + - id: stse.mattressHardness + version: 1 + ephemeral: false + - id: stse.yoga + version: 1 + ephemeral: false + categories: + - name: Bed diff --git a/drivers/SmartThings/zigbee-bed/src/init.lua b/drivers/SmartThings/zigbee-bed/src/init.lua new file mode 100755 index 0000000000..9d3e798b8b --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/init.lua @@ -0,0 +1,20 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" + +local zigbee_bed_template = { + supported_capabilities = { + capabilities.refresh, + }, + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, +} + +defaults.register_for_default_handlers(zigbee_bed_template, zigbee_bed_template.supported_capabilities) +local zigbee_bed_driver = ZigbeeDriver("zigbee-bed", zigbee_bed_template) +zigbee_bed_driver:run() diff --git a/drivers/SmartThings/zigbee-bed/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-bed/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/can_handle.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/can_handle.lua new file mode 100644 index 0000000000..0adcc5e46e --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_shus_products(opts, driver, device) + local FINGERPRINTS = require("shus-mattress.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("shus-mattress") + end + end + return false +end + +return is_shus_products diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua new file mode 100755 index 0000000000..fdd5ab0004 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua @@ -0,0 +1,16 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +local custom_capabilities = { + ai_mode = capabilities["stse.aiMode"], + auto_inflation = capabilities["stse.autoInflation"], + strong_exp_mode = capabilities["stse.strongExpMode"], + left_control = capabilities["stse.leftControl"], + right_control = capabilities["stse.rightControl"], + yoga = capabilities["stse.yoga"], + mattressHardness = capabilities["stse.mattressHardness"] +} + +return custom_capabilities diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua new file mode 100755 index 0000000000..db220da460 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua @@ -0,0 +1,129 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.zigbee.data_types" + +local custom_clusters = { + shus_smart_mattress = { + id = 0xFCC2, + mfg_specific_code = 0x1235, + attributes = { + left_ai_mode = { + id = 0x0006, + value_type = data_types.Boolean, + value = { + on = true, + off = false, + } + }, + right_ai_mode = { + id = 0x0007, + value_type = data_types.Boolean, + value = { + on = true, + off = false, + } + }, + auto_inflation = { + id = 0x0009, + value_type = data_types.Boolean, + value = { + on = true, + off = false, + } + }, + strong_exp_mode = { + id = 0x000A, + value_type = data_types.Boolean, + value = { + on = true, + off = false, + } + }, + left_back = { + id = 0x0000, + value_type = data_types.Uint8, + value = { + soft = 0, + hard = 1, + } + }, + left_waist = { + id = 0x0001, + value_type = data_types.Uint8, + value = { + soft = 0, + hard = 1, + } + }, + left_hip = { + id = 0x0002, + value_type = data_types.Uint8, + value = { + soft = 0, + hard = 1, + } + }, + right_back = { + id = 0x0003, + value_type = data_types.Uint8, + value = { + soft = 0, + hard = 1, + } + }, + right_waist = { + id = 0x0004, + value_type = data_types.Uint8, + value = { + soft = 0, + hard = 1, + } + }, + right_hip = { + id = 0x0005, + value_type = data_types.Uint8, + value = { + soft = 0, + hard = 1, + } + }, + yoga = { + id = 0x0008, + value_type = data_types.Uint8, + value = { + stop = 0, + left = 1, + right = 2, + both = 3, + } + }, + left_back_level = { + id = 0x000C, + value_type = data_types.Uint8 + }, + left_waist_level = { + id = 0x000D, + value_type = data_types.Uint8 + }, + left_hip_level = { + id = 0x000E, + value_type = data_types.Uint8 + }, + right_back_level = { + id = 0x000F, + value_type = data_types.Uint8 + }, + right_waist_level = { + id = 0x0010, + value_type = data_types.Uint8 + }, + right_hip_level = { + id = 0x0011, + value_type = data_types.Uint8 + } + } + } +} + +return custom_clusters diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/fingerprints.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/fingerprints.lua new file mode 100644 index 0000000000..2d6c80ee1a --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "SHUS", model = "SX-1" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua new file mode 100755 index 0000000000..65cf64e446 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua @@ -0,0 +1,214 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local custom_clusters = require "shus-mattress/custom_clusters" +local custom_capabilities = require "shus-mattress/custom_capabilities" + + +-- ############################# +-- # Attribute handlers define # +-- ############################# +local function process_switch_attr_factory(cmd) + return function(driver, device, value, zb_rx) + if value.value == false then + device:emit_event(cmd.off()) + elseif value.value == true then + device:emit_event(cmd.on()) + end + end +end + +local function process_control_attr_factory(cmd) + return function(driver, device, value, zb_rx) + device:emit_event(cmd("idle", { visibility = { displayed = false }})) + end +end + +local function process_level_factory(cmd) + return function(driver, device, value, zb_rx) + device:emit_event(cmd(value.value)) + end +end + +local function yoga_attr_handler(driver, device, value, zb_rx) + if value.value == 0 then + device:emit_event(custom_capabilities.yoga.state.stop()) + elseif value.value == 1 then + device:emit_event(custom_capabilities.yoga.state.left()) + elseif value.value == 2 then + device:emit_event(custom_capabilities.yoga.state.right()) + elseif value.value == 3 then + device:emit_event(custom_capabilities.yoga.state.both()) + end +end + +-- ############################## +-- # Capability handlers define # +-- ############################## + +local function send_read_attr_request(device, cluster, attr) + device:send( + cluster_base.read_manufacturer_specific_attribute( + device, + cluster.id, + attr.id, + cluster.mfg_specific_code + ) + ) +end + +local function do_refresh(driver, device) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.left_ai_mode) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.right_ai_mode) + + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.auto_inflation) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.strong_exp_mode) + + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.left_back) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.left_waist) + + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.left_hip) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.right_back) + + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.right_waist) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.right_hip) + + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.yoga) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.left_back_level) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.left_waist_level) + + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.left_hip_level) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.right_back_level) + + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.right_waist_level) + send_read_attr_request(device, custom_clusters.shus_smart_mattress, custom_clusters.shus_smart_mattress.attributes.right_hip_level) +end + +local function process_capabilities_factory(cap,attrs) + return function(driver, device, cmd) + device:send( + cluster_base.write_manufacturer_specific_attribute( + device, + custom_clusters.shus_smart_mattress.id, + custom_clusters.shus_smart_mattress.attributes[attrs].id, + custom_clusters.shus_smart_mattress.mfg_specific_code, + custom_clusters.shus_smart_mattress.attributes[attrs].value_type, + custom_clusters.shus_smart_mattress.attributes[attrs].value[cmd.args[cap]] + ) + ) + end +end + +local function process_capabilities_hardness_factory(cap,attrs,cap_attr) + return function(driver, device, cmd) + device:send( + cluster_base.write_manufacturer_specific_attribute( + device, + custom_clusters.shus_smart_mattress.id, + custom_clusters.shus_smart_mattress.attributes[attrs].id, + custom_clusters.shus_smart_mattress.mfg_specific_code, + custom_clusters.shus_smart_mattress.attributes[attrs].value_type, + custom_clusters.shus_smart_mattress.attributes[attrs].value[cmd.args[cap]] + ) + ) + --A button that can be triggered continuously + local evt_ctrl = cap_attr.soft() + local evt_idle = cap_attr("idle", { visibility = { displayed = false }}) + if cmd.args[cap] == "hard" then + evt_ctrl = cap_attr.hard() + end + device:emit_event(evt_ctrl) + device.thread:call_with_delay(1, function(d) + device:emit_event(evt_idle) + end) + end +end + +-- ############################# +-- # Lifecycle handlers define # +-- ############################# + +local function device_init(driver, device) +end + +local function device_added(driver, device) + device:emit_event(custom_capabilities.yoga.supportedYogaState({"stop", "left", "right"}, { visibility = { displayed = false }})) + do_refresh(driver, device) +end + +local function do_configure(driver, device) +end + + +-- ################# +-- # Handlers bind # +-- ################# + +local shus_smart_mattress = { + NAME = "Shus Smart Mattress", + supported_capabilities = { + capabilities.refresh + }, + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure + }, + zigbee_handlers = { + attr = { + [custom_clusters.shus_smart_mattress.id] = { + [custom_clusters.shus_smart_mattress.attributes.left_ai_mode.id] = process_switch_attr_factory(custom_capabilities.ai_mode.left), + [custom_clusters.shus_smart_mattress.attributes.right_ai_mode.id] = process_switch_attr_factory(custom_capabilities.ai_mode.right), + [custom_clusters.shus_smart_mattress.attributes.auto_inflation.id] = process_switch_attr_factory(custom_capabilities.auto_inflation.inflationState), + [custom_clusters.shus_smart_mattress.attributes.strong_exp_mode.id] = process_switch_attr_factory(custom_capabilities.strong_exp_mode.expState), + [custom_clusters.shus_smart_mattress.attributes.left_back.id] = process_control_attr_factory(custom_capabilities.left_control.leftback), + [custom_clusters.shus_smart_mattress.attributes.left_waist.id] = process_control_attr_factory(custom_capabilities.left_control.leftwaist), + [custom_clusters.shus_smart_mattress.attributes.left_hip.id] = process_control_attr_factory(custom_capabilities.left_control.lefthip), + [custom_clusters.shus_smart_mattress.attributes.right_back.id] = process_control_attr_factory(custom_capabilities.right_control.rightback), + [custom_clusters.shus_smart_mattress.attributes.right_waist.id] = process_control_attr_factory(custom_capabilities.right_control.rightwaist), + [custom_clusters.shus_smart_mattress.attributes.right_hip.id] = process_control_attr_factory(custom_capabilities.right_control.righthip), + [custom_clusters.shus_smart_mattress.attributes.yoga.id] = yoga_attr_handler, + [custom_clusters.shus_smart_mattress.attributes.left_back_level.id] = process_level_factory(custom_capabilities.mattressHardness.leftBackHardness), + [custom_clusters.shus_smart_mattress.attributes.left_waist_level.id] = process_level_factory(custom_capabilities.mattressHardness.leftWaistHardness), + [custom_clusters.shus_smart_mattress.attributes.left_hip_level.id] = process_level_factory(custom_capabilities.mattressHardness.leftHipHardness), + [custom_clusters.shus_smart_mattress.attributes.right_back_level.id] = process_level_factory(custom_capabilities.mattressHardness.rightBackHardness), + [custom_clusters.shus_smart_mattress.attributes.right_waist_level.id] = process_level_factory(custom_capabilities.mattressHardness.rightWaistHardness), + [custom_clusters.shus_smart_mattress.attributes.right_hip_level.id] = process_level_factory(custom_capabilities.mattressHardness.rightHipHardness) + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + }, + [custom_capabilities.ai_mode.ID] = { + ["leftControl"] = process_capabilities_factory("leftControl","left_ai_mode"), + ["rightControl"] = process_capabilities_factory("rightControl","right_ai_mode") + }, + [custom_capabilities.auto_inflation.ID] = { + ["stateControl"] = process_capabilities_factory("stateControl","auto_inflation") + }, + [custom_capabilities.strong_exp_mode.ID] = { + ["stateControl"] = process_capabilities_factory("stateControl","strong_exp_mode") + }, + [custom_capabilities.left_control.ID] = { + ["backControl"] = process_capabilities_hardness_factory("backControl","left_back",custom_capabilities.left_control.leftback), + ["waistControl"] = process_capabilities_hardness_factory("waistControl","left_waist",custom_capabilities.left_control.leftwaist), + ["hipControl"] = process_capabilities_hardness_factory("hipControl","left_hip",custom_capabilities.left_control.lefthip) + }, + [custom_capabilities.right_control.ID] = { + ["backControl"] = process_capabilities_hardness_factory("backControl","right_back",custom_capabilities.right_control.rightback), + ["waistControl"] = process_capabilities_hardness_factory("waistControl","right_waist",custom_capabilities.right_control.rightwaist), + ["hipControl"] = process_capabilities_hardness_factory("hipControl","right_hip",custom_capabilities.right_control.righthip) + }, + [custom_capabilities.yoga.ID] = { + ["stateControl"] = process_capabilities_factory("stateControl","yoga") + } + }, + can_handle = require("shus-mattress.can_handle"), +} + +return shus_smart_mattress diff --git a/drivers/SmartThings/zigbee-bed/src/sub_drivers.lua b/drivers/SmartThings/zigbee-bed/src/sub_drivers.lua new file mode 100644 index 0000000000..3e78841f3b --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("shus-mattress"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua b/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua new file mode 100644 index 0000000000..2b02b5ec73 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua @@ -0,0 +1,1119 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local shus_mattress_profile_def = t_utils.get_profile_definition("shus-smart-mattress.yml") +test.add_package_capability("aiMode.yaml") +test.add_package_capability("autoInflation.yaml") +test.add_package_capability("leftControl.yaml") +test.add_package_capability("rightControl.yaml") +test.add_package_capability("strongExpMode.yaml") +test.add_package_capability("yoga.yaml") +test.add_package_capability("mattressHardness.yaml") + +local custom_capabilities = require "shus-mattress/custom_capabilities" + +local PRIVATE_CLUSTER_ID = 0xFCC2 +local MFG_CODE = 0x1235 + +local mock_device = test.mock_device.build_test_zigbee_device( +{ + label = "Shus Smart Mattress", + profile = shus_mattress_profile_def, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SHUS", + model = "SX-1", + server_clusters = { 0x0000,PRIVATE_CLUSTER_ID } + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.supportedYogaState({"stop", "left", "right"}, { visibility = { displayed = false }}) )) + local read_0x0006_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0006, MFG_CODE) + local read_0x0007_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0007, MFG_CODE) + local read_0x0009_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0009, MFG_CODE) + local read_0x000a_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000a, MFG_CODE) + local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) + local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) + local read_0x0002_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE) + local read_0x0003_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0003, MFG_CODE) + local read_0x0004_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0004, MFG_CODE) + local read_0x0005_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0005, MFG_CODE) + local read_0x0008_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0008, MFG_CODE) + local read_0x000C_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000C, MFG_CODE) + local read_0x000D_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000D, MFG_CODE) + local read_0x000E_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000E, MFG_CODE) + local read_0x000F_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000F, MFG_CODE) + local read_0x0010_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0010, MFG_CODE) + local read_0x0011_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0011, MFG_CODE) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0006_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0007_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0009_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000a_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0002_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0003_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0004_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0005_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0008_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000C_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000D_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000E_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000F_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0010_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0011_messge}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability - refresh", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + local read_0x0006_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0006, MFG_CODE) + local read_0x0007_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0007, MFG_CODE) + local read_0x0009_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0009, MFG_CODE) + local read_0x000a_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000a, MFG_CODE) + local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) + local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) + local read_0x0002_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE) + local read_0x0003_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0003, MFG_CODE) + local read_0x0004_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0004, MFG_CODE) + local read_0x0005_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0005, MFG_CODE) + local read_0x0008_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0008, MFG_CODE) + local read_0x000C_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000C, MFG_CODE) + local read_0x000D_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000D, MFG_CODE) + local read_0x000E_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000E, MFG_CODE) + local read_0x000F_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000F, MFG_CODE) + local read_0x0010_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0010, MFG_CODE) + local read_0x0011_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0011, MFG_CODE) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0006_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0007_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0009_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000a_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0002_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0003_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0004_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0005_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0008_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000C_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000D_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000E_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000F_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0010_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0011_messge}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported leftback 0 and driver emit custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported leftback 1 and driver emit custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported leftwaist 0 and driver emit custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0001, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported leftwaist 1 and driver emit custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0001, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported lefthip 0 and driver emit custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0002, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported lefthip 1 and driver emit custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0002, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported rightback 0 and driver emit custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0003, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported rightback 1 and driver emit custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0003, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported rightwaist 0 and driver emit custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0004, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported rightwaist 1 and driver emit custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0004, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported righthip 0 and driver emit custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0005, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported righthip 1 and driver emit custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0005, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported leftBackHardness 1 and driver emit custom_capabilities.mattressHardness.leftBackHardness(1)", + function() + local attr_report_data = { + { 0x000C, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.leftBackHardness(1))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported leftWaistHardness 1 and driver emit custom_capabilities.mattressHardness.leftWaistHardness(1)", + function() + local attr_report_data = { + { 0x000D, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.leftWaistHardness(1))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported leftHipHardness 1 and driver emit custom_capabilities.mattressHardness.leftHipHardness(1)", + function() + local attr_report_data = { + { 0x000E, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.leftHipHardness(1))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported rightBackHardness 1 and driver emit custom_capabilities.mattressHardness.rightBackHardness(1)", + function() + local attr_report_data = { + { 0x000F, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.rightBackHardness(1))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported rightWaistHardness 1 and driver emit custom_capabilities.mattressHardness.rightWaistHardness(1)", + function() + local attr_report_data = { + { 0x0010, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.rightWaistHardness(1))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported rightHipHardness 1 and driver emit custom_capabilities.mattressHardness.rightHipHardness(1)", + function() + local attr_report_data = { + { 0x0011, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.rightHipHardness(1))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported yoga 3 and driver emit custom_capabilities.yoga.state.both()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 3 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.both())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported yoga 2 and driver emit custom_capabilities.yoga.state.right()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 2 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.right())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported yoga 1 and driver emit custom_capabilities.yoga.state.left()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.left())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported yoga 0 and driver emit custom_capabilities.yoga.state.stop()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.stop())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported ai_mode left false and driver emit custom_capabilities.ai_mode.left.off()", + function() + local attr_report_data = { + { 0x0006, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.left.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported ai_mode left true and driver emit custom_capabilities.ai_mode.left.on()", + function() + local attr_report_data = { + { 0x0006, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.left.on())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported ai_mode right true and driver emit custom_capabilities.ai_mode.right.on()", + function() + local attr_report_data = { + { 0x0007, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.right.on())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported ai_mode right false and driver emit custom_capabilities.ai_mode.right.off()", + function() + local attr_report_data = { + { 0x0007, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.right.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported inflationState false and driver emit custom_capabilities.auto_inflation.inflationState.off()", + function() + local attr_report_data = { + { 0x0009, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.auto_inflation.inflationState.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported inflationState true and driver emit custom_capabilities.auto_inflation.inflationState.on()", + function() + local attr_report_data = { + { 0x0009, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.auto_inflation.inflationState.on())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported strong_exp_mode false and driver emit custom_capabilities.strong_exp_mode.expState.off()", + function() + local attr_report_data = { + { 0x000a, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.strong_exp_mode.expState.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported strong_exp_mode true and driver emit custom_capabilities.strong_exp_mode.expState.on()", + function() + local attr_report_data = { + { 0x000a, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.strong_exp_mode.expState.on())) + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "capability leftControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="leftControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0006, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability leftControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="leftControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0006, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability rightControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="rightControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0007, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability rightControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="rightControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0007, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability auto_inflation stateControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.auto_inflation.ID, component = "main", command ="stateControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0009, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability auto_inflation stateControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.auto_inflation.ID, component = "main", command ="stateControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0009, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability strong_exp_mode stateControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.strong_exp_mode.ID, component = "main", command ="stateControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x000a, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability strong_exp_mode stateControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.strong_exp_mode.ID, component = "main", command ="stateControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x000a, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability left_control backControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.soft())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability waistControl backControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="waistControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0001, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.soft())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability left_control hipControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="hipControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0002, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.soft())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability left_control backControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.hard())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability waistControl backControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="waistControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0001, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.hard())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability left_control hipControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="hipControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0002, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.hard())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability right_control backControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="backControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0003, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.soft())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability right_control waistControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="waistControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0004, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.soft())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability right_control hipControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="hipControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0005, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.soft())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability right_control backControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="backControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0003, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.hard())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability right_control waistControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="waistControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0004, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.hard())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability right_control hipControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="hipControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0005, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.hard())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability yoga stateControl left and driver send left ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"left"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0008, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability yoga stateControl right and driver send right ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"right"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0008, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability yoga stateControl stop and driver send stop ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"stop"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0008, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "capability left_control backControl soft emits idle event after delay", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.soft())) + test.wait_for_events() + + test.mock_time.advance_time(1) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback("idle", { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/fingerprints.yml b/drivers/SmartThings/zigbee-button/fingerprints.yml index 04937ff5c7..347fa818df 100644 --- a/drivers/SmartThings/zigbee-button/fingerprints.yml +++ b/drivers/SmartThings/zigbee-button/fingerprints.yml @@ -1,14 +1,44 @@ zigbeeManufacturer: + - id: "Vimar/xx591-remote-control" + deviceLabel: Vimar Remote Control + manufacturer: Vimar + model: "RemoteControl_v1.0" + deviceProfileName: two-buttons-no-fw-update - id: "LUMI/lumi.remote.b1acn02" deviceLabel: Aqara Wireless Mini Switch T1 manufacturer: LUMI model: lumi.remote.b1acn02 - deviceProfileName: one-button-battery + deviceProfileName: one-button-batteryLevel - id: "LUMI/lumi.remote.acn003" deviceLabel: Aqara Wireless Remote Switch E1 (Single Rocker) manufacturer: LUMI model: lumi.remote.acn003 - deviceProfileName: one-button-battery + deviceProfileName: one-button-batteryLevel + - id: "LUMI/lumi.remote.b186acn03" + deviceLabel: Aqara Wireless Remote Switch T1 (Single Rocker) + manufacturer: LUMI + model: lumi.remote.b186acn03 + deviceProfileName: one-button-batteryLevel + - id: "LUMI/lumi.remote.b286acn03" + deviceLabel: Aqara Wireless Remote Switch T1 (Double Rocker) + manufacturer: LUMI + model: lumi.remote.b286acn03 + deviceProfileName: aqara-double-buttons + - id: "LUMI/lumi.remote.b18ac1" + deviceLabel: Aqara Wireless Remote Switch H1 (Single Rocker) + manufacturer: LUMI + model: lumi.remote.b18ac1 + deviceProfileName: aqara-single-button-mode + - id: "LUMI/lumi.remote.b28ac1" + deviceLabel: Aqara Wireless Remote Switch H1 (Double Rocker) + manufacturer: LUMI + model: lumi.remote.b28ac1 + deviceProfileName: aqara-double-buttons-mode + - id: "lumi/lumi.remote.rkba01" + deviceLabel: "Aqara Wireless Smart Knob H1" + manufacturer: LUMI + model: lumi.remote.rkba01 + deviceProfileName: aqara-knob-switch - id: "HEIMAN/SOS-EM" deviceLabel: HEIMAN Button manufacturer: HEIMAN @@ -18,7 +48,12 @@ zigbeeManufacturer: deviceLabel: frient Button manufacturer: frient A/S model: MBTZB-110 - deviceProfileName: button-profile + deviceProfileName: button-profile-frient + - id: "frient A/S/SBTZB-110" + deviceLabel: frient Button + manufacturer: frient A/S + model: SBTZB-110 + deviceProfileName: button-profile-frient - id: "LDS/ZBT-CCTSwitch-D0001" deviceLabel: EcoSmart Remote Control manufacturer: LDS @@ -129,6 +164,31 @@ zigbeeManufacturer: manufacturer: ShinaSystem model: SBM300ZB3 deviceProfileName: three-buttons-battery + - id: "ShinaSystem/SBM300ZC1" + deviceLabel: SiHAS Remote Control + manufacturer: ShinaSystem + model: SBM300ZC1 + deviceProfileName: one-button-battery + - id: "ShinaSystem/SBM300ZC2" + deviceLabel: SiHAS Remote Control + manufacturer: ShinaSystem + model: SBM300ZC2 + deviceProfileName: two-buttons-battery + - id: "ShinaSystem/SBM300ZC3" + deviceLabel: SiHAS Remote Control + manufacturer: ShinaSystem + model: SBM300ZC3 + deviceProfileName: three-buttons-battery + - id: "ShinaSystem/SBM300ZC4" + deviceLabel: SiHAS Remote Control + manufacturer: ShinaSystem + model: SBM300ZC4 + deviceProfileName: four-buttons-battery + - id: "ShinaSystem/SQM300ZC4" + deviceLabel: SiHAS Remote Control + manufacturer: ShinaSystem + model: SQM300ZC4 + deviceProfileName: four-buttons-battery - id: OSRAM/LIGHTIFY Dimming Switch deviceLabel: OSRAM Button manufacturer: OSRAM @@ -154,13 +214,18 @@ zigbeeManufacturer: manufacturer: Samjin model: button deviceProfileName: one-button-temp-battery + - id: SlateSwitch/button + deviceLabel: Slate Switch Button + manufacturer: zunzunbee + model: SSWZ8T + deviceProfileName: eight-buttons-temp-battery - id: eWeLink/WB01 deviceLabel: eWeLink Button manufacturer: eWeLink model: WB01 deviceProfileName: one-button-battery - id: eWeLink/SNZB-01P - deviceLabel: eWeLink Button + deviceLabel: Zigbee Wireless Switch manufacturer: eWeLink model: SNZB-01P deviceProfileName: one-button-battery @@ -184,9 +249,41 @@ zigbeeManufacturer: manufacturer: Samsung Electronics model: SAMSUNG-ITM-Z-005 deviceProfileName: SLED-three-buttons - # # Wall Hero + - id: "Linxura Smart Controller" + deviceLabel: Linxura Smart Controller + manufacturer: Linxura + model: Smart Controller + deviceProfileName: four-buttons-without-main-button + isJoinable: true + - id: "Aura Smart button" + deviceLabel: Aura Smart Button + manufacturer: Linxura + model: Aura Smart Button + deviceProfileName: four-buttons-without-main-button + isJoinable: true + # Wall Hero - id: "WALL HERO/ACL-401SCA4" - deviceLabel: 语音场景(4寸)面板 + deviceLabel: WallHero Remote Control (4-Inch) manufacturer: WALL HERO model: ACL-401SCA4 deviceProfileName: thirty-buttons + # SONOFF + - id: "SONOFF/SNZB-01M" + deviceLabel: SNZB-01M + manufacturer: SONOFF + model: SNZB-01M + deviceProfileName: four-buttons-battery + - id: "MultIR/MIR-SO100" + deviceLabel: MultiIR Smart button MIR-SO100 + manufacturer: MultIR + model: MIR-SO100 + deviceProfileName: one-button-battery-no-fw-update +zigbeeGeneric: + - id: "generic-button-sensor" + deviceLabel: "Zigbee Generic Button" + clusters: + server: + - 0x0500 + - 0xFE00 # EZVIZ private cluster + - 0xFE05 # EZVIZ private cluster + deviceProfileName: one-button-battery diff --git a/drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons-mode.yml b/drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons-mode.yml new file mode 100644 index 0000000000..f19f19da57 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons-mode.yml @@ -0,0 +1,35 @@ +name: aqara-double-buttons-mode +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: all + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - preferenceId: stse.allowOperationModeChange + explicit: true diff --git a/drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons.yml b/drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons.yml new file mode 100644 index 0000000000..07a4882783 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/aqara-double-buttons.yml @@ -0,0 +1,32 @@ +name: aqara-double-buttons +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: all + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/zigbee-button/profiles/aqara-knob-switch.yml b/drivers/SmartThings/zigbee-button/profiles/aqara-knob-switch.yml new file mode 100644 index 0000000000..1c197504bb --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/aqara-knob-switch.yml @@ -0,0 +1,19 @@ +name: aqara-knob-switch +components: + - id: main + capabilities: + - id: button + version: 1 + - id: knob + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button +preferences: + - preferenceId: stse.knobSensitivity + explicit: true diff --git a/drivers/SmartThings/zigbee-button/profiles/aqara-single-button-mode.yml b/drivers/SmartThings/zigbee-button/profiles/aqara-single-button-mode.yml new file mode 100644 index 0000000000..904de72281 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/aqara-single-button-mode.yml @@ -0,0 +1,17 @@ +name: aqara-single-button-mode +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button +preferences: + - preferenceId: stse.allowOperationModeChange + explicit: true diff --git a/drivers/SmartThings/zigbee-button/profiles/button-profile-frient.yml b/drivers/SmartThings/zigbee-button/profiles/button-profile-frient.yml new file mode 100644 index 0000000000..c817709b4d --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/button-profile-frient.yml @@ -0,0 +1,46 @@ +name: button-profile-frient +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - title: "LED Color" + name: ledColor + description: "Color of LED when button is pressed (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Off" + 1: "Red" + 2: "Green" + 3: "Yellow" + default: "2" + - title: "Button press delay (ms)" + name: buttonDelay + description: "Delay before button press is registered in milliseconds (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 8000 + default: 100 + - title: "Use as Panic Button" + name: panicButton + description: "Use as Panic Button (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting. May take up to 1 minute to reconfigure the device)." + required: false + preferenceType: enumeration + definition: + options: + 0xFFFF: "Off" + 0x002C: "On" + default: "0xFFFF" \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-button/profiles/button-profile-panic-frient.yml b/drivers/SmartThings/zigbee-button/profiles/button-profile-panic-frient.yml new file mode 100644 index 0000000000..61bb0d0add --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/button-profile-panic-frient.yml @@ -0,0 +1,85 @@ +name: button-profile-panic-frient +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + - id: panicAlarm + version: 1 + categories: + - name: RemoteController +preferences: + - title: "LED Color" + name: ledColor + description: "Color of LED when button is pressed (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Off" + 1: "Red" + 2: "Green" + 3: "Yellow" + default: "2" + - title: "Button press delay (ms)" + name: buttonDelay + description: "Delay before button press is registered in milliseconds (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 8000 + default: 100 + - title: "Use as Panic Button" + name: panicButton + description: "Use as Panic Button (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting. May take up to 1 minute to reconfigure the device)." + required: false + preferenceType: enumeration + definition: + options: + 0xFFFF: "Off" + 0x002C: "On" + default: "0x002C" + - title: "Hold to activate alarm (ms)" + name: buttonAlarmDelay + description: "Delay before button press is registered as alarm in milliseconds (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 8000 + default: 2000 + - title: "Hold to cancel alarm (ms)" + name: buttonCancelDelay + description: "Delay before button press cancels alarm in milliseconds (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 8000 + default: 2000 + - title: "Auto alarm cancel (s)" + name: autoCancel + description: "Set the time the alarm will be automatically canceled in seconds (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65536 + default: 10 + - title: "Alarm LED behavior" + name: alarmBehavior + description: "Choose the behavior of the LED when the alarm is triggered (since it is a battery powered device, it is recommended to press the button to wake it up right before or right after changing this setting)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Immediate" + 1: "Wait for confirmation" + default: "0" diff --git a/drivers/SmartThings/zigbee-button/profiles/button-profile.yml b/drivers/SmartThings/zigbee-button/profiles/button-profile.yml index eee9dc7e96..39c8466d52 100644 --- a/drivers/SmartThings/zigbee-button/profiles/button-profile.yml +++ b/drivers/SmartThings/zigbee-button/profiles/button-profile.yml @@ -11,4 +11,4 @@ components: - id: refresh version: 1 categories: - - name: RemoteController + - name: Button diff --git a/drivers/SmartThings/zigbee-button/profiles/eight-buttons-temp-battery.yml b/drivers/SmartThings/zigbee-button/profiles/eight-buttons-temp-battery.yml new file mode 100644 index 0000000000..2194d4b15f --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/eight-buttons-temp-battery.yml @@ -0,0 +1,72 @@ +name: eight-buttons-temp-battery +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: tone + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + - id: temperatureMeasurement + version: 1 + categories: + - name: RemoteController + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +metadata: + mnmn: SmartThingsCommunity + vid: b85e2d6f-0ea4-38e2-b5f4-31c3873b1920 +preferences: + - preferenceId: tempOffset + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-button/profiles/four-buttons-without-main-button.yml b/drivers/SmartThings/zigbee-button/profiles/four-buttons-without-main-button.yml new file mode 100644 index 0000000000..f0e7b90c2f --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/four-buttons-without-main-button.yml @@ -0,0 +1,32 @@ +name: four-buttons-without-main-button +components: + - id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-button/profiles/iris-one-button-battery.yml b/drivers/SmartThings/zigbee-button/profiles/iris-one-button-battery.yml index 306a8c7c3f..db1ea84769 100644 --- a/drivers/SmartThings/zigbee-button/profiles/iris-one-button-battery.yml +++ b/drivers/SmartThings/zigbee-button/profiles/iris-one-button-battery.yml @@ -11,7 +11,7 @@ components: - id: refresh version: 1 categories: - - name: RemoteController + - name: Button preferences: - name: "holdTime" title: "Hold time" diff --git a/drivers/SmartThings/zigbee-button/profiles/one-button-battery-no-fw-update.yml b/drivers/SmartThings/zigbee-button/profiles/one-button-battery-no-fw-update.yml new file mode 100644 index 0000000000..98175a88ea --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/one-button-battery-no-fw-update.yml @@ -0,0 +1,12 @@ +name: one-button-battery-no-fw-update +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/zigbee-button/profiles/one-button-battery.yml b/drivers/SmartThings/zigbee-button/profiles/one-button-battery.yml index f47193212d..7f956b61c8 100644 --- a/drivers/SmartThings/zigbee-button/profiles/one-button-battery.yml +++ b/drivers/SmartThings/zigbee-button/profiles/one-button-battery.yml @@ -11,4 +11,4 @@ components: - id: refresh version: 1 categories: - - name: RemoteController + - name: Button diff --git a/drivers/SmartThings/zigbee-button/profiles/one-button-batteryLevel.yml b/drivers/SmartThings/zigbee-button/profiles/one-button-batteryLevel.yml new file mode 100644 index 0000000000..005e41256f --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/one-button-batteryLevel.yml @@ -0,0 +1,14 @@ +name: one-button-batteryLevel +components: + - id: main + capabilities: + - id: button + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/zigbee-button/profiles/one-button.yml b/drivers/SmartThings/zigbee-button/profiles/one-button.yml index 6c8873c4a8..065678dcc8 100644 --- a/drivers/SmartThings/zigbee-button/profiles/one-button.yml +++ b/drivers/SmartThings/zigbee-button/profiles/one-button.yml @@ -9,4 +9,5 @@ components: - id: refresh version: 1 categories: - - name: RemoteController + - name: Button + diff --git a/drivers/SmartThings/zigbee-button/profiles/two-buttons-no-fw-update.yml b/drivers/SmartThings/zigbee-button/profiles/two-buttons-no-fw-update.yml new file mode 100644 index 0000000000..c11b3915eb --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/two-buttons-no-fw-update.yml @@ -0,0 +1,22 @@ +name: two-buttons-no-fw-update +components: + - id: main + capabilities: + - id: button + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/zigbee-button/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-button/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-button/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..f9e21e49b9 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-SO100" } +} diff --git a/drivers/SmartThings/zigbee-button/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-button/src/MultiIR/init.lua new file mode 100644 index 0000000000..06d6d98f7e --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/MultiIR/init.lua @@ -0,0 +1,37 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local log = require "log" + +local IASZone = zcl_clusters.IASZone +local PRIVATE_CMD_ID = 0xF1 + +local function ias_zone_private_cmd_handler(self, device, zb_rx) + local cmd_data = zb_rx.body.zcl_body.body_bytes:byte(1) + if cmd_data == 0 then + device:emit_event(capabilities.button.button.pushed({state_change = true})) + elseif cmd_data == 1 then + device:emit_event(capabilities.button.button.double({state_change = true})) + elseif cmd_data == 0x80 then + device:emit_event(capabilities.button.button.held({state_change = true})) + else + log.info("ias_zone_private_cmd Unknown value",zb_rx.body.zcl_body.body_bytes:byte(1)) + end +end + +local MultiIR_Emergency_Button = { + NAME = "MultiIR Emergency Button", + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [PRIVATE_CMD_ID] = ias_zone_private_cmd_handler + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_Emergency_Button diff --git a/drivers/SmartThings/zigbee-button/src/aqara-knob/can_handle.lua b/drivers/SmartThings/zigbee-button/src/aqara-knob/can_handle.lua new file mode 100644 index 0000000000..a5a48f6cb9 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara-knob/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device, ...) + local FINGERPRINTS = { mfr = "LUMI", model = "lumi.remote.rkba01" } + + if device:get_manufacturer() == FINGERPRINTS.mfr and device:get_model() == FINGERPRINTS.model then + return true, require("aqara-knob") + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-button/src/aqara-knob/init.lua b/drivers/SmartThings/zigbee-button/src/aqara-knob/init.lua new file mode 100644 index 0000000000..3b979baa24 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara-knob/init.lua @@ -0,0 +1,141 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +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 utils = require "st.utils" +local button_utils = require "button_utils" + +local PowerConfiguration = clusters.PowerConfiguration +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local MFG_CODE = 0x115F +local MULTISTATE_INPUT_CLUSTER_ID = 0x0012 +local PRESENT_ATTRIBUTE_ID = 0x0055 +local ROTATION_MONITOR_ID = 0x0232 + +local AQARA_KNOB = { + ["lumi.remote.rkba01"] = { mfr = "LUMI", type = "CR2032", quantity = 2 }, -- Aqara Wireless Knob Switch H1 +} + +local function device_init(driver, device) + local configuration = { + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryVoltage.ID, + minimum_interval = 30, + maximum_interval = 3600, + data_type = PowerConfiguration.attributes.BatteryVoltage.base_type, + reportable_change = 1 + } + } + + battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end +end + +local function device_added(self, device) + local model = device:get_model() + local type = AQARA_KNOB[model].type or "CR2032" + local quantity = AQARA_KNOB[model].quantity or 1 + + device:emit_event(capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } })) + device:emit_event(capabilities.button.numberOfButtons({ value = 1 })) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, + capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) + device:emit_event(capabilities.batteryLevel.battery.normal()) + device:emit_event(capabilities.batteryLevel.type(type)) + device:emit_event(capabilities.batteryLevel.quantity(quantity)) + device:emit_event(capabilities.knob.rotateAmount(0)) + device:emit_event(capabilities.knob.heldRotateAmount(0)) +end + +local function do_configure(driver, device) + device:configure() + -- Set manufacturer-specific attribute to enable "Operation Mode" for rotation reports + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1)) + device:emit_event(capabilities.knob.supportedAttributes({"rotateAmount", "heldRotateAmount"}, {state_change = true})) +end + +local function button_monitor_handler(driver, device, value, zb_rx) + local val = value.value + + if val == 1 then -- push + device:emit_event(capabilities.button.button.pushed({ state_change = true })) + elseif val == 2 then -- double push + device:emit_event(capabilities.button.button.double({ state_change = true })) + elseif val == 0 then -- down_hold + device:emit_event(capabilities.button.button.held({ state_change = true })) + end +end + +local function rotation_monitor_per_handler(driver, device, value, zb_rx) + local SENSITIVITY_KEY = "stse.knobSensitivity" + local SENSITIVITY_FACTORS = {0.5, 1.0, 2.0} + + local end_point = zb_rx.address_header.src_endpoint.value + local raw_val = utils.round(value.value) + + if raw_val > 0x7FFF then + raw_val = raw_val - 0x10000 + end + + local sensitivity = tonumber(device.preferences[SENSITIVITY_KEY]) + local factor = SENSITIVITY_FACTORS[sensitivity] or 1.0 + local intermediate_val = raw_val * factor + local sign = (intermediate_val > 0 and 1) or (intermediate_val < 0 and -1) or 0 + local val = math.floor(math.abs(intermediate_val) + 0.5) * sign + val = math.max(-100, math.min(100, val)) + + if val == 0 then + return + elseif end_point == 0x47 then -- normal + device:emit_event(capabilities.knob.rotateAmount({value = val}, {state_change = true})) + elseif end_point == 0x48 then -- press + device:emit_event(capabilities.knob.heldRotateAmount({value = val}, {state_change = true})) + end +end + +local function battery_level_handler(driver, device, value, zb_rx) + local voltage = value.value + local batteryLevel = "normal" + + if voltage <= 25 then + batteryLevel = "critical" + elseif voltage < 28 then + batteryLevel = "warning" + end + + device:emit_event(capabilities.batteryLevel.battery(batteryLevel)) +end + +local aqara_knob_switch_handler = { + NAME = "Aqara Wireless Knob Switch Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure + }, + zigbee_handlers = { + attr = { + [MULTISTATE_INPUT_CLUSTER_ID] = { + [PRESENT_ATTRIBUTE_ID] = button_monitor_handler + }, + [PRIVATE_CLUSTER_ID] = { + [ROTATION_MONITOR_ID] = rotation_monitor_per_handler + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryVoltage.ID] = battery_level_handler + }, + } + }, + can_handle = require("aqara-knob.can_handle"), +} + +return aqara_knob_switch_handler \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-button/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-button/src/aqara/can_handle.lua new file mode 100644 index 0000000000..3dc96661cd --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device) + local FINGERPRINTS = require "aqara.fingerprints" + if FINGERPRINTS[device:get_model()] and FINGERPRINTS[device:get_model()].mfr == device:get_manufacturer() then + return true, require("aqara") + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-button/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..83f7c77050 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + ["lumi.remote.b1acn02"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Mini Switch T1 + ["lumi.remote.acn003"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch E1 (Single Rocker) + ["lumi.remote.b186acn03"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch T1 (Single Rocker) + ["lumi.remote.b286acn03"] = { mfr = "LUMI", btn_cnt = 3, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch T1 (Double Rocker) + ["lumi.remote.b18ac1"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2450", quantity = 1 }, -- Aqara Wireless Remote Switch H1 (Single Rocker) + ["lumi.remote.b28ac1"] = { mfr = "LUMI", btn_cnt = 3, type = "CR2450", quantity = 1 } -- Aqara Wireless Remote Switch H1 (Double Rocker) +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/aqara/init.lua b/drivers/SmartThings/zigbee-button/src/aqara/init.lua index 5fd1076b4a..b9545e4721 100644 --- a/drivers/SmartThings/zigbee-button/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-button/src/aqara/init.lua @@ -1,102 +1,187 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" 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 button_utils = require "button_utils" +local MODE = "devicemode" +local MODE_CHANGE = "stse.allowOperationModeChange" +local SUPPORTED_BUTTON = { { "pushed" }, { "pushed", "held", "double" } } local PowerConfiguration = clusters.PowerConfiguration local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID_T1 = 0x0009 local PRIVATE_ATTRIBUTE_ID_E1 = 0x0125 +local PRIVATE_ATTRIBUTE_ID_ALIVE = 0x00F7 local MFG_CODE = 0x115F local MULTISTATE_INPUT_CLUSTER_ID = 0x0012 local PRESENT_ATTRIBUTE_ID = 0x0055 -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.remote.b1acn02" }, - { mfr = "LUMI", model = "lumi.remote.acn003" } -} +local COMP_LIST = { "button1", "button2", "all" } +local FINGERPRINTS = require "aqara.fingerprints" local configuration = { - { - cluster = MULTISTATE_INPUT_CLUSTER_ID, - attribute = PRESENT_ATTRIBUTE_ID, - minimum_interval = 3, - maximum_interval = 7200, - data_type = data_types.Uint16, - reportable_change = 1 - }, - { - cluster = PowerConfiguration.ID, - attribute = PowerConfiguration.attributes.BatteryVoltage.ID, - minimum_interval = 30, - maximum_interval = 3600, - data_type = PowerConfiguration.attributes.BatteryVoltage.base_type, - reportable_change = 1 - } + { + cluster = MULTISTATE_INPUT_CLUSTER_ID, + attribute = PRESENT_ATTRIBUTE_ID, + minimum_interval = 3, + maximum_interval = 7200, + data_type = data_types.Uint16, + reportable_change = 1 + }, + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryVoltage.ID, + minimum_interval = 30, + maximum_interval = 3600, + data_type = PowerConfiguration.attributes.BatteryVoltage.base_type, + reportable_change = 1 + } } local function present_value_attr_handler(driver, device, value, zb_rx) + if value.value < 0xFF then + local end_point = zb_rx.address_header.src_endpoint.value + local btn_evt_cnt = FINGERPRINTS[device:get_model()].btn_cnt or 1 + local evt = capabilities.button.button.held({ state_change = true }) if value.value == 1 then - device:emit_event(capabilities.button.button.pushed({state_change = true})) + evt = capabilities.button.button.pushed({ state_change = true }) elseif value.value == 2 then - device:emit_event(capabilities.button.button.double({state_change = true})) - elseif value.value == 0 then - device:emit_event(capabilities.button.button.held({state_change = true})) + evt = capabilities.button.button.double({ state_change = true }) + end + device:emit_event(evt) + if btn_evt_cnt > 1 then + device:emit_component_event(device.profile.components[COMP_LIST[end_point]], evt) end + end +end + +local function calc_battery_percentage(voltage) + local millivolt = voltage * 100 + local percentage = 0 + if millivolt >= 3000 then + percentage = 100 + elseif millivolt >= 2600 then + local fVoltage = (millivolt * millivolt) * 0.00045; + percentage = fVoltage - 2.277 * millivolt + 2880 + end + + return math.floor(percentage) +end + +local function battery_level_handler(driver, device, value, zb_rx) + local voltage = value.value + local batteryLevel = "normal" + + if voltage <= 25 then + batteryLevel = "critical" + elseif voltage < 28 then + batteryLevel = "warning" + end + + -- Note that all aqara buttons use batteryLevel and not battery capability. + if device:supports_capability_by_id(capabilities.battery.ID) then + device:emit_event(capabilities.battery.battery(calc_battery_percentage(voltage))) + elseif device:supports_capability_by_id(capabilities.batteryLevel.ID) then + device:emit_event(capabilities.batteryLevel.battery(batteryLevel)) + end end -local is_aqara_products = function(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true +local function mode_switching_handler(driver, device, value, zb_rx) + local btn_evt_cnt = FINGERPRINTS[device:get_model()].btn_cnt or 1 + local allow = device.preferences[MODE_CHANGE] or false + if allow then + local mode = device:get_field(MODE) or 1 + mode = 3 - mode + device:set_field(MODE, mode, { persist = true }) + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID_E1, + MFG_CODE, data_types.Uint8, mode)) + device:emit_event(capabilities.button.supportedButtonValues(SUPPORTED_BUTTON[mode], + { visibility = { displayed = false } })) + device:emit_event(capabilities.button.numberOfButtons({ value = 1 })) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, + capabilities.button.button.pushed({ state_change = false })) + if btn_evt_cnt > 1 then + for i = 1, btn_evt_cnt do + device:emit_component_event(device.profile.components[COMP_LIST[i]], + capabilities.button.supportedButtonValues(SUPPORTED_BUTTON[mode], + { visibility = { displayed = false } })) + device:emit_component_event(device.profile.components[COMP_LIST[i]], + capabilities.button.numberOfButtons({ value = 1 })) + device:emit_component_event(device.profile.components[COMP_LIST[i]], + capabilities.button.button.pushed({ state_change = false })) + button_utils.emit_event_if_latest_state_missing(device, COMP_LIST[i], capabilities.button, + capabilities.button.button.NAME, capabilities.button.button.pushed({ state_change = false })) end end - return false + end end local function device_init(driver, device) - battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) - if configuration ~= nil then - for _, attribute in ipairs(configuration) do - device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) - end + battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) end + end end local function added_handler(self, device) - device:emit_event(capabilities.button.supportedButtonValues({"pushed","held","double"}, {visibility = { displayed = false }})) - device:emit_event(capabilities.button.numberOfButtons({value = 1})) - device:emit_event(capabilities.button.button.pushed({state_change = false})) - device:emit_event(capabilities.battery.battery(100)) + local btn_evt_cnt = FINGERPRINTS[device:get_model()].btn_cnt or 1 + local mode = device:get_field(MODE) or 0 + local model = device:get_model() + local type = FINGERPRINTS[device:get_model()].type or "CR2032" + local quantity = FINGERPRINTS[device:get_model()].quantity or 1 + + if mode == 0 then + if model == "lumi.remote.b18ac1" or model == "lumi.remote.b28ac1" then + mode = 1 + else + mode = 2 + end + end + device:set_field(MODE, mode, { persist = true }) + device:emit_event(capabilities.button.supportedButtonValues(SUPPORTED_BUTTON[mode], + { visibility = { displayed = false } })) + device:emit_event(capabilities.button.numberOfButtons({ value = 1 })) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, + capabilities.button.button.pushed({ state_change = false })) + device:emit_event(capabilities.batteryLevel.battery.normal()) + device:emit_event(capabilities.batteryLevel.type(type)) + device:emit_event(capabilities.batteryLevel.quantity(quantity)) + + if btn_evt_cnt > 1 then + for i = 1, btn_evt_cnt do + device:emit_component_event(device.profile.components[COMP_LIST[i]], + capabilities.button.supportedButtonValues(SUPPORTED_BUTTON[mode], + { visibility = { displayed = false } })) + device:emit_component_event(device.profile.components[COMP_LIST[i]], + capabilities.button.numberOfButtons({ value = 1 })) + device:emit_component_event(device.profile.components[COMP_LIST[i]], + capabilities.button.button.pushed({ state_change = false })) + button_utils.emit_event_if_latest_state_missing(device, COMP_LIST[i], capabilities.button, + capabilities.button.button.NAME, capabilities.button.button.pushed({ state_change = false })) + end + end end local function do_configure(driver, device) + local ATTR_ID = PRIVATE_ATTRIBUTE_ID_T1 + local cmd_value = 1 + device:configure() - if device:get_model() == "lumi.remote.b1acn02" then - device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID_T1, MFG_CODE, data_types.Uint8, 1)) - elseif device:get_model() == "lumi.remote.acn003" then - device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID_E1, MFG_CODE, data_types.Uint8, 2)) + if device:get_model() == "lumi.remote.acn003" then + ATTR_ID = PRIVATE_ATTRIBUTE_ID_E1 + cmd_value = 2 end + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, ATTR_ID, MFG_CODE, data_types.Uint8, cmd_value)) -- when the wireless switch T1 accesses the network, the gateway sends -- private attribute 0009 to make the device no longer distinguish -- between the standard gateway and the aqara gateway. @@ -105,20 +190,26 @@ local function do_configure(driver, device) end local aqara_wireless_switch_handler = { - NAME = "Aqara Wireless Switch Handler", - lifecycle_handlers = { - init = device_init, - added = added_handler, - doConfigure = do_configure - }, - zigbee_handlers = { - attr = { - [MULTISTATE_INPUT_CLUSTER_ID] = { - [PRESENT_ATTRIBUTE_ID] = present_value_attr_handler - } + NAME = "Aqara Wireless Switch Handler", + lifecycle_handlers = { + init = device_init, + added = added_handler, + doConfigure = do_configure + }, + zigbee_handlers = { + attr = { + [MULTISTATE_INPUT_CLUSTER_ID] = { + [PRESENT_ATTRIBUTE_ID] = present_value_attr_handler + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryVoltage.ID] = battery_level_handler + }, + [PRIVATE_CLUSTER_ID] = { + [PRIVATE_ATTRIBUTE_ID_ALIVE] = mode_switching_handler } - }, - can_handle = is_aqara_products + } + }, + can_handle = require("aqara.can_handle"), } return aqara_wireless_switch_handler diff --git a/drivers/SmartThings/zigbee-button/src/button_utils.lua b/drivers/SmartThings/zigbee-button/src/button_utils.lua index b7499684cd..10ca4ab11a 100644 --- a/drivers/SmartThings/zigbee-button/src/button_utils.lua +++ b/drivers/SmartThings/zigbee-button/src/button_utils.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local log = require "log" @@ -79,4 +68,10 @@ button_utils.build_button_handler = function(button_name, pressed_type) end end +button_utils.emit_event_if_latest_state_missing = function(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + return button_utils diff --git a/drivers/SmartThings/zigbee-button/src/dimming-remote/can_handle.lua b/drivers/SmartThings/zigbee-button/src/dimming-remote/can_handle.lua new file mode 100644 index 0000000000..d3a469e214 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/dimming-remote/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zigbee_dimming_remote(opts, driver, device, ...) + local FINGERPRINTS = require("dimming-remote.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("dimming-remote") + end + end + return false +end + +return can_handle_zigbee_dimming_remote diff --git a/drivers/SmartThings/zigbee-button/src/dimming-remote/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/dimming-remote/fingerprints.lua new file mode 100644 index 0000000000..439694ebaf --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/dimming-remote/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIBEE_DIMMING_SWITCH_FINGERPRINTS = { + { mfr = "OSRAM", model = "LIGHTIFY Dimming Switch" }, + { mfr = "CentraLite", model = "3130" } +} + +return ZIBEE_DIMMING_SWITCH_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua b/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua index 45809d728c..54ca675ccd 100644 --- a/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua +++ b/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -22,19 +12,7 @@ local battery_defaults = require "st.zigbee.defaults.battery_defaults" local button_utils = require "button_utils" -local ZIBEE_DIMMING_SWITCH_FINGERPRINTS = { - { mfr = "OSRAM", model = "LIGHTIFY Dimming Switch" }, - { mfr = "CentraLite", model = "3130" } -} -local function can_handle_zigbee_dimming_remote(opts, driver, device, ...) - for _, fingerprint in ipairs(ZIBEE_DIMMING_SWITCH_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function button_pushed_handler(button_number) return function(self, device, value, zb_rx) @@ -54,7 +32,7 @@ local function added_handler(self, device) device:emit_component_event(component, capabilities.button.numberOfButtons({value = number_of_buttons}, {visibility = { displayed = false }})) end device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) - device:emit_event(capabilities.button.button.pushed({state_change = false})) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) end local function do_configure(self, device) @@ -84,7 +62,7 @@ local dimming_remote = { } } }, - can_handle = can_handle_zigbee_dimming_remote + can_handle = require("dimming-remote.can_handle"), } return dimming_remote diff --git a/drivers/SmartThings/zigbee-button/src/ewelink/can_handle.lua b/drivers/SmartThings/zigbee-button/src/ewelink/can_handle.lua new file mode 100644 index 0000000000..6a9426b75d --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/ewelink/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ewelink_button(opts, driver, device, ...) + local FINGERPRINTS = require("ewelink.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("ewelink") + end + end + return false +end + +return can_handle_ewelink_button diff --git a/drivers/SmartThings/zigbee-button/src/ewelink/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/ewelink/fingerprints.lua new file mode 100644 index 0000000000..3e21d092d3 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/ewelink/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local EWELINK_BUTTON_FINGERPRINTS = { + { mfr = "eWeLink", model = "WB01" }, + { mfr = "eWeLink", model = "SNZB-01P" } +} + +return EWELINK_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/ewelink/init.lua b/drivers/SmartThings/zigbee-button/src/ewelink/init.lua index 334dd79108..b503b68c64 100644 --- a/drivers/SmartThings/zigbee-button/src/ewelink/init.lua +++ b/drivers/SmartThings/zigbee-button/src/ewelink/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -19,19 +9,7 @@ local device_management = require "st.zigbee.device_management" local OnOff = clusters.OnOff local button = capabilities.button.button -local EWELINK_BUTTON_FINGERPRINTS = { - { mfr = "eWeLink", model = "WB01" }, - { mfr = "eWeLink", model = "SNZB-01P" } -} -local function can_handle_ewelink_button(opts, driver, device, ...) - for _, fingerprint in ipairs(EWELINK_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function do_configure(driver, device) device:configure() @@ -61,7 +39,7 @@ local ewelink_button = { } } }, - can_handle = can_handle_ewelink_button + can_handle = require("ewelink.can_handle"), } -return ewelink_button \ No newline at end of file +return ewelink_button diff --git a/drivers/SmartThings/zigbee-button/src/ezviz/can_handle.lua b/drivers/SmartThings/zigbee-button/src/ezviz/can_handle.lua new file mode 100644 index 0000000000..b046b8966c --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/ezviz/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_ezviz_button = function(opts, driver, device) + + local EZVIZ_PRIVATE_BUTTON_CLUSTER = 0xFE05 + local EZVIZ_PRIVATE_STANDARD_CLUSTER = 0xFE00 + local EZVIZ_MFR = "EZVIZ" + + local support_button_cluster = device:supports_server_cluster(EZVIZ_PRIVATE_BUTTON_CLUSTER) + local support_standard_cluster = device:supports_server_cluster(EZVIZ_PRIVATE_STANDARD_CLUSTER) + if device:get_manufacturer() == EZVIZ_MFR and support_button_cluster and support_standard_cluster then + return true, require("ezviz") + end + return false +end + +return is_ezviz_button diff --git a/drivers/SmartThings/zigbee-button/src/ezviz/init.lua b/drivers/SmartThings/zigbee-button/src/ezviz/init.lua new file mode 100644 index 0000000000..5bea38ffb4 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/ezviz/init.lua @@ -0,0 +1,39 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" + +local EZVIZ_PRIVATE_BUTTON_CLUSTER = 0xFE05 +local EZVIZ_PRIVATE_BUTTON_ATTRIBUTE = 0x0000 + + +local ezviz_private_cluster_button_handler = function(driver, device, zb_rx) + local event + local additional_fields = { + state_change = true + } + if zb_rx.value == 0x01 then + event = capabilities.button.button.pushed(additional_fields) + elseif zb_rx.value == 0x02 then + event = capabilities.button.button.double(additional_fields) + elseif zb_rx.value == 0x03 then + event = capabilities.button.button.held(additional_fields) + end + if event ~= nil then + device:emit_event(event) + end +end + +local ezviz_button_handler = { + NAME = "Ezviz Button", + zigbee_handlers = { + attr = { + [EZVIZ_PRIVATE_BUTTON_CLUSTER] = { + [EZVIZ_PRIVATE_BUTTON_ATTRIBUTE] = ezviz_private_cluster_button_handler + } + } + }, + can_handle = require("ezviz.can_handle"), +} +return ezviz_button_handler diff --git a/drivers/SmartThings/zigbee-button/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-button/src/frient/can_handle.lua new file mode 100644 index 0000000000..924c595fd3 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and (device:get_model() == "SBTZB-110" or device:get_model() == "MBTZB-110") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/frient/init.lua b/drivers/SmartThings/zigbee-button/src/frient/init.lua index eeed6e36a2..bf622cd694 100644 --- a/drivers/SmartThings/zigbee-button/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-button/src/frient/init.lua @@ -1,23 +1,23 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" local capabilities = require "st.capabilities" -local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local data_types = require "st.zigbee.data_types" +local button_utils = require "button_utils" local BasicInput = zcl_clusters.BasicInput local PowerConfiguration = zcl_clusters.PowerConfiguration +local OnOff = zcl_clusters.OnOff +local panicAlarm = capabilities.panicAlarm +local IASZone = zcl_clusters.IASZone + +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local BUTTON_LED_COLOR = 0x8002 +local BUTTON_PRESS_DELAY = 0x8001 +local PANIC_BUTTON = 0x8000 local battery_table = { [2.90] = 100, @@ -34,51 +34,194 @@ local battery_table = { [0.00] = 0 } -local function present_value_attr_handler(driver, device, value, zb_rx) - local event - local additional_fields = { - state_change = true +local CONFIGURATIONS = { + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.PresentValue.ID, + minimum_interval = 0, + maximum_interval = 21600, + data_type = BasicInput.attributes.PresentValue.base_type, + reportable_change = 1, + endpoint = 0x20 + }, + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryVoltage.ID, + minimum_interval = 30, + maximum_interval = 21600, + reportable_change = 1, } +} + +local PREFERENCE_TABLES = { + ledColor = { + clusterId = OnOff.ID, + attributeId = BUTTON_LED_COLOR, + dataType = data_types.Enum8, + mfg_code = DEVELCO_MANUFACTURER_CODE, + endpoint = 0x20, + frame_ctrl = 0x0C + }, + buttonDelay = { + clusterId = OnOff.ID, + attributeId = BUTTON_PRESS_DELAY, + dataType = data_types.Uint16, + mfg_code = DEVELCO_MANUFACTURER_CODE, + endpoint = 0x20, + frame_ctrl = 0x0C + }, + panicButton = { + clusterId = BasicInput.ID, + attributeId = PANIC_BUTTON, + dataType = data_types.Uint16, + mfg_code = DEVELCO_MANUFACTURER_CODE, + endpoint = 0x20, + frame_ctrl = 0x04 + }, + buttonAlarmDelay = { + clusterId = IASZone.ID, + attributeId = 0x8002, + dataType = data_types.Uint16, + mfg_code = DEVELCO_MANUFACTURER_CODE, + endpoint = 0x23, + frame_ctrl = 0x04 + }, + buttonCancelDelay = { + clusterId = IASZone.ID, + attributeId = 0x8003, + dataType= data_types.Uint16, + mfg_code = DEVELCO_MANUFACTURER_CODE, + endpoint = 0x23, + frame_ctrl = 0x04 + }, + autoCancel = { + clusterId = IASZone.ID, + attributeId = 0x8004, + dataType = data_types.Uint16, + mfg_code = DEVELCO_MANUFACTURER_CODE, + endpoint = 0x23, + frame_ctrl = 0x04 + }, + alarmBehavior = { + clusterId = IASZone.ID, + attributeId = 0x8005, + dataType = data_types.Enum8, + mfg_code = DEVELCO_MANUFACTURER_CODE, + endpoint = 0x23, + frame_ctrl = 0x04 + }, +} + +local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) + if device:supports_capability(panicAlarm) then + if zone_status:is_alarm2_set() then + device:emit_event(panicAlarm.panicAlarm.panic({state_change = true})) + else + device:emit_event(panicAlarm.panicAlarm.clear({state_change = true})) + end + end +end + +local function configure_ias_zone_settings(driver, device) + device:send(cluster_base.write_manufacturer_specific_attribute(device, IASZone.ID, 0x8002, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 2000):to_endpoint(0x23)) + device:send(cluster_base.write_manufacturer_specific_attribute(device, IASZone.ID, 0x8003, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 2000):to_endpoint(0x23)) + device:send(cluster_base.write_manufacturer_specific_attribute(device, IASZone.ID, 0x8004, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 10):to_endpoint(0x23)) + device:send(cluster_base.write_manufacturer_specific_attribute(device, IASZone.ID, 0x8005, DEVELCO_MANUFACTURER_CODE, data_types.Enum8, 0):to_endpoint(0x23)) +end + +local function present_value_attr_handler(driver, device, value, zb_rx) if value.value == true then - event = capabilities.button.button.pushed(additional_fields) - device:emit_event(event) + device:emit_event(capabilities.button.button.pushed({state_change = true})) end end -local function init_handler(self, device, event, args) +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function init_handler(self, device) + for _,attribute in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(attribute) + end battery_defaults.enable_battery_voltage_table(device, battery_table) end local function added_handler(self, device) device:emit_event(capabilities.button.supportedButtonValues({"pushed"}, {visibility = { displayed = false }})) device:emit_event(capabilities.button.numberOfButtons({value = 1})) - device:emit_event(capabilities.button.button.pushed({state_change = false})) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) end -local configure_handler = function(self, device) - device:send(device_management.build_bind_request(device, BasicInput.ID, self.environment_info.hub_zigbee_eui)) - device:send(BasicInput.attributes.PresentValue:configure_reporting(device, 0, 600, 1)) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryVoltage:configure_reporting(device, 30, 21600, 1)) +local function do_configure(driver, device, event, args) + device:configure() + device:send(cluster_base.write_manufacturer_specific_attribute(device, OnOff.ID, BUTTON_LED_COLOR, DEVELCO_MANUFACTURER_CODE, data_types.Enum8, 2):to_endpoint(0x20)) + device:send(cluster_base.write_manufacturer_specific_attribute(device, OnOff.ID, BUTTON_PRESS_DELAY, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 100):to_endpoint(0x20)) + device:send(cluster_base.write_manufacturer_specific_attribute(device, BasicInput.ID, PANIC_BUTTON, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 0xFFFF):to_endpoint(0x20)) +end + +local function info_changed(driver, device, event, args) + for name, info in pairs(PREFERENCE_TABLES) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + local input = device.preferences[name] + local payload = tonumber(input) + + if (name == "panicButton") then + if (input == "0x002C")then + device:try_update_metadata({profile = "button-profile-panic-frient"}) + device.thread:call_with_delay(5, function() + device:emit_event(panicAlarm.panicAlarm.clear({state_change = true})) + configure_ias_zone_settings(driver,device) + end) + else + device:try_update_metadata({profile = "button-profile-frient"}) + end + end + + if (payload ~= nil) then + local message = cluster_base.write_manufacturer_specific_attribute( + device, + info.clusterId, + info.attributeId, + info.mfg_code, + info.dataType, + payload + ) + message.address_header.dest_endpoint.value = info.endpoint + message.body.zcl_header.frame_ctrl.value = info.frame_ctrl + device:send(message) + end + end + end end local frient_button = { NAME = "Frient Button Handler", lifecycle_handlers = { added = added_handler, - doConfigure = configure_handler, - init = init_handler + doConfigure = do_configure, + init = init_handler, + infoChanged = info_changed }, zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, [BasicInput.ID] = { [BasicInput.attributes.PresentValue.ID] = present_value_attr_handler } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" and device:get_model() == "MBTZB-110" - end + can_handle = require("frient.can_handle"), } - return frient_button diff --git a/drivers/SmartThings/zigbee-button/src/init.lua b/drivers/SmartThings/zigbee-button/src/init.lua index a0e4dec66e..cb1565ea74 100644 --- a/drivers/SmartThings/zigbee-button/src/init.lua +++ b/drivers/SmartThings/zigbee-button/src/init.lua @@ -1,22 +1,19 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" local constants = require "st.zigbee.constants" local IASZone = (require "st.zigbee.zcl.clusters").IASZone +local TemperatureMeasurement = (require "st.zigbee.zcl.clusters").TemperatureMeasurement +local button_utils = require "button_utils" + +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} local generate_event_from_zone_status = function(driver, device, zone_status, zb_rx) local event @@ -65,22 +62,66 @@ local ias_zone_status_change_handler = function(driver, device, zb_rx) generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) end +--- Default handler for Temperature min and max measured value on the Temperature measurement cluster +--- +--- This starts initially by performing the same conversion in the temperature_measurement_attr_handler function. +--- It then sets the field of whichever measured value is defined by the @param and checks if the fields +--- correctly compare +--- +--- @param minOrMax string the string that determines which attribute to set +--- @param driver Driver The current driver running containing necessary context for execution +--- @param device ZigbeeDevice The device this message was received from containing identifying information +--- @param value Int16 the value of the measured temperature +--- @param zb_rx containing the full message this report came in + +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + local function added_handler(self, device) device:emit_event(capabilities.button.supportedButtonValues({"pushed","held","double"}, {visibility = { displayed = false }})) device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = { displayed = false }})) - device:emit_event(capabilities.button.button.pushed({state_change = false})) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) + if device:supports_server_cluster(TemperatureMeasurement.ID) then + device:send(TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) + device:send(TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) + end end local zigbee_button_driver_template = { supported_capabilities = { capabilities.button, capabilities.battery, - capabilities.temperatureMeasurement + capabilities.panicAlarm, + capabilities.temperatureMeasurement, }, zigbee_handlers = { attr = { [IASZone.ID] = { [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), } }, cluster = { @@ -89,21 +130,13 @@ local zigbee_button_driver_template = { } } }, - sub_drivers = { - require("aqara"), - require("pushButton"), - require("frient"), - require("zigbee-multi-button"), - require("dimming-remote"), - require("iris"), - require("samjin"), - require("ewelink"), - require("thirdreality") - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { added = added_handler, }, - ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE + ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_button_driver_template, zigbee_button_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-button/src/iris/can_handle.lua b/drivers/SmartThings/zigbee-button/src/iris/can_handle.lua new file mode 100644 index 0000000000..618bc6eb5a --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/iris/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_iris_button(opts, driver, device, ...) + local FINGERPRINTS = require("iris.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("iris") + end + end + return false +end + +return can_handle_iris_button diff --git a/drivers/SmartThings/zigbee-button/src/iris/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/iris/fingerprints.lua new file mode 100644 index 0000000000..4ae1761e06 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/iris/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IRIS_BUTTON_FINGERPRINTS = { + { mfr = "CentraLite", model = "3455-L" }, + { mfr = "CentraLite", model = "3460-L" } +} + +return IRIS_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/iris/init.lua b/drivers/SmartThings/zigbee-button/src/iris/init.lua index 1baa2f8ced..a6eac49870 100644 --- a/drivers/SmartThings/zigbee-button/src/iris/init.lua +++ b/drivers/SmartThings/zigbee-button/src/iris/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -21,19 +11,7 @@ local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local button_utils = require "button_utils" -local IRIS_BUTTON_FINGERPRINTS = { - { mfr = "CentraLite", model = "3455-L" }, - { mfr = "CentraLite", model = "3460-L" } -} -local function can_handle_iris_button(opts, driver, device, ...) - for _, fingerprint in ipairs(IRIS_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function button_pressed_handler(self, device, value, zb_rx) button_utils.init_button_press(device) @@ -62,7 +40,7 @@ end local function added_handler(self, device) device:emit_event(capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = { displayed = false }})) device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = { displayed = false }})) - device:emit_event(capabilities.button.button.pushed({state_change = false})) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) end @@ -94,7 +72,7 @@ local iris_button = { } } }, - can_handle = can_handle_iris_button + can_handle = require("iris.can_handle"), } return iris_button diff --git a/drivers/SmartThings/zigbee-button/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-button/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-button/src/pushButton/can_handle.lua b/drivers/SmartThings/zigbee-button/src/pushButton/can_handle.lua new file mode 100644 index 0000000000..f25592fb01 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/pushButton/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function pushButton_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "HEIMAN" and device:get_model() == "SOS-EM" then + return true, require("pushButton") + end + return false +end + +return pushButton_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/pushButton/init.lua b/drivers/SmartThings/zigbee-button/src/pushButton/init.lua index 8d09684fd7..170307a600 100644 --- a/drivers/SmartThings/zigbee-button/src/pushButton/init.lua +++ b/drivers/SmartThings/zigbee-button/src/pushButton/init.lua @@ -1,34 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- Copyright 2020 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except --- in compliance with the License. You may obtain a copy of the License at: --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software distributed under the License is distributed --- on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License --- for the specific language governing permissions and limitations under the License. local capabilities = require "st.capabilities" +local button_utils = require "button_utils" local function added_handler(self, device) device:emit_event(capabilities.button.supportedButtonValues({"pushed"}, {visibility = { displayed = false }})) device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = { displayed = false }})) - device:emit_event(capabilities.button.button.pushed({state_change = false})) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) end local push_button = { @@ -37,9 +18,7 @@ local push_button = { added = added_handler, }, sub_drivers = {}, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "HEIMAN" and device:get_model() == "SOS-EM" - end + can_handle = require("pushButton.can_handle"), } return push_button diff --git a/drivers/SmartThings/zigbee-button/src/samjin/can_handle.lua b/drivers/SmartThings/zigbee-button/src/samjin/can_handle.lua new file mode 100644 index 0000000000..0886bedb9c --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/samjin/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function samjin_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Samjin" and device:get_model() == "button" then + return true, require("samjin") + end + return false +end + +return samjin_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/samjin/init.lua b/drivers/SmartThings/zigbee-button/src/samjin/init.lua index 66ae410169..bd254091a6 100644 --- a/drivers/SmartThings/zigbee-button/src/samjin/init.lua +++ b/drivers/SmartThings/zigbee-button/src/samjin/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" @@ -21,7 +11,6 @@ battery_config.data_type = zcl_clusters.PowerConfiguration.attributes.BatteryVol local function init_handler(self, device) device:add_configured_attribute(battery_config) - device:add_monitored_attribute(battery_config) end local samjin_button = { @@ -29,9 +18,7 @@ local samjin_button = { lifecycle_handlers = { init = init_handler }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Samjin" and device:get_model() == "button" - end + can_handle = require("samjin.can_handle"), } return samjin_button diff --git a/drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua b/drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua new file mode 100644 index 0000000000..3d832ac180 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua @@ -0,0 +1,144 @@ +-- Copyright 2021 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local data_types = require "st.zigbee.data_types" +local utils = require "st.zigbee.utils" +local zdo_commands = require "st.zigbee.zdo.commands" + +local zdo_messages = {} + +--- A class representing a zigbee ZDO header +--- @class st.zigbee.zdo.Header +--- +--- @field public NAME string "ZDOHeader" used for printing +--- @field public seqno st.zigbee.data_types.Uint8 the sequence number for the Zigbee message +local ZdoHeader = { + NAME = "ZDOHeader", +} +ZdoHeader.__index = ZdoHeader +zdo_messages.ZdoHeader = ZdoHeader + +--- A function to take a stream of bytes and parse a Zigbee Zdo header +--- @param buf Reader The buf positioned at the beginning of the bytes representing the ZdoHeader +--- @return st.zigbee.zdo.Header a new instance of the ZdoHeader parsed from the bytes +function ZdoHeader.deserialize(buf) + local s = {} + s.seqno = data_types.Uint8.deserialize(buf, "seqno") + setmetatable(s, ZdoHeader) + return s +end + +--- A helper function used by common code to get all the component pieces of this message frame +---@return table An array formatted table with each component field in the order their bytes should be serialized +function ZdoHeader:get_fields() + return { self.seqno } +end + +--- A function to return the total length in bytes this frame uses when serialized +--- @return number the length in bytes of this frame +function ZdoHeader:get_length() end +ZdoHeader.get_length = utils.length_from_fields + +--- A function for serializing this ZdoHeader +--- @return string This message frame serialized +ZdoHeader._serialize = utils.serialize_from_fields + +--- A function for printing in a human readable format +--- @return string A human readable representation of this message frame +function ZdoHeader:pretty_print() end +ZdoHeader.pretty_print = utils.print_from_fields +ZdoHeader.__tostring = ZdoHeader.pretty_print + +--- This is a function to build a Zdo header from its individual components +--- @param orig table UNUSED This is the ZclMessageRx object when called with the syntax ZclMessageRx(...) +--- @param seqno st.zigbee.data_types.Uint8 the sequence number for the ZDO header +--- @return st.zigbee.zdo.Header The constructed ZdoHeader +function ZdoHeader.from_values(orig, seqno) + local out = {} + out.seqno = data_types.validate_or_build_type(seqno, data_types.Uint8, "seqno") + out.seqno.field_name = "seqno" + setmetatable(out, ZdoHeader) + return out +end + +setmetatable(zdo_messages.ZdoHeader, { __call = zdo_messages.ZdoHeader.from_values }) + +--- A class representing a received zigbee ZDO message (device -> hub). +--- @class st.zigbee.zdo.MessageBody +--- +--- @field public NAME string "ZdoMessageRx" used for printing +--- @field public type st.zigbee.data_types.Uint8 message type (internal use only) +--- @field public address_header st.zigbee.AddressHeader The addressing information for this message +--- @field public lqi st.zigbee.data_types.Uint8 The lqi of this message +--- @field public rssi st.zigbee.data_types.Int8 The rssi of this message +--- @field public zdo_header st.zigbee.zdo.Header The ZdoHeader for this message +--- @field public body st.zigbee.zdo.MessageBody The message body. This is a message frame that must implement serialize, get_length, pretty_print, etc. +local ZdoMessageBody = { + NAME = "ZDOMessageBody", +} +ZdoMessageBody.__index = ZdoMessageBody +zdo_messages.ZdoMessageBody = ZdoMessageBody + +--- A function to take a stream of bytes and parse a received Zigbee Zdo message (device -> hub) +--- @param parent table A parent table for the class that includes the type, address_header, lqi, rssi, and body_length +--- @param buf Reader The buf positioned at the beginning of the bytes representing the ZdoHeader +--- @return st.zigbee.zdo.MessageBody a new instance of the ZdoMessageRx parsed from the bytes +function ZdoMessageBody.deserialize(parent, buf) + local s = {} + s.zdo_header = zdo_messages.ZdoHeader.deserialize(buf) + -- there is a bug in hub FW that causes the zdo message payload to have an invalid first byte + -- and a missing last byte. Default to adding in a 1, which is a good default for the mgmt_bind_response + -- binding table entry endpoint_id + local version = require "version" + if version.rpc == 8 then + buf.buf = buf.buf .. "\01" + buf:seek(1) + end + s.zdo_body = zdo_commands.parse_zdo_command(parent.address_header.cluster.value, buf) + + setmetatable(s, ZdoMessageBody) + return s +end + +--- A helper function used by common code to get all the component pieces of this message frame +---@return table An array formatted table with each component field in the order their bytes should be serialized +function ZdoMessageBody:get_fields() + return { + self.zdo_header, + self.zdo_body + } +end + +--- A function to return the total length in bytes this frame uses when serialized +--- @return number the length in bytes of this frame +function ZdoMessageBody:get_length() end +ZdoMessageBody.get_length = utils.length_from_fields + +--- A function for serializing this Zdo Message +--- @return string the bytes representing this message +ZdoMessageBody._serialize = utils.serialize_from_fields + +--- A function for printing in a human readable format +--- @return string A human readable representation of this message frame +function ZdoMessageBody:pretty_print() end +ZdoMessageBody.pretty_print = utils.print_from_fields +ZdoMessageBody.__tostring = ZdoMessageBody.pretty_print + +--- This is a function to build an zdo rx message from its individual components +--- @param orig table UNUSED This is the ZdoMessageRx object when called with the syntax ZdoMessageRx(...) +--- @param data_table table a table containing the fields of this ZdoMessageRx. address_header, zdo_header, and body are required. type, lqi, rssi are optional with defaults +--- @return st.zigbee.zdo.MessageBody The constructed ZdoMessageRx +function ZdoMessageBody.from_values(orig, data_table) + if data_table.zdo_header == nil then + -- Just set seqno to 0 + data_table.zdo_header = zdo_messages.ZdoHeader(0) + end + if data_table.zdo_body == nil then + error(string.format("%s requires valid body", orig.NAME), 2) + end + setmetatable(data_table, ZdoMessageBody) + return data_table +end + +setmetatable(zdo_messages.ZdoMessageBody, { __call = zdo_messages.ZdoMessageBody.from_values }) + +return zdo_messages diff --git a/drivers/SmartThings/zigbee-button/src/sub_drivers.lua b/drivers/SmartThings/zigbee-button/src/sub_drivers.lua new file mode 100644 index 0000000000..8aa36d9ba0 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/sub_drivers.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("aqara-knob"), + lazy_load_if_possible("pushButton"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("zigbee-multi-button"), + lazy_load_if_possible("dimming-remote"), + lazy_load_if_possible("iris"), + lazy_load_if_possible("samjin"), + lazy_load_if_possible("ewelink"), + lazy_load_if_possible("thirdreality"), + lazy_load_if_possible("ezviz"), + lazy_load_if_possible("MultiIR"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua index 9b876e6f54..342a62a5c7 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua @@ -1,16 +1,5 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" @@ -38,9 +27,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -61,7 +48,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("button3", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -82,7 +72,10 @@ test.register_coroutine_test( mock_device:generate_test_message("button3", button_attr.held({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -94,7 +87,10 @@ test.register_coroutine_test( zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua index cf6c405156..808eb0a764 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -42,9 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -104,7 +91,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -139,7 +129,10 @@ test.register_coroutine_test( Level.ID, 3) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -184,7 +177,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua index 88e803fd40..49883cd275 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua @@ -1,16 +1,5 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -28,10 +17,28 @@ local MFG_CODE = 0x115F local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID_T1 = 0x0009 local PRIVATE_ATTRIBUTE_ID_E1 = 0x0125 +local PRIVATE_ATTRIBUTE_ID_ALIVE = 0x00F7 +local MODE_CHANGE = "stse.allowOperationModeChange" + +local COMP_LIST = { "button1", "button2", "all" } + +local mock_device_h1_single = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-single-button-mode.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.remote.b18ac1", + server_clusters = { 0x0001, 0x0012 } + } + } + } +) local mock_device_e1 = test.mock_device.build_test_zigbee_device( { - profile = t_utils.get_profile_definition("one-button-battery.yml"), + profile = t_utils.get_profile_definition("one-button-batteryLevel.yml"), zigbee_endpoints = { [1] = { id = 1, @@ -43,14 +50,14 @@ local mock_device_e1 = test.mock_device.build_test_zigbee_device( } ) -local mock_device_t1 = test.mock_device.build_test_zigbee_device( +local mock_device_h1_double_rocker = test.mock_device.build_test_zigbee_device( { - profile = t_utils.get_profile_definition("one-button-battery.yml"), + profile = t_utils.get_profile_definition("aqara-double-buttons-mode.yml"), zigbee_endpoints = { [1] = { id = 1, manufacturer = "LUMI", - model = "lumi.remote.b1acn02", + model = "lumi.remote.b286acn03", server_clusters = { 0x0001, 0x0012 } } } @@ -59,33 +66,41 @@ local mock_device_t1 = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + test.mock_device.add_test_device(mock_device_h1_single) test.mock_device.add_test_device(mock_device_e1) - test.mock_device.add_test_device(mock_device_t1) - zigbee_test_utils.init_noop_health_check_timer() + test.mock_device.add_test_device(mock_device_h1_double_rocker) end test.set_test_init_function(test_init) test.register_coroutine_test( - "Handle added lifecycle -- e1", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device_e1.id, "added" }) - test.socket.capability:__expect_send(mock_device_e1:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed","held","double"}, {visibility = { displayed = false }}))) - test.socket.capability:__expect_send(mock_device_e1:generate_test_message("main", capabilities.button.numberOfButtons({value = 1}))) - test.socket.capability:__expect_send(mock_device_e1:generate_test_message("main", capabilities.button.button.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device_e1:generate_test_message("main", capabilities.battery.battery(100))) - end -) - -test.register_coroutine_test( - "Handle added lifecycle -- t1", + "Handle added lifecycle - T1 double rocker", function() - test.socket.device_lifecycle:__queue_receive({ mock_device_t1.id, "added" }) - test.socket.capability:__expect_send(mock_device_t1:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed","held","double"}, {visibility = { displayed = false }}))) - test.socket.capability:__expect_send(mock_device_t1:generate_test_message("main", capabilities.button.numberOfButtons({value = 1}))) - test.socket.capability:__expect_send(mock_device_t1:generate_test_message("main", capabilities.button.button.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device_t1:generate_test_message("main", capabilities.battery.battery(100))) - end + test.socket.device_lifecycle:__queue_receive({ mock_device_h1_double_rocker.id, "added" }) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.batteryLevel.battery.normal())) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.batteryLevel.type("CR2032"))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.batteryLevel.quantity(1))) + for i = 1, 3 do + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], + capabilities.button.button.pushed({ state_change = false }))) + end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -106,41 +121,53 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device_e1.id, - zigbee_test_utils.build_attr_config(mock_device_e1, MULTISTATE_INPUT_CLUSTER_ID, PRESENT_ATTRIBUTE_ID, 0x0003, 0x1C20, data_types.Uint16, 0x0001) + zigbee_test_utils.build_attr_config(mock_device_e1, MULTISTATE_INPUT_CLUSTER_ID, PRESENT_ATTRIBUTE_ID, 0x0003, + 0x1C20, data_types.Uint16, 0x0001) }) test.socket.zigbee:__expect_send({ mock_device_e1.id, - cluster_base.write_manufacturer_specific_attribute(mock_device_e1, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID_E1, MFG_CODE, - data_types.Uint8, 2) }) - mock_device_e1:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + cluster_base.write_manufacturer_specific_attribute(mock_device_e1, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID_E1, + MFG_CODE, + data_types.Uint8, 2) }) + mock_device_e1:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Handle doConfigure lifecycle -- t1", function() - test.socket.device_lifecycle:__queue_receive({ mock_device_t1.id, "doConfigure" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_h1_double_rocker.id, "doConfigure" }) test.socket.zigbee:__expect_send({ - mock_device_t1.id, - zigbee_test_utils.build_bind_request(mock_device_t1, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + mock_device_h1_double_rocker.id, + zigbee_test_utils.build_bind_request(mock_device_h1_double_rocker, zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) }) test.socket.zigbee:__expect_send({ - mock_device_t1.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device_t1, 30, 3600, 1) + mock_device_h1_double_rocker.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device_h1_double_rocker, 30, 3600, 1) }) test.socket.zigbee:__expect_send({ - mock_device_t1.id, - zigbee_test_utils.build_bind_request(mock_device_t1, zigbee_test_utils.mock_hub_eui, MULTISTATE_INPUT_CLUSTER_ID) + mock_device_h1_double_rocker.id, + zigbee_test_utils.build_bind_request(mock_device_h1_double_rocker, zigbee_test_utils.mock_hub_eui, + MULTISTATE_INPUT_CLUSTER_ID) }) test.socket.zigbee:__expect_send({ - mock_device_t1.id, - zigbee_test_utils.build_attr_config(mock_device_t1, MULTISTATE_INPUT_CLUSTER_ID, PRESENT_ATTRIBUTE_ID, 0x0003, 0x1C20, data_types.Uint16, 0x0001) + mock_device_h1_double_rocker.id, + zigbee_test_utils.build_attr_config(mock_device_h1_double_rocker, MULTISTATE_INPUT_CLUSTER_ID, PRESENT_ATTRIBUTE_ID, + 0x0003, 0x1C20, data_types.Uint16, 0x0001) }) - test.socket.zigbee:__expect_send({ mock_device_t1.id, - cluster_base.write_manufacturer_specific_attribute(mock_device_t1, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID_T1, MFG_CODE, - data_types.Uint8, 1) }) - mock_device_t1:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + test.socket.zigbee:__expect_send({ mock_device_h1_double_rocker.id, + cluster_base.write_manufacturer_specific_attribute(mock_device_h1_double_rocker, PRIVATE_CLUSTER_ID, + PRIVATE_ATTRIBUTE_ID_T1, MFG_CODE, + data_types.Uint8, 1) }) + mock_device_h1_double_rocker:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -150,12 +177,18 @@ test.register_coroutine_test( { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0001 } } test.socket.zigbee:__queue_receive({ - mock_device_e1.id, - zigbee_test_utils.build_attribute_report(mock_device_e1, MULTISTATE_INPUT_CLUSTER_ID, attr_report_data, MFG_CODE) + mock_device_h1_double_rocker.id, + zigbee_test_utils.build_attribute_report(mock_device_h1_double_rocker, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) }) - test.socket.capability:__expect_send(mock_device_e1:generate_test_message("main", - capabilities.button.button.pushed({state_change = true}))) - end + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("button1", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -165,12 +198,18 @@ test.register_coroutine_test( { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0002 } } test.socket.zigbee:__queue_receive({ - mock_device_e1.id, - zigbee_test_utils.build_attribute_report(mock_device_e1, MULTISTATE_INPUT_CLUSTER_ID, attr_report_data, MFG_CODE) + mock_device_h1_double_rocker.id, + zigbee_test_utils.build_attribute_report(mock_device_h1_double_rocker, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) }) - test.socket.capability:__expect_send(mock_device_e1:generate_test_message("main", - capabilities.button.button.double({state_change = true}))) - end + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.button.double({ state_change = true }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("button1", + capabilities.button.button.double({ state_change = true }))) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -180,16 +219,22 @@ test.register_coroutine_test( { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0000 } } test.socket.zigbee:__queue_receive({ - mock_device_e1.id, - zigbee_test_utils.build_attribute_report(mock_device_e1, MULTISTATE_INPUT_CLUSTER_ID, attr_report_data, MFG_CODE) + mock_device_h1_double_rocker.id, + zigbee_test_utils.build_attribute_report(mock_device_h1_double_rocker, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) }) - test.socket.capability:__expect_send(mock_device_e1:generate_test_message("main", - capabilities.button.button.held({state_change = true}))) - end + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.button.held({ state_change = true }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("button1", + capabilities.button.button.held({ state_change = true }))) + end, + { + min_api_version = 17 + } ) test.register_message_test( - "Battery voltage report should be handled", + "Battery Level - Normal", { { channel = "zigbee", @@ -199,8 +244,113 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device_e1:generate_test_message("main", capabilities.battery.battery(100)) + message = mock_device_e1:generate_test_message("main", capabilities.batteryLevel.battery("normal")) } + }, + { + min_api_version = 17 + } +) +test.register_message_test( + "Battery Level - Warning", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_e1.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device_e1, 27) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_e1:generate_test_message("main", capabilities.batteryLevel.battery("warning")) + } + }, + { + min_api_version = 17 + } +) +test.register_message_test( + "Battery Level - Critical", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_e1.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device_e1, 20) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_e1:generate_test_message("main", capabilities.batteryLevel.battery("critical")) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Wireless Remote Switch H1 Mode Change", + function() + local mode = 2 + local updates = { + preferences = { + [MODE_CHANGE] = true + } + } + test.socket.device_lifecycle:__queue_receive(mock_device_h1_double_rocker:generate_info_changed(updates)) + mock_device_h1_double_rocker:set_field("devicemode", 1, { persist = true }) + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID_ALIVE, data_types.OctetString.ID, "\x01\x21\xB8\x0B\x03\x28\x19\x04\x21\xA8\x13\x05\x21\x45\x08\x06\x24\x07\x00\x00\x00\x00\x08\x21\x15\x01\x0A\x21\xF5\x65\x0C\x20\x01\x64\x20\x01\x66\x20\x03\x67\x20\x01\x68\x21\xA8\x00" } + } + test.wait_for_events() + test.socket.zigbee:__queue_receive({ + mock_device_h1_double_rocker.id, + zigbee_test_utils.build_attribute_report(mock_device_h1_double_rocker, PRIVATE_CLUSTER_ID, attr_report_data, + MFG_CODE) + }) + test.socket.zigbee:__expect_send({ mock_device_h1_double_rocker.id, cluster_base + .write_manufacturer_specific_attribute(mock_device_h1_double_rocker, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID_E1, + MFG_CODE, data_types.Uint8, mode) }) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) + + for i = 1, 3 do + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], + capabilities.button.button.pushed({ state_change = false }))) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle added lifecycle - H1 single rocker (sets mode=1)", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_h1_single.id, "added" }) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.batteryLevel.battery.normal())) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.batteryLevel.type("CR2450"))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.batteryLevel.quantity(1))) + end, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-button/src/test/test_aqara_knob_switch.lua b/drivers/SmartThings/zigbee-button/src/test/test_aqara_knob_switch.lua new file mode 100644 index 0000000000..69ae937aff --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_aqara_knob_switch.lua @@ -0,0 +1,287 @@ +-- 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 zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + + +local MULTISTATE_INPUT_CLUSTER_ID = 0x0012 +local PRESENT_ATTRIBUTE_ID = 0x0055 +local PowerConfiguration = clusters.PowerConfiguration + +local MFG_CODE = 0x115F +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-knob-switch.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.remote.rkba01", + server_clusters = { 0x0001 } + } + } + } +) +mock_device:set_field("preferences", {["stse.knobSensitivity"] = 2}, {persist = true}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery.normal())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.type("CR2032"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.quantity(2))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount(0))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.heldRotateAmount(0))) + end +) + +test.register_coroutine_test( + "Handle doConfigure lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 3600, 1) + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, + MFG_CODE, + data_types.Uint8, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.supportedAttributes({"rotateAmount", "heldRotateAmount"}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - normal", + function() + local attr_report_data = { + { 0x0232, data_types.Uint16.ID, 0x0001 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 1}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - max clamp at 100", + function() + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 200 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 100}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - min clamp at -100", + function() + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 65386 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = -100}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - sensitivity 0.5x", + function() + mock_device:set_field("preferences", {["stse.knobSensitivity"] = 1}) + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 3 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 3}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - sensitivity 2.0x", + function() + mock_device:set_field("preferences", {["stse.knobSensitivity"] = 3}) + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 5 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 5}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - held rotate amount", + function() + mock_device:set_field("preferences", {["stse.knobSensitivity"] = 2}) + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 15 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x48) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.heldRotateAmount({value = 15}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - press", + function() + local attr_report_data = { + { 0x0232, data_types.Uint16.ID, 0x0001 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x48) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.heldRotateAmount({value = 1}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "Reported button should be handled: pushed true", + function() + local attr_report_data = { + { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0001 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end +) + +test.register_coroutine_test( + "Reported button should be handled: double true", + function() + local attr_report_data = { + { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0002 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.double({ state_change = true }))) + end +) + +test.register_coroutine_test( + "Reported button should be handled: held true", + function() + local attr_report_data = { + { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.held({ state_change = true }))) + end +) + +test.register_message_test( + "Battery Level - Normal", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery("normal")) + } + } +) +test.register_message_test( + "Battery Level - Warning", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 27) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery("warning")) + } + } +) +test.register_message_test( + "Battery Level - Critical", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 20) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery("critical")) + } + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua index 9c4b87a1e2..be95f76cce 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -58,9 +47,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -106,7 +93,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) @@ -160,7 +150,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -196,7 +189,10 @@ test.register_coroutine_test( OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -207,7 +203,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -252,7 +251,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua b/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua index 609dbb15f9..db9bad6ea9 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -41,9 +30,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -66,7 +53,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -94,7 +84,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -122,7 +115,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -161,7 +157,10 @@ test.register_coroutine_test( ) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -172,12 +171,16 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "added lifecycle event", function() + -- The initial button pushed event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( @@ -223,12 +226,57 @@ test.register_coroutine_test( attribute_id = "button", state = { value = "pushed" } } }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 2 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button1", + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button1", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button2", + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button2", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua index e9fe8c8724..773021ac20 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,6 +9,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local button = capabilities.button @@ -31,7 +21,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "eWeLink", model = "WB01", - server_clusters = { 0x0001 } + server_clusters = { 0x0001, 0x0402 } } } } @@ -64,7 +54,18 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button.button.pushed({ state_change = false })) ) - end + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -76,7 +77,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -89,7 +93,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button.button.double({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -102,7 +109,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button.button.held({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -139,7 +149,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button.button.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua new file mode 100644 index 0000000000..07652b0e0e --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua @@ -0,0 +1,206 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zb_const = require "st.zigbee.constants" +local messages = require "st.zigbee.messages" +local data_types = require "st.zigbee.data_types" +local zcl_messages = require "st.zigbee.zcl" +local report_attr = require "st.zigbee.zcl.global_commands.report_attribute" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus + +local EZVIZ_PRIVATE_BUTTON_CLUSTER = 0xFE05 +local EZVIZ_PRIVATE_ATTR = 0x0000 + +local ZIGBEE_ONE_BUTTON_BATTERY = "one-button-battery" + +local mock_device_ezviz_button = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition(ZIGBEE_ONE_BUTTON_BATTERY .. ".yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "EZVIZ", + server_clusters = { 0x0500, 0x0001, 0xFE05, 0xFE00 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device_ezviz_button)end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Battery percentage report should be handled (button)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_ezviz_button.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_ezviz_button, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_ezviz_button:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 17 + } +) + +local function build_test_attr_report(device, value) + local report_body = report_attr.ReportAttribute({ + report_attr.ReportAttributeAttributeRecord(EZVIZ_PRIVATE_ATTR, data_types.Uint8.ID, value) + }) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(report_body.ID) + }) + local addrh = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + EZVIZ_PRIVATE_BUTTON_CLUSTER + ) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = report_body + }) + return messages.ZigbeeMessageRx({ + address_header = addrh, + body = message_body + }) +end + +test.register_message_test( + "EZVIZ private attribute report should result with sending pushed event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_ezviz_button.id, build_test_attr_report(mock_device_ezviz_button, 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EZVIZ private attribute report should result with sending double event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_ezviz_button.id, build_test_attr_report(mock_device_ezviz_button, 0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.double({ state_change = true })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "EZVIZ private attribute report should result with sending held event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_ezviz_button.id, build_test_attr_report(mock_device_ezviz_button, 0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.held({ state_change = true })) + } + }, + { + min_api_version = 17 + } +) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device_ezviz_button.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_ezviz_button) }) +-- test.wait_for_events() +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device_ezviz_button) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_ezviz_button.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send( + { + mock_device_ezviz_button.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_ezviz_button) + } + ) + test.socket.zigbee:__expect_send({ mock_device_ezviz_button.id, ZoneStatusAttribute:read(mock_device_ezviz_button) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_ezviz_button.id, "added" }) + test.socket.capability:__expect_send( + mock_device_ezviz_button:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device_ezviz_button:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = false })) + ) + + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua index 9a8ec632ad..40d74e503c 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua @@ -1,36 +1,65 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" -local PowerConfiguration = clusters.PowerConfiguration local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff + +local panicAlarm = capabilities.panicAlarm.panicAlarm local button_attr = capabilities.button.button -local t_utils = require "integration_test.utils" + + +local DEVELCO_MANUFACTURER_CODE = 0x1015 + +local data_types = require "st.zigbee.data_types" local mock_device = test.mock_device.build_test_zigbee_device( { - profile = t_utils.get_profile_definition("button-profile.yml"), + profile = t_utils.get_profile_definition("button-profile-frient.yml"), zigbee_endpoints = { [1] = { id = 1, manufacturer = "frient A/S", - model = "MBTZB-110", - server_clusters = {0x0001,0x0019} + model = "SBTZB-110", + server_clusters = {OnOff.ID}, + }, + [0x20] = { + id = 0x20, + server_clusters = {BasicInput.ID, PowerConfiguration.ID}, + client_clusters = {OnOff.ID}, + + }, + } + } +) +local mock_device_panic = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("button-profile-panic-frient.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "frient A/S", + model = "SBTZB-110", + server_clusters = {OnOff.ID}, + }, + [0x20] = { + id = 0x20, + server_clusters = {BasicInput.ID, PowerConfiguration.ID}, + client_clusters = {OnOff.ID}, + + }, + [0x23] = { + id = 0x23, + server_clusters = {IASZone.ID} } } } @@ -38,6 +67,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_panic) zigbee_test_utils.init_noop_health_check_timer() end @@ -56,71 +86,246 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) +test.register_message_test("Refresh should read all necessary attributes", { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = {mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device)} + }, + { + channel = "zigbee", + direction = "send", + message = {mock_device.id, BasicInput.attributes.PresentValue:read(mock_device)} + }, +}, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test("panicAlarm should be triggered and cleared", function() + + local panic_report = IASZone.attributes.ZoneStatus.build_test_attr_report( + IASZone.attributes.ZoneStatus, + mock_device_panic, + 0x0002 + ) + + test.socket.zigbee:__queue_receive({ + mock_device_panic.id, + panic_report + }) + + test.socket.capability:__expect_send(mock_device_panic:generate_test_message("main", panicAlarm.panic({value = "panic", state_change = true}))) + + test.wait_for_events() + + local clear_report = IASZone.attributes.ZoneStatus.build_test_attr_report( + IASZone.attributes.ZoneStatus, + mock_device_panic, + 0x0001 + ) + test.socket.zigbee:__queue_receive({ + mock_device_panic.id, + clear_report + }) + + test.socket.capability:__expect_send(mock_device_panic:generate_test_message("main", panicAlarm.clear({value = "clear", state_change = true}))) + test.wait_for_events() + +end, +{ + min_api_version = 17 +} +) + test.register_coroutine_test( "Battery Voltage test cases", function() - local battery_test_map = { - [33] = 100, - [32] = 100, - [27] = 50, - [26] = 30, - [23] = 10, - [15] = 0, - [10] = 0 - } + local battery_table = { + [33] = 100, + [32] = 100, + [27] = 50, + [26] = 30, + [23] = 10, + [15] = 0, + [10] = 0 + } + -- The initial button pushed event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = { displayed = false }}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({value = 1}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({ state_change = false}))) + test.wait_for_events() - for voltage, batt_perc in pairs(battery_test_map) do - test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, voltage) }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) + for voltage, batt_perc in pairs(battery_table) do + test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, voltage) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc))) + end test.wait_for_events() - end - end + -- Avoid sending the button pushed contactSensor event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = { displayed = false }}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({value = 1}))) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "Configure should configure all necessary attributes", + "added , init, and doConfigure should configure all necessary attributes", function() - test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send( - { - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, - 30, - 21600, - 1) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - BasicInput.attributes.PresentValue:configure_reporting(mock_device, 0, 600, 1) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - BasicInput.ID) - } - ) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = { displayed = false }}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({value = 1}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({ state_change = false}))) + + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + + test.socket.zigbee:__expect_send({mock_device.id, zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + 0x20 + ):to_endpoint(0x20)}) + + test.socket.zigbee:__expect_send({mock_device.id, zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + BasicInput.ID, + 0x20 + ):to_endpoint(0x20)}) + + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30,21600, 1 ):to_endpoint(0x20)}) + test.socket.zigbee:__expect_send({mock_device.id, BasicInput.attributes.PresentValue:configure_reporting(mock_device, 0,21600, 1 ):to_endpoint(0x20)}) + + test.socket.zigbee:__expect_send({mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, OnOff.ID, 0x8002, DEVELCO_MANUFACTURER_CODE, data_types.Enum8, 2):to_endpoint(0x20)}) + test.socket.zigbee:__expect_send({mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, OnOff.ID, 0x8001, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 100):to_endpoint(0x20)}) + test.socket.zigbee:__expect_send({mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, BasicInput.ID, 0x8000, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 65535):to_endpoint(0x20)}) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test("info_changed for OnOff cluster attributes should run properly", +function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed( + { + preferences = { + ledColor = 1, + buttonDelay = 300, + } + } + )) + + local ledColor_msg = cluster_base.write_manufacturer_specific_attribute(mock_device,OnOff.ID, 0x8002, DEVELCO_MANUFACTURER_CODE, data_types.Enum8, 1) + ledColor_msg.body.zcl_header.frame_ctrl.value = 0x0C + ledColor_msg.address_header.dest_endpoint.value = 0x20 + test.socket.zigbee:__expect_send({mock_device.id, ledColor_msg}) + + local buttonDelay_msg = cluster_base.write_manufacturer_specific_attribute(mock_device,OnOff.ID, 0x8001, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 0x012C) + buttonDelay_msg.body.zcl_header.frame_ctrl.value = 0x0C + buttonDelay_msg.address_header.dest_endpoint.value = 0x20 + test.socket.zigbee:__expect_send({mock_device.id, buttonDelay_msg}) +end, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test(" Configuration and Switching to button-profile-panic-frient deviceProfile should be triggered", function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed( + { + preferences = { + panicButton = "0x002C" + } + } + )) + mock_device:expect_metadata_update({ profile = "button-profile-panic-frient" }) + test.socket.zigbee:__expect_send({mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, BasicInput.ID, 0x8000, DEVELCO_MANUFACTURER_CODE, data_types.Uint16,0x002C)}) + + local attributes = { + {attr = 0x8002, payload = 0x07D0, data_type = data_types.Uint16}, + {attr = 0x8003, payload = 0x07D0, data_type = data_types.Uint16}, + {attr = 0x8004, payload = 0x0A, data_type = data_types.Uint16}, + {attr = 0x8005, payload = 0, data_type = data_types.Enum8} + } + -- waiting for IASzone configuration execution + test.mock_time.advance_time(5) + for _, attr in ipairs(attributes) do + local msg = cluster_base.write_manufacturer_specific_attribute(mock_device,IASZone.ID, attr.attr, DEVELCO_MANUFACTURER_CODE, attr.data_type, attr.payload) + msg.address_header.dest_endpoint.value = 0x23 + test.socket.zigbee:__expect_send({mock_device.id, msg}) end + -- Unable to check if the emit went through successfully due to the framework limitations in swapping mock device's deviceProfile + --test.socket.capability:__expect_send({mock_device.id, capabilities.panicAlarm.panicAlarm.clear({state_change = true})}) +end, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test("Switching from button-profile-panic-frient to button-profile-frient should work", function() + test.socket.device_lifecycle:__queue_receive(mock_device_panic:generate_info_changed( + { + preferences = { + panicButton = "0xFFFF" + }, + } + )) + mock_device_panic:expect_metadata_update({ profile = "button-profile-frient" }) + test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic,BasicInput.ID,0x8000,DEVELCO_MANUFACTURER_CODE,data_types.Uint16,0xFFFF)}) +end, +{ + min_api_version = 17 +} ) +test.register_coroutine_test("New preferences after switching the profile should work", function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device_panic:generate_info_changed( + { + preferences = { + buttonAlarmDelay = 1, + buttonCancelDelay = 300, + autoCancel = 20, + alarmBehavior = 1 + } + } + )) + test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic, IASZone.ID,0x8002,DEVELCO_MANUFACTURER_CODE,data_types.Uint16, 1)}) + test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic, IASZone.ID,0x8003,DEVELCO_MANUFACTURER_CODE,data_types.Uint16, 300)}) + test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic, IASZone.ID,0x8004,DEVELCO_MANUFACTURER_CODE,data_types.Uint16, 20)}) + test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic, IASZone.ID,0x8005,DEVELCO_MANUFACTURER_CODE,data_types.Enum8, 1)}) +end, +{ + min_api_version = 17 +} +) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua index 10cbab378d..7e9448ed7e 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -59,9 +48,7 @@ local mock_device_hs6ssb = test.mock_device.build_test_zigbee_device( local function test_init() test.mock_device.add_test_device(mock_device) - test.mock_device.add_test_device(mock_device_hs6ssb) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device_hs6ssb)end test.set_test_init_function(test_init) @@ -103,7 +90,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -135,7 +125,10 @@ test.register_coroutine_test( mock_device_hs6ssb:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -179,7 +172,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0x0011) test.socket.zigbee:__expect_add_hub_to_group(0x0012) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -200,7 +196,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0x0012) test.socket.zigbee:__expect_add_hub_to_group(0x0013) mock_device_hs6ssb:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -278,7 +277,10 @@ test.register_coroutine_test( Basic.attributes.DeviceEnabled:write(mock_device, true) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -309,7 +311,10 @@ test.register_coroutine_test( Basic.attributes.DeviceEnabled:write(mock_device_hs6ssb, true) }) mock_device_hs6ssb:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -354,7 +359,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -399,7 +407,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device_hs6ssb.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua b/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua index 9851d4564b..4a772c5ab0 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -48,9 +37,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -86,7 +73,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -127,7 +117,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -148,7 +141,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -207,12 +203,16 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "added lifecycle event", function() + -- The initial button pushed event should be send during the device's first time onboarding test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send({ mock_device.id, @@ -253,14 +253,59 @@ test.register_coroutine_test( attribute_id = "button", state = { value = "pushed" } } }) - + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.wait_for_events() + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "supportedButtonValues", state = { value = { "pushed", "held" } } + } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "numberOfButtons", state = { value = 2 } + } + }) + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" then + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = button_name, + attribute_id = "supportedButtonValues", state = { value = { "pushed", "held" } } + } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = button_name, + attribute_id = "numberOfButtons", state = { value = 1 } + } + }) + end end + + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua b/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua index 3cef68e9c3..d350567294 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -42,9 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -66,7 +53,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", (button_attr.pushed({ state_change = true }))) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -108,7 +98,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -129,7 +122,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -151,12 +147,16 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_add_hub_to_group(0x0000) test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "added lifecycle event", function() + -- The initial button pushed event should be send during the device's first time onboarding test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send({ mock_device.id, @@ -197,14 +197,58 @@ test.register_coroutine_test( attribute_id = "button", state = { value = "pushed" } } }) - + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.wait_for_events() + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "supportedButtonValues", state = { value = { "pushed" } } + } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "numberOfButtons", state = { value = 2 } + } + }) + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" then + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = button_name, + attribute_id = "supportedButtonValues", state = { value = { "pushed" } } + } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = button_name, + attribute_id = "numberOfButtons", state = { value = 1 } + } + }) + end end + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua b/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua index b32e9f7784..1fc0e9d8e6 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -43,9 +32,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -120,7 +107,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", (button_attr.held({ state_change = true }))) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -162,7 +152,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -184,12 +177,16 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "added lifecycle event", function() + -- The initial button pushed event should be send during the device's first time onboarding test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( mock_device:generate_test_message( @@ -232,8 +229,8 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - - test.socket.capability:__expect_send({ + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.capability:__expect_send({ mock_device.id, { capability_id = "button", component_id = "main", @@ -242,7 +239,55 @@ test.register_coroutine_test( }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 5 }, { visibility = { displayed = false } }) + ) + ) + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" then + if button_name ~= "button5" then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) + ) + ) + else + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed"}, { visibility = { displayed = false } }) + ) + ) + end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end end + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -258,6 +303,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua index 505a9af2e0..81e68a52da 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -42,9 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -58,7 +45,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -71,7 +61,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -110,7 +103,10 @@ test.register_coroutine_test( IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -125,12 +121,16 @@ test.register_coroutine_test( mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "added lifecycle event", function() + -- The initial button pushed event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( @@ -152,12 +152,33 @@ test.register_coroutine_test( attribute_id = "button", state = { value = "pushed" } } }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -175,7 +196,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -193,7 +217,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -211,7 +238,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -229,7 +259,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua new file mode 100644 index 0000000000..a70d3b52a2 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua @@ -0,0 +1,123 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local IASZone = clusters.IASZone +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus +local button_attr = capabilities.button.button + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("four-buttons-without-main-button.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Linxura", + model = "Aura Smart Button", + server_clusters = {0x0500, 0x0000} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test cases for Buttons Pushed", + function() + for var = 0, 3 do + test.socket.zigbee:__queue_receive({ + mock_device.id, + ZoneStatusAttribute:build_test_attr_report(mock_device, 1 + var * 6) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(string.format("button%d", var + 1), button_attr.pushed({ state_change = true })) + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test cases for Buttons Double", + function() + for var = 0, 3 do + test.socket.zigbee:__queue_receive({ + mock_device.id, + ZoneStatusAttribute:build_test_attr_report(mock_device, 3 + var * 6) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(string.format("button%d", var + 1), button_attr.double({ state_change = true })) + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "Test cases for Buttons Held", + function() + for var = 0, 3 do + test.socket.zigbee:__queue_receive({ + mock_device.id, + ZoneStatusAttribute:build_test_attr_report(mock_device, 5 + var * 6) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(string.format("button%d", var + 1), button_attr.held({ state_change = true })) + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua new file mode 100644 index 0000000000..35ad7aae08 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua @@ -0,0 +1,123 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local IASZone = clusters.IASZone +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus +local button_attr = capabilities.button.button + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("four-buttons-without-main-button.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Linxura", + model = "Smart Controller", + server_clusters = {0x0500, 0x0000} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test cases for Buttons Pushed", + function() + for var = 0, 3 do + test.socket.zigbee:__queue_receive({ + mock_device.id, + ZoneStatusAttribute:build_test_attr_report(mock_device, 1 + var * 6) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(string.format("button%d", var + 1), button_attr.pushed({ state_change = true })) + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test cases for Buttons Double", + function() + for var = 0, 3 do + test.socket.zigbee:__queue_receive({ + mock_device.id, + ZoneStatusAttribute:build_test_attr_report(mock_device, 3 + var * 6) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(string.format("button%d", var + 1), button_attr.double({ state_change = true })) + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "Test cases for Buttons Held", + function() + for var = 0, 3 do + test.socket.zigbee:__queue_receive({ + mock_device.id, + ZoneStatusAttribute:build_test_attr_report(mock_device, 5 + var * 6) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(string.format("button%d", var + 1), button_attr.held({ state_change = true })) + ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_multiir_smart_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_multiir_smart_button.lua new file mode 100644 index 0000000000..84b974e988 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_multiir_smart_button.lua @@ -0,0 +1,131 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local IASZone = clusters.IASZone +local PRIVATE_CMD_ID = 0xF1 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("one-button-battery-no-fw-update.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "MultIR", + model = "MIR-SO100", + server_clusters = {0x0000, 0x0001, 0x0003, 0x0020, 0x0500, 0x0B05} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + + + +test.register_coroutine_test( + "added lifecycle event", + function() + -- The initial button pushed event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed","held","double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "button", state = { value = "pushed" } + } + }) + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed","held","double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "IASZone cmd 0xF1 0x00 are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, IASZone.ID, PRIVATE_CMD_ID, 0x0000, "\x00", 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) + } + } +) + +test.register_message_test( + "IASZone cmd 0xF1 0x01 are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, IASZone.ID, PRIVATE_CMD_ID, 0x0000, "\x01", 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.double({state_change = true})) + } + } +) + +test.register_message_test( + "IASZone cmd 0xF1 0x80 are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, IASZone.ID, PRIVATE_CMD_ID, 0x0000, "\x80", 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.held({state_change = true})) + } + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua index 016336191c..2f6deceb70 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -40,9 +29,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -59,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) test.register_message_test( @@ -69,6 +59,9 @@ test.register_message_test( direction = "receive", message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0000) } } + }, + { + min_api_version = 17 } ) @@ -85,6 +78,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -101,50 +97,53 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send({ - mock_device.id, - { - capability_id = "button", component_id = "main", - attribute_id = "button", state = { value = "pushed" } - } - }) - test.wait_for_events() +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) +-- test.socket.capability:__expect_send( +-- mock_device:generate_test_message( +-- "main", +-- capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }) +-- ) +-- ) +-- test.socket.capability:__expect_send( +-- mock_device:generate_test_message( +-- "main", +-- capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) +-- ) +-- ) +-- test.socket.capability:__expect_send({ +-- mock_device.id, +-- { +-- capability_id = "button", component_id = "main", +-- attribute_id = "button", state = { value = "pushed" } +-- } +-- }) +-- test.wait_for_events() - test.mock_time.advance_time(50000) -- Battery has a max reporting interval of 21600 - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send( - { - mock_device.id, - PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) - } - ) - end, - { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end - } -) +-- test.mock_time.advance_time(50000) -- Battery has a max reporting interval of 21600 +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) test.register_coroutine_test( "Refresh necessary attributes", @@ -163,7 +162,10 @@ test.register_coroutine_test( IASZone.attributes.ZoneStatus:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -215,7 +217,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua index d9c5ff06d5..bbe5cbaf86 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -48,9 +37,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -92,7 +79,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -132,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.up_hold({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -174,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -214,12 +210,16 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_add_hub_to_group(0xE902) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "added lifecycle event", function() + -- The initial button pushed event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__set_channel_ordering("relaxed") @@ -273,7 +273,61 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" and (button_name == "button1" or button_name == "button3") then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "up_hold" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + elseif button_name ~= "main" and (button_name == "button2" or button_name == "button4") then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "down_hold" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + else + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "up_hold", "down_hold" }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 4 }, { visibility = { displayed = false } }) + ) + ) + end + end + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -312,6 +366,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -327,7 +384,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua index c698645ad2..8c291f8483 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -58,9 +47,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -137,7 +124,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -211,6 +201,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.up_hold({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -289,6 +282,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -331,12 +327,16 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0xE903) test.socket.zigbee:__expect_add_hub_to_group(0xE904) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "added lifecycle event", function() + -- The initial button pushed event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__set_channel_ordering("relaxed") @@ -390,7 +390,62 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" and (button_name == "button1" or button_name == "button3" or button_name == "button5" or button_name == "button7") then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "up_hold" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + elseif button_name ~= "main" and (button_name == "button2" or button_name == "button4" or button_name == "button6" or button_name == "button8") then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "down_hold" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + else + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "up_hold", "down_hold" }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 8 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -429,6 +484,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -444,7 +502,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua index 9e986f097f..72bcd2009c 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -40,9 +29,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -101,7 +88,10 @@ test.register_coroutine_test( IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -128,6 +118,14 @@ test.register_coroutine_test( attribute_id = "button", state = { value = "pushed" } } }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) -- test.socket.zigbee:__expect_send({ -- mock_device.id, -- PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) @@ -140,7 +138,10 @@ test.register_coroutine_test( -- mock_device.id, -- IASZone.attributes.ZoneStatus:read(mock_device) -- }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua index 6696ae513b..d5eed5d55f 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -41,9 +30,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -96,7 +83,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) @@ -150,7 +140,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) @@ -204,7 +197,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.double({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -228,12 +224,23 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) + for endpoint = 1,4 do + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID, endpoint) + }) + end test.socket.zigbee:__expect_add_hub_to_group(0x0000) test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -278,7 +285,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua index 2ed8e2f7a0..2793a6de1b 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -46,9 +35,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -76,7 +63,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,7 +114,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -145,7 +138,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -204,7 +200,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -249,7 +248,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua index dbebf6cad6..9e209541ff 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -41,9 +30,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_coroutine_test( @@ -105,7 +92,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -171,7 +161,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("button11", (button_attr.pushed({ state_change = true }))) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -192,7 +185,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -237,7 +233,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-button/src/test/test_sonoff_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_sonoff_button.lua new file mode 100644 index 0000000000..22f76bc383 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_sonoff_button.lua @@ -0,0 +1,279 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local zb_const = require "st.zigbee.constants" +local messages = require "st.zigbee.messages" +local data_types = require "st.zigbee.data_types" +local zcl_messages = require "st.zigbee.zcl" +local report_attr = require "st.zigbee.zcl.global_commands.report_attribute" + +local SONOFF_PRIVATE_BUTTON_CLUSTER = 0xFC12 +local SONOFF_PRIVATE_ATTR = 0x0000 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("four-buttons-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + }, + [2] = { + id = 2, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + }, + [3] = { + id = 3, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + }, + [4] = { + id = 4, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function build_test_attr_report(device, endpoint, value) + local report_body = report_attr.ReportAttribute({ + report_attr.ReportAttributeAttributeRecord(SONOFF_PRIVATE_ATTR, data_types.Uint8.ID, value) + }) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(report_body.ID) + }) + local addrh = messages.AddressHeader( + device:get_short_address(), + endpoint, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + SONOFF_PRIVATE_BUTTON_CLUSTER + ) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = report_body + }) + + return messages.ZigbeeMessageRx({ + address_header = addrh, + body = message_body + }) +end + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 4 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 1 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button1", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button1", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 2 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button2", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button2", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 3 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button3", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button3", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 4 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button4", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button4", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false })) + ) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button pushed message should generate event", + function() + -- 0xFC12, 0x0000, 0x01 = pushed + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x01)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button double message should generate event", + function() + -- 0xFC12, 0x0000, 0x02 = double + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x02)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.double({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.double({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button held message should generate event", + function() + -- 0xFC12, 0x0000, 0x03 = held + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x03)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.held({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.held({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button pushed_3x message should generate event", + function() + -- 0xFC12, 0x0000, 0x04 = pushed_3x + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x04)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.pushed_3x({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed_3x({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button 2 pushed message should generate event on button2 component", + function() + -- Endpoint 2 test + local attr_report = build_test_attr_report(mock_device, 2, data_types.Uint8(0x01)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery percentage report should generate event", + function() + local battery_report = clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 180) + + test.socket.zigbee:__queue_receive({ mock_device.id, battery_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(90)) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua index bdb8e51bf6..f4ee2e0aed 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local data_types = require "st.zigbee.data_types" @@ -43,7 +46,10 @@ test.register_coroutine_test( capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -57,7 +63,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0x0012, attr_report_data, 0x110A) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -71,7 +80,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0x0012, attr_report_data, 0x110A) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.double({ state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -85,7 +97,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0x0012, attr_report_data, 0x110A) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.held({ state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua new file mode 100644 index 0000000000..c76d080a01 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua @@ -0,0 +1,220 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local OnOff = clusters.OnOff +local LevelControl = clusters.Level + +local button_attr = capabilities.button.button + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("two-buttons-no-fw-update.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Vimar", + model = "RemoteControl_v1.0", + server_clusters = {0x0000, 0x0003}, + client_clusters = {0x0006, 0x0008} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Remote Control should be handled in added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "down_hold", "up" }, { visibility = { displayed = false } }) + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 2 }, { visibility = { displayed = false } }) + ) + ) + + for button_number = 1, 2 do + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button" .. button_number, + capabilities.button.supportedButtonValues({ "pushed", "down_hold", "up" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button" .. button_number, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "button", state = { value = "pushed" } + } + }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button UP (button1) should handle pushed event", + function() + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.server.commands.On.build_test_rx(mock_device) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", button_attr.pushed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button DOWN (button2) should handle pushed event", + function() + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.server.commands.Off.build_test_rx(mock_device) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", button_attr.pushed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button UP (button1) should handle down hold and up event", + function() + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, LevelControl.server.commands.Move.build_test_rx(mock_device, LevelControl.types.MoveStepMode.UP, 255) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", button_attr.down_hold({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.down_hold({ state_change = true })) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, LevelControl.server.commands.Stop.build_test_rx(mock_device) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", button_attr.up({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.up({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Button DOWN (button2) should handle down hold and up", + function() + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, LevelControl.server.commands.Move.build_test_rx(mock_device, LevelControl.types.MoveStepMode.DOWN, 255) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", button_attr.down_hold({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.down_hold({ state_change = true })) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, LevelControl.server.commands.Stop.build_test_rx(mock_device) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", button_attr.up({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button_attr.up({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Remote Control driver should handle configuration lifecycle", + function() + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send( + { + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + LevelControl.ID) + } + ) + + for _, component in pairs(mock_device.profile.components) do + local number_of_buttons = component.id == "main" and 2 or 1 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + component.id, + capabilities.button.supportedButtonValues({ "pushed", "down_hold", "up" }, { visibility = { displayed = true } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + component.id, + capabilities.button.numberOfButtons({ value = number_of_buttons }, { visibility = { displayed = true } }) + ) + ) + end + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua index f0b8b99d1f..93c6d26139 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -37,9 +26,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -317,7 +304,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 0x1F) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -355,7 +345,10 @@ test.register_coroutine_test( test.socket:set_time_advance_per_select(0.1) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua index 747e17241a..db4f6b9a43 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua @@ -1,22 +1,12 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" @@ -26,13 +16,11 @@ local ZoneStatusAttribute = IASZone.attributes.ZoneStatus local button_attr = capabilities.button.button local mock_device = test.mock_device.build_test_zigbee_device( - { profile = t_utils.get_profile_definition("button-profile.yml") } + { profile = t_utils.get_profile_definition("one-button-temp-battery.yml") } ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -49,6 +37,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -65,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -81,6 +75,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.double({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -92,6 +89,9 @@ test.register_message_test( direction = "receive", message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0000) } } + }, + { + min_api_version = 17 } ) @@ -108,6 +108,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -124,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -140,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.double({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -156,51 +165,112 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) - ) - ) - test.socket.capability:__expect_send({ - mock_device.id, - { - capability_id = "button", component_id = "main", - attribute_id = "button", state = { value = "pushed" } - } - }) - test.wait_for_events() +test.register_message_test( + "Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) - test.mock_time.advance_time(50000) -- Battery has a max reporting interval of 21600 - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send( - { - mock_device.id, - PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) - } - ) - end, +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) + } + }, { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 17 + } ) +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) +-- test.socket.capability:__expect_send( +-- mock_device:generate_test_message( +-- "main", +-- capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }) +-- ) +-- ) +-- test.socket.capability:__expect_send( +-- mock_device:generate_test_message( +-- "main", +-- capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) +-- ) +-- ) +-- test.socket.capability:__expect_send({ +-- mock_device.id, +-- { +-- capability_id = "button", component_id = "main", +-- attribute_id = "button", state = { value = "pushed" } +-- } +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) +-- }) +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) -- Battery has a max reporting interval of 21600 +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) +-- } +-- ) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) +-- }) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + test.register_coroutine_test( "Refresh necessary attributes", function() @@ -218,7 +288,16 @@ test.register_coroutine_test( IASZone.attributes.ZoneStatus:read(mock_device) } ) - end + test.socket.zigbee:__expect_send( + { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + ) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -237,6 +316,14 @@ test.register_coroutine_test( 1) } ) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, + 30, + 600, + 100) + } + ) test.socket.zigbee:__expect_send( { mock_device.id, @@ -245,6 +332,14 @@ test.register_coroutine_test( PowerConfiguration.ID) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID) + } + ) test.socket.zigbee:__expect_send( { mock_device.id, @@ -263,6 +358,12 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + ) test.socket.zigbee:__expect_send( { mock_device.id, @@ -270,7 +371,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua index 9a55de8ab9..1b3078f637 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -42,9 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -61,6 +48,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) @@ -106,7 +96,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -128,7 +121,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -143,7 +139,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +165,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -208,7 +210,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -254,7 +259,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0x4003) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua new file mode 100644 index 0000000000..13479d4e8b --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua @@ -0,0 +1,207 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local IasEnrollResponseCode = IASZone.types.EnrollResponseCode + +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus +local button_attr = capabilities.button.button + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("eight-buttons-temp-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "zunzunbee", + model = "SSWZ8T", + server_clusters = {0x0000, 0x0001, 0x0003, 0x0020, 0x0402, 0x0500} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + for button_name, _ in pairs(mock_device.profile.components) do + if button_name ~= "main" then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.supportedButtonValues({ "pushed", "held"}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 8 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "button", state = { value = "pushed" } + } + }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting( + mock_device, 30, 21600, 02 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, 30, 600, 100 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test cases for Buttons Pushed", + function() + for var = 1, 8 do + test.socket.zigbee:__queue_receive({ + mock_device.id, + ZoneStatusAttribute:build_test_attr_report(mock_device, 1<> 10) & 0x0F + local number_of_buttons = math.max(1, math.min(button_count_bits, 8)) + + -- Emit numberOfButtons event + local number_event = capabilities.button.numberOfButtons({ value = number_of_buttons }, { visibility = { displayed = true } }) + device:emit_component_event(device.profile.components["main"], number_event) + + -- Map of zone status bit checks to button component names + local button_bit_map = { + { check = "is_alarm2_set", name = "button1" }, + { check = "is_tamper_set", name = "button2" }, + { check = "is_battery_low_set", name = "button3" }, + { check = "is_supervision_notify_set", name = "button4" }, + { check = "is_restore_notify_set", name = "button5" }, + { check = "is_trouble_set", name = "button6" }, + { check = "is_ac_mains_fault_set", name = "button7" }, + { check = "is_test_set", name = "button8" } + } + + local additional_fields = { state_change = true } + local button_name, event + + + -- Check which button bit is set and determine if it was a hold or push + for _, entry in ipairs(button_bit_map) do + if zone_status[entry.check](zone_status) then + button_name = entry.name + event = zone_status:is_alarm1_set() and + capabilities.button.button.held(additional_fields) or + capabilities.button.button.pushed(additional_fields) + break + end + end + + -- Emit the button event to the specific button component + if button_name and event then + local component = device.profile.components[button_name] + if component then + device:emit_component_event(component, event) + else + log.warn("Attempted to emit button event for non-existing component: " .. button_name) + end + + -- Also emit the event to the "main" component as a general indicator + device:emit_component_event(device.profile.components["main"], event) + end +end + +--- @param driver Driver The current driver running containing necessary context for execution +--- @param device ZigbeeDevice The device this message was received from containing identifying information +--- @param zone_status 2 byte bitmap zoneStatus attribute value of the IAS Zone cluster +--- @param zb_rx ZigbeeMessageRx the full message this report came in +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + generate_button_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +--- @param driver Driver The current driver running containing necessary context for execution +--- @param device ZigbeeDevice The device this message was received from containing identifying information +--- @param zb_rx containing zoneStatus attribute value of the IAS Zone cl +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_button_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +-- Handle the beep capability command by sending Identify command to the device +local function beep_handler(self, device, command) + device:send(IdentifyCluster.server.commands.Identify(device, BEEP_IDENTIFY_TIME)) +end + +-- Main device handler definition +local zunzunbee_device_handler = { + NAME = "Zunzunbee Device handler", + lifecycle_handlers = { + init = init_handler + }, + supported_capabilities = { + capabilities.button, + capabilities.battery, + capabilities.tone, + capabilities.temperatureMeasurement + }, + capability_handlers = { + [Tone.ID] = { + [Tone.commands.beep.NAME] = beep_handler + } + }, + zigbee_handlers = { + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + } + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + } + }, + can_handle = require("zigbee-multi-button.zunzunbee.can_handle"), +} + +return zunzunbee_device_handler diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml b/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml index 3c05e02436..eb0d7e0209 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml @@ -14,3 +14,13 @@ zigbeeManufacturer: manufacturer: HEIMAN model: COSensor-EM deviceProfileName: carbonMonoxide-battery + - id: "frient A/S/SCAZB-143" + deviceLabel: Frient Carbon Monoxide Detector + manufacturer: frient A/S + model: SCAZB-143 + deviceProfileName: frient-smoke-co-temperature-battery + - id: "frient A/S/SCAZB-141" + deviceLabel: Frient Carbon Monoxide Detector + manufacturer: frient A/S + model: SCAZB-141 + deviceProfileName: frient-smoke-co-temperature-battery \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/profiles/frient-smoke-co-temperature-battery.yml b/drivers/SmartThings/zigbee-carbon-monoxide-detector/profiles/frient-smoke-co-temperature-battery.yml new file mode 100644 index 0000000000..3a22a0ad77 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/profiles/frient-smoke-co-temperature-battery.yml @@ -0,0 +1,55 @@ +name: frient-smoke-co-temperature-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: tamperAlert + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + - id: alarm + version: 1 + config: + values: + - key: "alarm.value" + enabledValues: + - off + - siren + - key: "{{enumCommands}}" + enabledValues: + - off + - siren + categories: + - name: SmokeDetector +preferences: +- title: "Max alarm duration (s)" + name: maxWarningDuration + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 +- preferenceId: tempOffset + explicit: true +- title: "Temperature Sensitivity (°C)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/can_handle.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/can_handle.lua new file mode 100644 index 0000000000..95ad1a88fa --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_climax_technology_carbon_monoxide = function(opts, driver, device) + local FINGERPRINTS = require("ClimaxTechnology.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("ClimaxTechnology") + end + end + + return false +end + +return is_climax_technology_carbon_monoxide diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/fingerprints.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/fingerprints.lua new file mode 100644 index 0000000000..d72389f772 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS = { + { mfr = "ClimaxTechnology", model = "CO_00.00.00.22TC" }, + { mfr = "ClimaxTechnology", model = "CO_00.00.00.15TC" } +} + +return CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua index cf96fb7162..1f18983c4b 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua @@ -1,33 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" -local CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS = { - { mfr = "ClimaxTechnology", model = "CO_00.00.00.22TC" }, - { mfr = "ClimaxTechnology", model = "CO_00.00.00.15TC" } -} +local capabilities = require "st.capabilities" -local is_climax_technology_carbon_monoxide = function(opts, driver, device) - for _, fingerprint in ipairs(CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local device_added = function(self, device) device:emit_event(capabilities.battery.battery(100)) @@ -38,7 +15,7 @@ local climax_technology_carbon_monoxide = { lifecycle_handlers = { added = device_added }, - can_handle = is_climax_technology_carbon_monoxide + can_handle = require("ClimaxTechnology.can_handle"), } return climax_technology_carbon_monoxide diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/can_handle.lua new file mode 100644 index 0000000000..f451011e03 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_frient_smoke_carbon_monoxide = function(opts, driver, device) + local FINGERPRINTS = require("frient.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("frient") + end + end + + return false +end + +return is_frient_smoke_carbon_monoxide diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/fingerprints.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/fingerprints.lua new file mode 100644 index 0000000000..22180458e9 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_SMOKE_CARBON_MONOXIDE_FINGERPRINTS = { + { mfr = "frient A/S", model = "SCAZB-141" }, + { mfr = "frient A/S", model = "SCAZB-143" } +} + +return FRIENT_SMOKE_CARBON_MONOXIDE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/init.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/init.lua new file mode 100644 index 0000000000..4f7d8fa8d5 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/init.lua @@ -0,0 +1,236 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local SinglePrecisionFloat = require "st.zigbee.data_types.SinglePrecisionFloat" +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local IASZone = zcl_clusters.IASZone +local CarbonMonoxideCluster = zcl_clusters.CarbonMonoxide +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement +local IASWD = zcl_clusters.IASWD +local SirenConfiguration = IASWD.types.SirenConfiguration +local carbonMonoxide = capabilities.carbonMonoxideDetector +local alarm = capabilities.alarm +local smokeDetector = capabilities.smokeDetector +local carbonMonoxideMeasurement = capabilities.carbonMonoxideMeasurement +local tamperAlert = capabilities.tamperAlert + +local ALARM_COMMAND = "alarmCommand" +local DEFAULT_MAX_WARNING_DURATION = 0x00F0 +local CarbonMonoxideEndpoint = 0x2E +local SmokeAlarmEndpoint = 0x23 +local TEMPERATURE_ENDPOINT = 0x26 + +local alarm_command = { + OFF = 0, + SIREN = 1 +} + +local CONFIGURATIONS = { + { + cluster = CarbonMonoxideCluster.ID, + attribute = CarbonMonoxideCluster.attributes.MeasuredValue.ID, + minimum_interval = 30, + maximum_interval = 600, + data_type = data_types.SinglePrecisionFloat, + reportable_change = SinglePrecisionFloat(0, -20, 0.048576) -- 0, -20, 0.048576 is 1ppm in SinglePrecisionFloat + } +} + +local function get_current_max_warning_duration(device) + return device.preferences.maxWarningDuration == nil and DEFAULT_MAX_WARNING_DURATION or device.preferences.maxWarningDuration +end + +local function device_added(driver, device) + device:emit_event(alarm.alarm.off()) + device:emit_event(smokeDetector.smoke.clear()) + device:emit_event(carbonMonoxide.carbonMonoxide.clear()) + device:emit_event(tamperAlert.tamper.clear()) + device:emit_event(carbonMonoxideMeasurement.carbonMonoxideLevel({value = 0, unit = "ppm"})) +end + +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(2.6, 3.1)(driver, device) + if CONFIGURATIONS ~= nil then + for _, attribute in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(attribute) + end + end +end + +local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) + local endpoint = zigbee_message.address_header.src_endpoint.value + if endpoint == SmokeAlarmEndpoint then + if zone_status:is_test_set() then + device:emit_event(smokeDetector.smoke.tested()) + elseif zone_status:is_alarm1_set() then + device:emit_event(smokeDetector.smoke.detected()) + else + device.thread:call_with_delay(6, function () + device:emit_event(smokeDetector.smoke.clear()) + end) + end + elseif endpoint == CarbonMonoxideEndpoint then + if zone_status:is_test_set() then + device:emit_event(carbonMonoxide.carbonMonoxide.tested()) + elseif zone_status:is_alarm1_set() then + device:emit_event(carbonMonoxide.carbonMonoxide.detected()) + else + device.thread:call_with_delay(6, function () + device:emit_event(carbonMonoxide.carbonMonoxide.clear()) + end) + end + end + if zone_status:is_tamper_set() then + device:emit_event(tamperAlert.tamper.detected()) + else + device:emit_event(tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function carbon_monoxide_measure_value_attr_handler(driver, device, attr_val, zb_rx) + local co_value = attr_val.value + if co_value <= 1 then + co_value = co_value * 1000000 + else + return + end + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, carbonMonoxideMeasurement.carbonMonoxideLevel({value = co_value, unit = "ppm"})) +end + +local function do_configure(driver, device) + device:configure() + local maxWarningDuration = get_current_max_warning_duration(device) + device:send(IASWD.attributes.MaxDuration:write(device, maxWarningDuration):to_endpoint(0x23)) + + device.thread:call_with_delay(5, function() + device:refresh() + end) +end + +local function send_siren_command(device, warning_mode, warning_siren_level) + local warning_duration = get_current_max_warning_duration(device) + local siren_configuration + + siren_configuration = SirenConfiguration(0x00) + siren_configuration:set_warning_mode(warning_mode) + siren_configuration:set_siren_level(warning_siren_level) + + device:send( + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + ) +end + +local function siren_switch_off_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.OFF, {persist = true}) + send_siren_command(device, 0x00, 0x00) +end + +local function siren_alarm_siren_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.SIREN, {persist = true}) + send_siren_command(device, 0x01 , 0x01) + + local warningDurationDelay = get_current_max_warning_duration(device) + + device.thread:call_with_delay(warningDurationDelay, function() -- Send command to switch from siren to off in the app when the siren is done + if(device:get_field(ALARM_COMMAND) == alarm_command.SIREN) then + siren_switch_off_handler(driver, device, command) + end + end) +end + +local emit_alarm_event = function(device, cmd) + if cmd == alarm_command.OFF then + device:emit_event(capabilities.alarm.alarm.off()) + elseif cmd == alarm_command.SIREN then + device:emit_event(capabilities.alarm.alarm.siren()) + end +end + +local default_response_handler = function(driver, device, zigbee_message) + local is_success = zigbee_message.body.zcl_body.status.value + local command = zigbee_message.body.zcl_body.cmd.value + local alarm_ev = device:get_field(ALARM_COMMAND) + + if command == IASWD.server.commands.StartWarning.ID and is_success == Status.SUCCESS then + if alarm_ev ~= alarm_command.OFF then + emit_alarm_event(device, alarm_ev) + local lastDuration = get_current_max_warning_duration(device) + device.thread:call_with_delay(lastDuration, function(d) + device:emit_event(capabilities.alarm.alarm.off()) + end) + else + emit_alarm_event(device,alarm_command.OFF) + end + end +end + +local function info_changed(driver, device, event, args) + for name, info in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "maxWarningDuration") then + local input = device.preferences.maxWarningDuration + device:send(IASWD.attributes.MaxDuration:write(device, input)) + elseif (name == "temperatureSensitivity") then + local sensitivity = device.preferences.temperatureSensitivity + local temperatureSensitivity = math.floor(sensitivity * 100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 600, temperatureSensitivity):to_endpoint(TEMPERATURE_ENDPOINT)) + end + end + end +end + +local frient_smoke_carbon_monoxide = { + NAME = "Frient Smoke Carbon Monoxide", + lifecycle_handlers = { + added = device_added, + init = device_init, + configure = do_configure, + infoChanged = info_changed, + }, + capability_handlers = { + [alarm.ID] = { + [alarm.commands.off.NAME] = siren_switch_off_handler, + [alarm.commands.siren.NAME] = siren_alarm_siren_handler + } + }, + zigbee_handlers = { + global = { + [IASWD.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = default_response_handler + } + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status + }, + [CarbonMonoxideCluster.ID] = { + [CarbonMonoxideCluster.attributes.MeasuredValue.ID] = carbon_monoxide_measure_value_attr_handler + } + } + }, + can_handle = require("frient.can_handle"), +} + +return frient_smoke_carbon_monoxide \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua index 7829c31e5f..8a4f5b5c01 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local capabilities = require "st.capabilities" @@ -22,11 +12,19 @@ local zigbee_carbon_monoxide_driver_template = { supported_capabilities = { capabilities.carbonMonoxideDetector, capabilities.battery, + capabilities.carbonMonoxideMeasurement, + capabilities.temperatureMeasurement, + capabilities.smokeDetector, + capabilities.tamperAlert, + capabilities.alarm }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, - sub_drivers = { require("ClimaxTechnology") } + health_check = false, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(zigbee_carbon_monoxide_driver_template, zigbee_carbon_monoxide_driver_template.supported_capabilities) +defaults.register_for_default_handlers(zigbee_carbon_monoxide_driver_template, + zigbee_carbon_monoxide_driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) local zigbee_carbon_monoxide_driver = ZigbeeDriver("zigbee-carbon-monoxide-detector", zigbee_carbon_monoxide_driver_template) zigbee_carbon_monoxide_driver:run() diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/sub_drivers.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/sub_drivers.lua new file mode 100644 index 0000000000..c826ab7d00 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +local sub_drivers = { + lazy_load_if_possible("ClimaxTechnology"), + lazy_load_if_possible("frient"), +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua index 07c7a38314..b0df2ffaa4 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -34,9 +23,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -54,7 +41,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_frient_co_smoke_temperature_battery.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_frient_co_smoke_temperature_battery.lua new file mode 100644 index 0000000000..16625fdf92 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_frient_co_smoke_temperature_battery.lua @@ -0,0 +1,506 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local IASZone = clusters.IASZone +local IASWD = clusters.IASWD +local CarbonMonoxideCluster = clusters.CarbonMonoxide +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local capabilities = require "st.capabilities" +local alarm = capabilities.alarm +local smokeDetector = capabilities.smokeDetector +local carbonMonoxideDetector = capabilities.carbonMonoxideDetector +local carbonMonoxideMeasurement = capabilities.carbonMonoxideMeasurement +local tamperAlert = capabilities.tamperAlert +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" +local SinglePrecisionFloat = require "st.zigbee.data_types.SinglePrecisionFloat" +local device_management = require "st.zigbee.device_management" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local SMOKE_ENDPOINT = 0x23 +local CO_ENDPOINT = 0x2E +local TEMPERATURE_ENDPOINT = 0x26 +local ALARM_COMMAND = "alarmCommand" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-smoke-co-temperature-battery.yml"), + fingerprinted_endpoint_id = SMOKE_ENDPOINT, + zigbee_endpoints = { + [SMOKE_ENDPOINT] = { + id = SMOKE_ENDPOINT, + manufacturer = "frient A/S", + model = "SCAZB-143", + server_clusters = { PowerConfiguration.ID, IASZone.ID, IASWD.ID } + }, + [CO_ENDPOINT] = { + id = CO_ENDPOINT, + server_clusters = { IASZone.ID, CarbonMonoxideCluster.ID } + }, + [TEMPERATURE_ENDPOINT] = { + id = TEMPERATURE_ENDPOINT, + server_clusters = { TemperatureMeasurement.ID } + } + } + } +) + +local function build_default_response_msg(cluster, command, status, endpoint) + local addr_header = messages.AddressHeader( + mock_device:get_short_address(), + endpoint or SMOKE_ENDPOINT, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local default_response_body = default_response.DefaultResponse(command, status) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(default_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = default_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +local function expect_bind_and_config(config, endpoint) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, config.cluster, zigbee_test_utils.mock_hub_eui, endpoint):to_endpoint(endpoint) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_config(mock_device, config):to_endpoint(endpoint) + }) +end + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle should set default states", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideMeasurement.carbonMonoxideLevel({ value = 0, unit = "ppm" })) + ) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "init and doConfigure should bind, configure, and refresh", + function() + local battery_config = { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryVoltage.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = data_types.Uint8, + reportable_change = 1 + } + local ias_zone_config = { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 0, + maximum_interval = 180, + data_type = IASZone.attributes.ZoneStatus.base_type + } + local co_config = { + cluster = CarbonMonoxideCluster.ID, + attribute = CarbonMonoxideCluster.attributes.MeasuredValue.ID, + minimum_interval = 30, + maximum_interval = 600, + data_type = data_types.SinglePrecisionFloat, + reportable_change = SinglePrecisionFloat(0, -20, 0.048576) + } + local temp_config = { + cluster = TemperatureMeasurement.ID, + attribute = TemperatureMeasurement.attributes.MeasuredValue.ID, + minimum_interval = 30, + maximum_interval = 600, + data_type = data_types.Int16, + reportable_change = data_types.Int16(100) + } + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, PowerConfiguration.ID, PowerConfiguration.attributes.BatteryVoltage.ID):to_endpoint(SMOKE_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, IASZone.ID, IASZone.attributes.ZoneStatus.ID):to_endpoint(SMOKE_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, IASZone.ID, IASZone.attributes.ZoneStatus.ID):to_endpoint(CO_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, CarbonMonoxideCluster.ID, CarbonMonoxideCluster.attributes.MeasuredValue.ID):to_endpoint(CO_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, TemperatureMeasurement.ID, TemperatureMeasurement.attributes.MeasuredValue.ID):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + expect_bind_and_config(battery_config, SMOKE_ENDPOINT) + expect_bind_and_config(ias_zone_config, SMOKE_ENDPOINT) + expect_bind_and_config(ias_zone_config, CO_ENDPOINT) + expect_bind_and_config(co_config, CO_ENDPOINT) + expect_bind_and_config(temp_config, TEMPERATURE_ENDPOINT) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device, 0x00, 0x00) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "IAS Zone smoke detected should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.detected()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone smoke tested should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0100):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.tested()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone smoke clear should be delayed", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000):from_endpoint(SMOKE_ENDPOINT) + }) + + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "IAS Zone carbon monoxide detected should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.detected()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone carbon monoxide tested should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0100):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.tested()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone carbon monoxide clear should be delayed", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000):from_endpoint(CO_ENDPOINT) + }) + + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.clear()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Tamper detected should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.detected()) + ) + end +) + +test.register_coroutine_test( + "Tamper clear should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "Carbon monoxide measurement should scale values <= 1", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + CarbonMonoxideCluster.attributes.MeasuredValue:build_test_attr_report( + mock_device, + SinglePrecisionFloat(0, -20, 0.048576) + ):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideMeasurement.carbonMonoxideLevel({ value = 0.99999999747524, unit = "ppm" })) + ) + end +) + +test.register_coroutine_test( + "Carbon monoxide measurement should pass through values > 1", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + CarbonMonoxideCluster.attributes.MeasuredValue:build_test_attr_report( + mock_device, + SinglePrecisionFloat(0, -15, 0.572864) + ):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideMeasurement.carbonMonoxideLevel({ value = 47.999998059822, unit = "ppm" })) + ) + end +) + +test.register_coroutine_test( + "infoChanged should update maxWarningDuration and temperatureSensitivity", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + local updates = { + preferences = { + maxWarningDuration = 120, + temperatureSensitivity = 1.3 + } + } + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, 120) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 600, + 130 + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + end +) + +test.register_coroutine_test( + "Alarm siren command should send StartWarning and auto-off", + function() + mock_device.preferences.maxWarningDuration = 5 + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + local expected_configuration = IASWD.types.SirenConfiguration(0x00) + expected_configuration:set_warning_mode(0x01) + expected_configuration:set_siren_level(0x01) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expected_configuration, + data_types.Uint16(5), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) + + test.wait_for_events() + test.mock_time.advance_time(5) + + local expected_off_configuration = IASWD.types.SirenConfiguration(0x00) + expected_off_configuration:set_warning_mode(0x00) + expected_off_configuration:set_siren_level(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expected_off_configuration, + data_types.Uint16(5), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) + end +) + +test.register_coroutine_test( + "Alarm off command should send StartWarning stop", + function() + mock_device.preferences.maxWarningDuration = 5 + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + local expected_configuration = IASWD.types.SirenConfiguration(0x00) + expected_configuration:set_warning_mode(0x00) + expected_configuration:set_siren_level(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expected_configuration, + data_types.Uint16(5), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) + end +) + +test.register_coroutine_test( + "Default response to StartWarning should emit alarm events", + function() + mock_device.preferences.maxWarningDuration = 2 + mock_device:set_field(ALARM_COMMAND, 1, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(IASWD.ID, IASWD.server.commands.StartWarning.ID, Status.SUCCESS, SMOKE_ENDPOINT) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.siren()) + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua index 84b6dd4c4b..1b67dc8ee6 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -29,9 +18,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -46,7 +33,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "carbonMonoxideDetector", capability_attr_id = "carbonMonoxide" } + } + }, + }, + { + min_api_version = 17 } ) @@ -62,7 +60,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "carbonMonoxideDetector", capability_attr_id = "carbonMonoxide" } + } + }, + }, + { + min_api_version = 17 } ) @@ -78,7 +87,19 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "carbonMonoxideDetector", capability_attr_id = "carbonMonoxide" } + } + }, + + }, + { + min_api_version = 17 } ) @@ -94,7 +115,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "carbonMonoxideDetector", capability_attr_id = "carbonMonoxide" } + } + }, + }, + { + min_api_version = 17 } ) @@ -111,6 +143,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) @@ -179,7 +214,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-contact/fingerprints.yml b/drivers/SmartThings/zigbee-contact/fingerprints.yml index 85a7523ea0..56e1599c96 100644 --- a/drivers/SmartThings/zigbee-contact/fingerprints.yml +++ b/drivers/SmartThings/zigbee-contact/fingerprints.yml @@ -3,7 +3,7 @@ zigbeeManufacturer: deviceLabel: Aqara Door and Window Sensor T1 manufacturer: LUMI model: lumi.magnet.agl02 - deviceProfileName: contact-battery-profile + deviceProfileName: contact-batteryLevel - id: "NYCE/3010" deviceLabel: NYCE Open/Closed Sensor manufacturer: NYCE @@ -140,15 +140,25 @@ zigbeeManufacturer: model: SS-B100-ZB deviceProfileName: contact-battery-temperature - id: "frient A/S/WISZB-120" - deviceLabel: frient Open/Closed Sensor + deviceLabel: frient Entry Sensor Pro manufacturer: frient A/S model: WISZB-120 - deviceProfileName: contact-battery-temperature + deviceProfileName: contact-battery-temperature-tamper - id: "frient A/S/WISZB-121" - deviceLabel: frient Open/Closed Sensor + deviceLabel: frient Entry Sensor manufacturer: frient A/S model: WISZB-121 - deviceProfileName: contact-battery-temperature + deviceProfileName: contact-battery-profile + - id: "frient A/S/WISZB-131" + deviceLabel: frient Entry Sensor 2 Pro + manufacturer: frient A/S + model: WISZB-131 + deviceProfileName: frient-contact-battery-temperature + - id: "frient A/S/WISZB-137" + deviceLabel: frient Vibration Sensor + manufacturer: frient A/S + model: WISZB-137 + deviceProfileName: acceleration-motion-temperature-battery - id: "Compacta/ZBWDS" deviceLabel: Smartenit Open/Closed Sensor manufacturer: Compacta @@ -194,6 +204,26 @@ zigbeeManufacturer: manufacturer: Third Reality, Inc model: 3RVS01031Z deviceProfileName: thirdreality-multi-sensor + - id: "SONOFF/SNZB-04P" + deviceLabel: SONOFF Contact Sensor + manufacturer: eWeLink + model: SNZB-04P + deviceProfileName: contact-battery-profile + - id: "SONOFF/SNZB-04PR2" + deviceLabel: SONOFF Contact Sensor + manufacturer: SONOFF + model: SNZB-04PR2 + deviceProfileName: contact-battery-profile + - id: "Aug. Winkhaus SE/FM.V.ZB" + deviceLabel: Funkkontakt FM.V.ZB + manufacturer: Aug. Winkhaus SE + model: FM.V.ZB + deviceProfileName: contact-battery-profile + - id: "MultIR/MIR-MC100" + deviceLabel: MultiIR Contact Sensor MIR-MC100 + manufacturer: MultIR + model: MIR-MC100 + deviceProfileName: contact-battery-tamper-no-fw-update zigbeeGeneric: - id: "contact-generic" deviceLabel: "Zigbee Contact Sensor" diff --git a/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-battery.yml b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-battery.yml new file mode 100644 index 0000000000..4f90f8a11e --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-battery.yml @@ -0,0 +1,50 @@ +name: acceleration-motion-temperature-battery +components: +- id: main + capabilities: + - id: accelerationSensor + version: 1 + - id: motionSensor + version: 1 + - id: threeAxis + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature sensitivity (°)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - title: "Sensitivity level" + name: sensitivityLevel + description: "How sensitivite the device is to vibrations" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 15 + default: 10 + - title: "Use with Contact Sensor" + name: garageSensor + required: false + preferenceType: enumeration + definition: + options: + "Yes": "Yes" + "No": "No" + default: "No" \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-contact-battery.yml b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-contact-battery.yml new file mode 100644 index 0000000000..c40eb49b9e --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-contact-battery.yml @@ -0,0 +1,89 @@ +name: acceleration-motion-temperature-contact-battery +components: +- id: main + capabilities: + - id: accelerationSensor + version: 1 + - id: motionSensor + version: 1 + - id: threeAxis + version: 1 + - id: contactSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature sensitivity (°)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - title: "Sensitivity level" + name: sensitivityLevel + description: "How sensitivite the device is to vibrations" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 15 + default: 10 + - title: "Use with Contact Sensor" + name: garageSensor + required: false + preferenceType: enumeration + definition: + options: + "Yes": "Yes" + "No": "No" + default: "Yes" + - title: "Axis to activate Contact Sensor" + name: contactSensorAxis + required: false + preferenceType: enumeration + definition: + options: + "X": "X" + "Y": "Y" + "Z": "Z" + default: "Z" + - title: "Initial position (closed state)" + name: sensorInitialPosition + description: "Initial position of the device in the chosen axis" + required: false + preferenceType: number + definition: + minimum: -2000 + maximum: 2000 + default: 0 + - title: "Contact Sensor threshold (open)" + name: contactSensorValue + description: "Value change required to trigger contact sensor" + required: false + preferenceType: number + definition: + minimum: 20 + maximum: 4000 + default: 900 + - title: "Measurement tolerance" + name: tolerance + description: "Set the tolerance in percentage of the threshold" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 20 + default: 0 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper-no-fw-update.yml b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper-no-fw-update.yml new file mode 100644 index 0000000000..528e6c0021 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper-no-fw-update.yml @@ -0,0 +1,14 @@ +name: contact-battery-tamper-no-fw-update +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: battery + version: 1 + - id: tamperAlert + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/zigbee-contact/profiles/contact-battery-temperature-tamper.yml b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-temperature-tamper.yml new file mode 100644 index 0000000000..a7025b938f --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-temperature-tamper.yml @@ -0,0 +1,30 @@ +name: contact-battery-temperature-tamper +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: battery + version: 1 + - id: temperatureMeasurement + version: 1 + - id: tamperAlert + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature Sensitivity (°C)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 diff --git a/drivers/SmartThings/zigbee-contact/profiles/contact-batteryLevel.yml b/drivers/SmartThings/zigbee-contact/profiles/contact-batteryLevel.yml new file mode 100644 index 0000000000..56a8adcd8c --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/contact-batteryLevel.yml @@ -0,0 +1,14 @@ +name: contact-batteryLevel +components: + - id: main + capabilities: + - id: contactSensor + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/zigbee-contact/profiles/frient-contact-battery-temperature.yml b/drivers/SmartThings/zigbee-contact/profiles/frient-contact-battery-temperature.yml new file mode 100644 index 0000000000..6eea0a0c2d --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/frient-contact-battery-temperature.yml @@ -0,0 +1,28 @@ +name: frient-contact-battery-temperature +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: battery + version: 1 + - id: temperatureMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature Sensitivity (°C)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 diff --git a/drivers/SmartThings/zigbee-contact/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-contact/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..4bda1794f0 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-MC100" } +} diff --git a/drivers/SmartThings/zigbee-contact/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-contact/src/MultiIR/init.lua new file mode 100644 index 0000000000..1481bb01fc --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/MultiIR/init.lua @@ -0,0 +1,52 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local IASZone = clusters.IASZone + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + device:emit_event(zone_status:is_alarm1_set() and capabilities.contactSensor.contact.open() or capabilities.contactSensor.contact.closed()) + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_attr_handler(driver, device, attr_val, zb_rx) + generate_event_from_zone_status(driver, device, attr_val, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +local function added_handler(driver, device) + device:emit_event(capabilities.battery.battery(100)) + device:emit_event(capabilities.contactSensor.contact.closed()) + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local MultiIR_sensor = { + NAME = "MultiIR Contact Sensor", + lifecycle_handlers = { + added = added_handler + }, + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler, + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler, + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/aqara/can_handle.lua new file mode 100644 index 0000000000..7877f2c180 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device, ...) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-contact/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..bcad0d779d --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.magnet.agl02" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/aqara/init.lua b/drivers/SmartThings/zigbee-contact/src/aqara/init.lua index 05e109ddd9..9093ee7bb9 100644 --- a/drivers/SmartThings/zigbee-contact/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/aqara/init.lua @@ -1,20 +1,29 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + 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 battery_defaults = require "st.zigbee.defaults.battery_defaults" +local OnOff = clusters.OnOff local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.magnet.agl02" } -} +local MFG_CODE = 0x115F +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local PRIVATE_HEART_BATTERY_ENERGY_ID = 0x00F7 + local CONFIGURATIONS = { { - cluster = IASZone.ID, - attribute = IASZone.attributes.ZoneStatus.ID, + cluster = OnOff.ID, + attribute = OnOff.attributes.OnOff.ID, minimum_interval = 30, maximum_interval = 3600, - data_type = IASZone.attributes.ZoneStatus.base_type, + data_type = OnOff.attributes.OnOff.base_type, reportable_change = 1 }, { @@ -27,30 +36,99 @@ local CONFIGURATIONS = { } } -local is_aqara_products = function(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) + device:remove_configured_attribute(IASZone.ID, IASZone.attributes.ZoneStatus.ID) + device:remove_monitored_attribute(IASZone.ID, IASZone.attributes.ZoneStatus.ID) + battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end +local function do_configure(self, device) + device:configure() + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) +end + +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +local function added_handler(driver, device) + device:emit_event(capabilities.batteryLevel.type("CR1632")) + device:emit_event(capabilities.batteryLevel.quantity(1)) + device:emit_event(capabilities.batteryLevel.battery("normal")) + emit_event_if_latest_state_missing(device, "main", capabilities.contactSensor, capabilities.contactSensor.contact.NAME, capabilities.contactSensor.contact.open()) +end + +local function contact_status_handler(self, device, value, zb_rx) + if value.value == 1 or value.value == true then + device:emit_event(capabilities.contactSensor.contact.open()) + elseif value.value == 0 or value.value == false then + device:emit_event(capabilities.contactSensor.contact.closed()) + end +end + +local function calc_battery_level(voltage) + local batteryLevel = "normal" + if voltage <= 25 then + batteryLevel = "critical" + elseif voltage < 28 then + batteryLevel = "warning" + end + + return batteryLevel +end + +local function battery_status_handler(driver, device, value, zb_rx) + device:emit_event(capabilities.batteryLevel.battery(calc_battery_level(value.value))) +end + +local function calc_batt_from_binary(bin_str, offset) + -- Read two bytes from the specified offset (little-endian format: low byte first) + local low = string.byte(bin_str, offset) + local high = string.byte(bin_str, offset + 1) + + -- Validate byte availability + if not low or not high then return 0 end + + -- Combine bytes into a 16-bit unsigned integer (millivolts) + local voltage = high * 256 + low + + return voltage +end + +local function battery_energy_status_handler(driver, device, value, zb_rx) + device:emit_event(capabilities.batteryLevel.battery(calc_battery_level(calc_batt_from_binary(value.value, 3)))) +end + local aqara_contact_handler = { NAME = "Aqara Contact Handler", + zigbee_handlers = { + attr = { + [PRIVATE_CLUSTER_ID] = { + [PRIVATE_HEART_BATTERY_ENERGY_ID] = battery_energy_status_handler + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryVoltage.ID] = battery_status_handler + }, + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = contact_status_handler + } + } + }, lifecycle_handlers = { - init = device_init + init = device_init, + doConfigure = do_configure, + added = added_handler, }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_contact_handler diff --git a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/can_handle.lua new file mode 100644 index 0000000000..ceb3f4d933 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aurora_contact(opts, driver, device, ...) + local FINGERPRINTS = require("aurora-contact-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aurora-contact-sensor") + end + end + return false +end + +return can_handle_aurora_contact diff --git a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/fingerprints.lua new file mode 100644 index 0000000000..022bbc83d5 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AURORA_CONTACT_FINGERPRINTS = { + { mfr = "Aurora", model = "DoorSensor50AU" }, -- Aurora Smart Door/Window Sensor + { mfr = "Aurora", model = "WindowSensor51AU" } -- Aurora Smart Door/Window Sensor +} + +return AURORA_CONTACT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua index 43002ec0ad..b5ac4fe8bb 100644 --- a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua @@ -1,26 +1,12 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local IASZone = clusters.IASZone -local AURORA_CONTACT_FINGERPRINTS = { - { mfr = "Aurora", model = "DoorSensor50AU" }, -- Aurora Smart Door/Window Sensor - { mfr = "Aurora", model = "WindowSensor51AU" } -- Aurora Smart Door/Window Sensor -} local AURORA_CONTACT_CONFIGURATION = { { @@ -33,21 +19,12 @@ local AURORA_CONTACT_CONFIGURATION = { } } -local function can_handle_aurora_contact(opts, driver, device, ...) - for _, fingerprint in ipairs(AURORA_CONTACT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.use_battery_voltage_handling(device) for _, attribute in ipairs(AURORA_CONTACT_CONFIGURATION) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -56,7 +33,7 @@ local aurora_contact = { lifecycle_handlers = { init = device_init }, - can_handle = can_handle_aurora_contact + can_handle = require("aurora-contact-sensor.can_handle"), } return aurora_contact diff --git a/drivers/SmartThings/zigbee-contact/src/configurations.lua b/drivers/SmartThings/zigbee-contact/src/configurations.lua index d2c5ca822d..668e8c5ac6 100644 --- a/drivers/SmartThings/zigbee-contact/src/configurations.lua +++ b/drivers/SmartThings/zigbee-contact/src/configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" @@ -37,7 +26,6 @@ local devices = { EWELINK_HEIMAN = { FINGERPRINTS = { { mfr = "eWeLink", model = "DS01" }, - { mfr = "eWeLink", model = "SNZB-04P" }, { mfr = "HEIMAN", model = "DoorSensor-N" } }, CONFIGURATION = { @@ -92,7 +80,7 @@ local devices = { { mfr = "Sercomm Corp.", model = "SZ-DWS04" }, { mfr = "DAWON_DNS", model = "SS-B100-ZB" }, { mfr = "frient A/S", model = "WISZB-120" }, - { mfr = "frient A/S", model = "WISZB-121" }, + { mfr = "frient A/S", model = "WISZB-131" }, { mfr = "Compacta", model = "ZBWDS" } }, CONFIGURATION = { @@ -113,6 +101,37 @@ local devices = { reportable_change = 100 } } + }, + FRIENT_CONTACT_SENSOR_WISZB_121 = { + FINGERPRINTS = { + { mfr = "frient A/S", model = "WISZB-121" } + }, + CONFIGURATION = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 30, + maximum_interval = 300, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1 + } + } + }, + FRIENT_VIBRATION_SENSOR_WISZB_137 = { + FINGERPRINTS = { + { mfr = "frient A/S", model = "WISZB-137" } + }, + CONFIGURATION = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 0, + maximum_interval = 3600, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1, + endpoint = 0x2D + } + } } } diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/can_handle.lua new file mode 100644 index 0000000000..2c9f359dbe --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_contact_temperature_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("contact-temperature-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("contact-temperature-sensor") + end + end + return false +end + +return can_handle_contact_temperature_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/can_handle.lua new file mode 100644 index 0000000000..b17c1efb21 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ecolink_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("contact-temperature-sensor.ecolink-contact.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("contact-temperature-sensor.ecolink-contact") + end + end + return false +end + +return can_handle_ecolink_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/fingerprints.lua new file mode 100644 index 0000000000..c86e6e073f --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS = { + { mfr = "Ecolink", model = "4655BC0-R" }, + { mfr = "Ecolink", model = "DWZB1-ECO" } +} + +return ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua index 22999c59aa..31dc67fd3e 100644 --- a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local device_management = require "st.zigbee.device_management" @@ -22,19 +12,7 @@ local SHORT_POLL_INTERVAL = 0x0200 local LONG_POLL_INTERVAL = 0xB1040000 local FAST_POLL_TIMEOUT = 0x0028 -local ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS = { - { mfr = "Ecolink", model = "4655BC0-R" }, - { mfr = "Ecolink", model = "DWZB1-ECO" } -} -local function can_handle_ecolink_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function do_configure(driver, device) device:configure() @@ -51,7 +29,7 @@ local ecolink_sensor = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_ecolink_sensor + can_handle = require("contact-temperature-sensor.ecolink-contact.can_handle"), } return ecolink_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/fingerprints.lua new file mode 100644 index 0000000000..4efc09684b --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/fingerprints.lua @@ -0,0 +1,24 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS = { + { mfr = "CentraLite", model = "3300-S" }, + { mfr = "CentraLite", model = "3300" }, + { mfr = "CentraLite", model = "3320-L" }, + { mfr = "CentraLite", model = "3323-G" }, + { mfr = "CentraLite", model = "Contact Sensor-A" }, + { mfr = "Visonic", model = "MCT-340 E" }, + { mfr = "Visonic", model = "MCT-340 SMA" }, + { mfr = "Ecolink", model = "4655BC0-R" }, + { mfr = "Ecolink", model = "DWZB1-ECO" }, + { mfr = "iMagic by GreatStar", model = "1116-S" }, + { mfr = "Bosch", model = "RFMS-ZBMS" }, + { mfr = "Megaman", model = "MS601/z1" }, + { mfr = "AduroSmart Eria", model = "CSW_ADUROLIGHT" }, + { mfr = "ADUROLIGHT", model = "CSW_ADUROLIGHT" }, + { mfr = "Sercomm Corp.", model = "SZ-DWS04" }, + { mfr = "DAWON_DNS", model = "SS-B100-ZB" }, + { mfr = "Compacta", model = "ZBWDS" } +} + +return CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/frient-sensor/init.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/frient-sensor/init.lua deleted file mode 100644 index cd4edd7216..0000000000 --- a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/frient-sensor/init.lua +++ /dev/null @@ -1,63 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local clusters = require "st.zigbee.zcl.clusters" -local battery_defaults = require "st.zigbee.defaults.battery_defaults" -local configurationMap = require "configurations" - -local TemperatureMeasurement = clusters.TemperatureMeasurement - -local FRIENT_CONTACT_TEMPERATURE_FINGERPRINTS = { - { mfr = "frient A/S", model = "WISZB-120" }, - { mfr = "frient A/S", model = "WISZB-121" } -} - -local function can_handle_frient_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(FRIENT_CONTACT_TEMPERATURE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - -local function device_init(driver, device) - local configuration = configurationMap.get_device_configuration(device) - - battery_defaults.build_linear_voltage_init(2.3, 3.0)(driver, device) - - if configuration ~= nil then - for _, attribute in ipairs(configuration) do - device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) - end - end -end - -local function do_configure(driver, device) - device:configure() - device:send(TemperatureMeasurement.server.attributes.MeasuredValue:configure_reporting(device, 30, 1800, 100):to_endpoint(0x26)) - device:refresh() -end - -local frient_sensor = { - NAME = "Frient Contact Temperature", - lifecycle_handlers = { - init = device_init, - doConfigure = do_configure - }, - can_handle = can_handle_frient_sensor -} - -return frient_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua index 87ee88c5f3..76f822fedd 100644 --- a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua @@ -1,50 +1,12 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local configurationMap = require "configurations" -local CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS = { - { mfr = "CentraLite", model = "3300-S" }, - { mfr = "CentraLite", model = "3300" }, - { mfr = "CentraLite", model = "3320-L" }, - { mfr = "CentraLite", model = "3323-G" }, - { mfr = "CentraLite", model = "Contact Sensor-A" }, - { mfr = "Visonic", model = "MCT-340 E" }, - { mfr = "Visonic", model = "MCT-340 SMA" }, - { mfr = "Ecolink", model = "4655BC0-R" }, - { mfr = "Ecolink", model = "DWZB1-ECO" }, - { mfr = "iMagic by GreatStar", model = "1116-S" }, - { mfr = "Bosch", model = "RFMS-ZBMS" }, - { mfr = "Megaman", model = "MS601/z1" }, - { mfr = "AduroSmart Eria", model = "CSW_ADUROLIGHT" }, - { mfr = "ADUROLIGHT", model = "CSW_ADUROLIGHT" }, - { mfr = "Sercomm Corp.", model = "SZ-DWS04" }, - { mfr = "DAWON_DNS", model = "SS-B100-ZB" }, - { mfr = "frient A/S", model = "WISZB-120" }, - { mfr = "frient A/S", model = "WISZB-121" }, - { mfr = "Compacta", model = "ZBWDS" } -} -local function can_handle_contact_temperature_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) @@ -54,7 +16,6 @@ local function device_init(driver, device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end @@ -64,11 +25,8 @@ local contact_temperature_sensor = { lifecycle_handlers = { init = device_init }, - sub_drivers = { - require("contact-temperature-sensor/ecolink-contact"), - require("contact-temperature-sensor/frient-sensor") - }, - can_handle = can_handle_contact_temperature_sensor + sub_drivers = require("contact-temperature-sensor.sub_drivers"), + can_handle = require("contact-temperature-sensor.can_handle"), } return contact_temperature_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/sub_drivers.lua new file mode 100644 index 0000000000..d47c4ad123 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("contact-temperature-sensor.ecolink-contact"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/frient/can_handle.lua new file mode 100644 index 0000000000..e2d56c3dc0 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if (device:get_manufacturer() == "frient A/S") and + (device:get_model() == "WISZB-120" or + device:get_model() == "WISZB-121" or + device:get_model() == "WISZB-131" or + device:get_model() == "WISZB-137") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/can_handle.lua new file mode 100644 index 0000000000..14ecae440a --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_DEVICE_FINGERPRINTS = { + { mfr = "frient A/S", model = "WISZB-137", }, +} + +return function(opts, driver, device, ...) + for _, fingerprint in ipairs(FRIENT_DEVICE_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("frient.frient-vibration") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/init.lua b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/init.lua new file mode 100644 index 0000000000..bba3dc0bdf --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/init.lua @@ -0,0 +1,212 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local zcl_commands = require "st.zigbee.zcl.global_commands" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local device_management = require "st.zigbee.device_management" +local data_types = require "st.zigbee.data_types" +local threeAxis = capabilities.threeAxis + +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement +local IASZone = zcl_clusters.IASZone +local PowerConfiguration = zcl_clusters.PowerConfiguration +local POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT = 0x2D +local TEMPERATURE_ENDPOINT = 0x26 + +local Frient_AccelerationMeasurementCluster = { + ID = 0xFC04, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValueX = { ID = 0x0000, data_type = data_types.Int16 }, + MeasuredValueY = { ID = 0x0001, data_type = data_types.Int16 }, + MeasuredValueZ = { ID = 0x0002, data_type = data_types.Int16 } + }, +} + +local function acceleration_measure_report_handler(driver, device, zb_rx) + local measured_x, measured_y, measured_z + + for _, attribute_record in ipairs(zb_rx.body.zcl_body.attr_records) do + local attribute_id = attribute_record.attr_id.value + local axis_value = attribute_record.data.value + + if attribute_id == Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID then + measured_x = axis_value + elseif attribute_id == Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID then + measured_y = axis_value + elseif attribute_id == Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID then + measured_z = axis_value + end + end + + if measured_x and measured_y and measured_z then + device:emit_event(threeAxis.threeAxis({measured_x, measured_y, measured_z})) + + if device:supports_capability(capabilities.contactSensor) then + local garageAxis = measured_x + if device.preferences.contactSensorAxis == "Y" then + garageAxis = measured_y + elseif device.preferences.contactSensorAxis == "Z" then + garageAxis = measured_z + end + local initial_position = device.preferences.sensorInitialPosition or 0 + if math.abs(initial_position - garageAxis) >= device.preferences.contactSensorValue - device.preferences.contactSensorValue * (device.preferences.tolerance / 100) then + device:emit_event(capabilities.contactSensor.contact.open()) + else + device:emit_event(capabilities.contactSensor.contact.closed()) + end + end + end +end + +local function get_cluster_configurations() + return { + { + cluster = Frient_AccelerationMeasurementCluster.ID, + attribute = Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, + minimum_interval = 0, + maximum_interval = 300, + reportable_change = 0x0001, + data_type = data_types.Int16, + mfg_code = Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + }, + { + cluster = Frient_AccelerationMeasurementCluster.ID, + attribute = Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, + minimum_interval = 0, + maximum_interval = 300, + reportable_change = 0x0001, + data_type = data_types.Int16, + mfg_code = Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + }, + { + cluster = Frient_AccelerationMeasurementCluster.ID, + attribute = Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, + minimum_interval = 0, + maximum_interval = 300, + reportable_change = 0x0001, + data_type = data_types.Int16, + mfg_code = Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + } + } +end + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + device:emit_event(zone_status:is_alarm1_set() and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) + device:emit_event(zone_status:is_alarm2_set() and capabilities.accelerationSensor.acceleration.active() or capabilities.accelerationSensor.acceleration.inactive()) +end + +local function ias_zone_status_attr_handler(driver, device, attr_val, zb_rx) + generate_event_from_zone_status(driver, device, attr_val, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(2.3, 3.0)(driver, device) + --Add the manufacturer-specific attributes to generate their configure reporting and bind requests + for _, config in pairs(get_cluster_configurations()) do + device:add_configured_attribute(config) + end +end + +local function do_refresh(driver, device) + device:send(IASZone.attributes.ZoneStatus:read(device):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_AccelerationMeasurementCluster.ID, Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_AccelerationMeasurementCluster.ID, Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_AccelerationMeasurementCluster.ID, Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(TemperatureMeasurement.attributes.MeasuredValue:read(device):to_endpoint(TEMPERATURE_ENDPOINT)) + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) +end + +local function do_configure(driver, device, event, args) + device:configure() + + device:send(device_management.build_bind_request(device, zcl_clusters.IASZone.ID, driver.environment_info.hub_zigbee_eui, POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(IASZone.attributes.ZoneStatus:configure_reporting(device, 0, 1*60*60, 1):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(device_management.build_bind_request(device, Frient_AccelerationMeasurementCluster.ID, driver.environment_info.hub_zigbee_eui, POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + + local sensitivityLevel = device.preferences.sensitivityLevel or 10 + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:write(device, sensitivityLevel):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + + local sensitivity = math.floor((device.preferences.temperatureSensitivity or 0.1) * 100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 1 * 60 * 60, sensitivity):to_endpoint(TEMPERATURE_ENDPOINT)) + + device.thread:call_with_delay(5, function() + device:refresh() + end) +end + +local function info_changed(driver, device, event, args) + if args and args.old_st_store then + if args.old_st_store.preferences.sensitivityLevel ~= device.preferences.sensitivityLevel then + local sensitivityLevel = device.preferences.sensitivityLevel or 10 + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:write(device, sensitivityLevel):to_endpoint(0x2D)) + end + if args.old_st_store.preferences.temperatureSensitivity ~= device.preferences.temperatureSensitivity then + local sensitivity = math.floor((device.preferences.temperatureSensitivity or 0.1)*100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 1*60*60, sensitivity):to_endpoint(0x26)) + end + if args.old_st_store.preferences.garageSensor ~= device.preferences.garageSensor then + if device.preferences.garageSensor == "Yes" then + device:try_update_metadata({profile = "acceleration-motion-temperature-contact-battery"}) + elseif device.preferences.garageSensor == "No" then + device:try_update_metadata({profile = "acceleration-motion-temperature-battery"}) + end + end + device.thread:call_with_delay(5, function() + device:refresh() + end) + end +end + +local frient_vibration_driver_template = { + NAME = "frient vibration driver", + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed + }, + zigbee_handlers = { + global = { + [Frient_AccelerationMeasurementCluster.ID] = { + [zcl_commands.ReportAttribute.ID] = acceleration_measure_report_handler, + [zcl_commands.ReadAttributeResponse.ID] = acceleration_measure_report_handler + } + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + can_handle = require ("frient.frient-vibration.can_handle") +} + +return frient_vibration_driver_template diff --git a/drivers/SmartThings/zigbee-contact/src/frient/init.lua b/drivers/SmartThings/zigbee-contact/src/frient/init.lua new file mode 100644 index 0000000000..f28cad8cdb --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/init.lua @@ -0,0 +1,89 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local configurationMap = require "configurations" +local capabilities = require "st.capabilities" + +local IASZone = clusters.IASZone +local TemperatureMeasurement = clusters.TemperatureMeasurement +local TEMPERATURE_ENDPOINT = 0x26 + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + device:emit_event(zone_status:is_alarm1_set() and capabilities.contactSensor.contact.open() or capabilities.contactSensor.contact.closed()) + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_attr_handler(driver, device, attr_val, zb_rx) + generate_event_from_zone_status(driver, device, attr_val, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +local function device_init(driver, device) + local configuration = configurationMap.get_device_configuration(device) + + battery_defaults.build_linear_voltage_init(2.3, 3.0)(driver, device) + + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end + end +end + +local function added_handler(driver, device) + if device:supports_capability_by_id(capabilities.temperatureMeasurement.ID) then + device:send(TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) + device:send(TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) + end +end + +local function do_configure(driver, device) + device:configure() + device:refresh() +end + +local function info_changed(driver, device, event, args) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "temperatureSensitivity") then + local input = device.preferences.temperatureSensitivity + local temperatureSensitivity = math.floor(input * 100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 1800, temperatureSensitivity):to_endpoint(TEMPERATURE_ENDPOINT)) + end + end + end +end + +local frient_sensor = { + NAME = "Frient Contact Temperature Tamper", + lifecycle_handlers = { + init = device_init, + added = added_handler, + doConfigure = do_configure, + infoChanged = info_changed + }, + sub_drivers = require("frient.sub_drivers"), + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler, + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler, + } + } + }, + can_handle = require("frient.can_handle"), +} + +return frient_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/frient/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/frient/sub_drivers.lua new file mode 100644 index 0000000000..cfaca8fca3 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient.frient-vibration"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/init.lua b/drivers/SmartThings/zigbee-contact/src/init.lua index 7a1dc6b237..0258d5360a 100644 --- a/drivers/SmartThings/zigbee-contact/src/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/init.lua @@ -1,34 +1,69 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local constants = require "st.zigbee.constants" local defaults = require "st.zigbee.defaults" +local TemperatureMeasurement = (require "st.zigbee.zcl.clusters").TemperatureMeasurement local configurationMap = require "configurations" local SMARTSENSE_MULTI_SENSOR_CUSTOM_PROFILE = 0xFC01 +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + +--- Default handler for Temperature min and max measured value on the Temperature measurement cluster +--- +--- This starts initially by performing the same conversion in the temperature_measurement_attr_handler function. +--- It then sets the field of whichever measured value is defined by the @param and checks if the fields +--- correctly compare +--- +--- @param minOrMax string the string that determines which attribute to set +--- @param driver Driver The current driver running containing necessary context for execution +--- @param device ZigbeeDevice The device this message was received from containing identifying information +--- @param value Int16 the value of the measured temperature +--- @param zb_rx containing the full message this report came in + +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end +local function added_handler(driver, device) + device:send(TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) + device:send(TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) +end + local zigbee_contact_driver_template = { supported_capabilities = { capabilities.contactSensor, @@ -37,23 +72,28 @@ local zigbee_contact_driver_template = { capabilities.threeAxis, capabilities.accelerationSensor }, + zigbee_handlers = { + attr = { + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), + } + } + }, additional_zcl_profiles = { [SMARTSENSE_MULTI_SENSOR_CUSTOM_PROFILE] = true }, lifecycle_handlers = { - init = device_init - }, - sub_drivers = { - require("aqara"), - require("aurora-contact-sensor"), - require("contact-temperature-sensor"), - require("multi-sensor"), - require("smartsense-multi"), - require("sengled") + init = device_init, + added = added_handler, }, - ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE + sub_drivers = require("sub_drivers"), + ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, + health_check = false, + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(zigbee_contact_driver_template, zigbee_contact_driver_template.supported_capabilities) +defaults.register_for_default_handlers(zigbee_contact_driver_template, + zigbee_contact_driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) local zigbee_contact = ZigbeeDriver("zigbee_contact", zigbee_contact_driver_template) zigbee_contact:run() diff --git a/drivers/SmartThings/zigbee-contact/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-contact/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/can_handle.lua new file mode 100644 index 0000000000..0065632f88 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zigbee_multi_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("multi-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("multi-sensor") + end + end + return false +end + +return can_handle_zigbee_multi_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/can_handle.lua new file mode 100644 index 0000000000..94e73b1691 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function centralite_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "CentraLite" then + return true, require("multi-sensor.centralite-multi") + end + return false +end + +return centralite_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua index 1e75aa6b0d..0f7b7b7f25 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local multi_utils = require "multi-sensor/multi_utils" @@ -67,9 +57,7 @@ local centralite_handler = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "CentraLite" - end + can_handle = require("multi-sensor.centralite-multi.can_handle"), } return centralite_handler diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/fingerprints.lua new file mode 100644 index 0000000000..7abfe772b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MULTI_SENSOR_FINGERPRINTS = { + { mfr = "CentraLite", model = "3320" }, + { mfr = "CentraLite", model = "3321" }, + { mfr = "CentraLite", model = "3321-S" }, + { mfr = "SmartThings", model = "multiv4" }, + { mfr = "Samjin", model = "multi" }, + { mfr = "Third Reality, Inc", model = "3RVS01031Z" } +} + +return MULTI_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua index a440156c11..4cc8f88efa 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local zcl_commands = require "st.zigbee.zcl.global_commands" local multi_utils = require "multi-sensor/multi_utils" @@ -18,23 +9,7 @@ local zcl_clusters = require "st.zigbee.zcl.clusters" local contactSensor_defaults = require "st.zigbee.defaults.contactSensor_defaults" local capabilities = require "st.capabilities" -local MULTI_SENSOR_FINGERPRINTS = { - { mfr = "CentraLite", model = "3320" }, - { mfr = "CentraLite", model = "3321" }, - { mfr = "CentraLite", model = "3321-S" }, - { mfr = "SmartThings", model = "multiv4" }, - { mfr = "Samjin", model = "multi" }, - { mfr = "Third Reality, Inc", model = "3RVS01031Z" } -} -local function can_handle_zigbee_multi_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(MULTI_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function multi_sensor_report_handler(driver, device, zb_rx) local x, y, z @@ -98,12 +73,7 @@ local multi_sensor = { } } }, - sub_drivers = { - require("multi-sensor/smartthings-multi"), - require("multi-sensor/samjin-multi"), - require("multi-sensor/centralite-multi"), - require("multi-sensor/thirdreality-multi") - }, - can_handle = can_handle_zigbee_multi_sensor + sub_drivers = require("multi-sensor.sub_drivers"), + can_handle = require("multi-sensor.can_handle"), } return multi_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua index e24ec1097b..b1b3e18be4 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cluster_base = require "st.zigbee.cluster_base" @@ -152,4 +141,4 @@ multi_utils.convert_to_signedInt16 = function(byte1, byte2) end -return multi_utils \ No newline at end of file +return multi_utils diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/can_handle.lua new file mode 100644 index 0000000000..e524845f40 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function samjin_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Samjin" then + return true, require("multi-sensor.samjin-multi") + end + return false +end + +return samjin_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua index 935d1cfc20..3bc3edbcf9 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local data_types = require "st.zigbee.data_types" @@ -54,9 +44,7 @@ local samjin_driver = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Samjin" - end + can_handle = require("multi-sensor.samjin-multi.can_handle"), } return samjin_driver diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/can_handle.lua new file mode 100644 index 0000000000..6c99521efc --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function smartthings_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "SmartThings" then + return true, require("multi-sensor.smartthings-multi") + end + return false +end + +return smartthings_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua index 69d0b7f3f4..24234ef6de 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local zcl_commands = require "st.zigbee.zcl.global_commands" @@ -91,9 +81,7 @@ local smartthings_multi = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "SmartThings" - end + can_handle = require("multi-sensor.smartthings-multi.can_handle"), } return smartthings_multi diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/sub_drivers.lua new file mode 100644 index 0000000000..3769ab89d1 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("multi-sensor/smartthings-multi"), + lazy_load_if_possible("multi-sensor/samjin-multi"), + lazy_load_if_possible("multi-sensor/centralite-multi"), + lazy_load_if_possible("multi-sensor/thirdreality-multi"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/can_handle.lua new file mode 100644 index 0000000000..bdfc4f75b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function thirdreality_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Third Reality, Inc" then + return true, require("multi-sensor.thirdreality-multi") + end + return false +end + +return thirdreality_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua index c63596cab5..1be7988fac 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_commands = require "st.zigbee.zcl.global_commands" local multi_utils = require "multi-sensor/multi_utils" @@ -47,9 +37,7 @@ local thirdreality_multi = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Third Reality, Inc" - end + can_handle = require("multi-sensor.thirdreality-multi.can_handle"), } return thirdreality_multi diff --git a/drivers/SmartThings/zigbee-contact/src/sengled/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/sengled/can_handle.lua new file mode 100644 index 0000000000..68d774fb8f --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/sengled/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sengled_products = function(opts, driver, device, ...) + local FINGERPRINTS = require("sengled.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sengled") + end + end + return false +end + +return is_sengled_products diff --git a/drivers/SmartThings/zigbee-contact/src/sengled/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/sengled/fingerprints.lua new file mode 100644 index 0000000000..4d015324b3 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/sengled/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "sengled", model = "E2D-G73" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/sengled/init.lua b/drivers/SmartThings/zigbee-contact/src/sengled/init.lua index afe1f29770..6a4b4c6814 100644 --- a/drivers/SmartThings/zigbee-contact/src/sengled/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/sengled/init.lua @@ -1,12 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "sengled", model = "E2D-G73" } -} local CONFIGURATIONS = { { @@ -27,21 +27,12 @@ local CONFIGURATIONS = { } } -local is_sengled_products = function(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.1, 3.0)(driver, device) for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -50,7 +41,7 @@ local sengled_contact_handler = { lifecycle_handlers = { init = device_init }, - can_handle = is_sengled_products + can_handle = require("sengled.can_handle"), } return sengled_contact_handler diff --git a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/can_handle.lua new file mode 100644 index 0000000000..f5c226cc55 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device, ...) + local SMARTSENSE_PROFILE_ID = 0xFC01 + local FINGERPRINTS = require("smartsense-multi.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("smartsense-multi") + end + end + local endpoint = device.zigbee_endpoints[1] or device.zigbee_endpoints["1"] + if endpoint.profile_id == SMARTSENSE_PROFILE_ID then return true end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/fingerprints.lua new file mode 100644 index 0000000000..fff1b601a3 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SMARTSENSE_MULTI_FINGERPRINTS = { + { mfr = "SmartThings", model = "PGC313" }, + { mfr = "SmartThings", model = "PGC313EU" } +} + +return SMARTSENSE_MULTI_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua index 92fa777b7c..d5030278e8 100644 --- a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local multi_utils = require "multi-sensor/multi_utils" @@ -24,22 +13,6 @@ local SMARTSENSE_MULTI_ACC_CMD = 0x00 local SMARTSENSE_MULTI_XYZ_CMD = 0x05 local SMARTSENSE_MULTI_STATUS_CMD = 0x07 local SMARTSENSE_MULTI_STATUS_REPORT_CMD = 0x09 -local SMARTSENSE_PROFILE_ID = 0xFC01 - -local SMARTSENSE_MULTI_FINGERPRINTS = { - { mfr = "SmartThings", model = "PGC313" }, - { mfr = "SmartThings", model = "PGC313EU" } -} - -local function can_handle(opts, driver, device, ...) - for _, fingerprint in ipairs(SMARTSENSE_MULTI_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - if device.zigbee_endpoints[1].profileId == SMARTSENSE_PROFILE_ID then return true end - return false -end local function acceleration_handler(driver, device, zb_rx) -- This is a custom cluster command for the kickstarter multi. @@ -181,7 +154,7 @@ local smartsense_multi = { } } }, - can_handle = can_handle + can_handle = require("smartsense-multi.can_handle"), } return smartsense_multi diff --git a/drivers/SmartThings/zigbee-contact/src/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/sub_drivers.lua new file mode 100644 index 0000000000..8a4977ca5e --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/sub_drivers.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("aurora-contact-sensor"), + lazy_load_if_possible("contact-temperature-sensor"), + lazy_load_if_possible("multi-sensor"), + lazy_load_if_possible("smartsense-multi"), + lazy_load_if_possible("sengled"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("MultiIR"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua index 41e9ebce57..8f6c5ecfdd 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua @@ -1,38 +1,32 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" -local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local capabilities = require "st.capabilities" + +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" -local IASZone = clusters.IASZone +local OnOff = clusters.OnOff local PowerConfiguration = clusters.PowerConfiguration -local IASCIEAddress = IASZone.attributes.IASCIEAddress -local EnrollResponseCode = IASZone.types.EnrollResponseCode + +local MFG_CODE = 0x115F +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local PRIVATE_HEART_BATTERY_ENERGY_ID = 0x00F7 local mock_device = test.mock_device.build_test_zigbee_device( { - profile = t_utils.get_profile_definition("contact-battery-profile.yml"), + profile = t_utils.get_profile_definition("contact-batteryLevel.yml"), zigbee_endpoints = { [1] = { id = 1, manufacturer = "LUMI", model = "lumi.magnet.agl02", - server_clusters = { 0x0000, 0x0001, 0x0003, 0x0500 } + server_clusters = { PRIVATE_CLUSTER_ID, PowerConfiguration.ID, OnOff.ID } } } } @@ -47,71 +41,183 @@ end test.set_test_init_function(test_init) test.register_coroutine_test( - "Configure should configure all necessary attributes", + "doConfigure lifecycle handler", function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASZone.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) - }) - + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) }) test.socket.zigbee:__expect_send( { mock_device.id, - IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 30, 3600, 1) + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 3600, 1) } ) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, OnOff.ID) }) test.socket.zigbee:__expect_send( { mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 3600, 1) + OnOff.attributes.OnOff:configure_reporting(mock_device, 30, 3600, 1) } ) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE + , data_types.Uint8, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) - test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) }) - test.socket.zigbee:__expect_send({ mock_device.id, IASZone.server.commands.ZoneEnrollResponse(mock_device, EnrollResponseCode.SUCCESS, 0x00) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) +test.register_coroutine_test( + "added lifecycle handler", + function() + -- The initial contactSensor event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.type("CR1632"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.quantity(1))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("normal"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.contactSensor.contact.open())) + test.wait_for_events() + -- Avoid sending the initial contactSensor event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.type("CR1632"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.quantity(1))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("normal"))) + end, + { + min_api_version = 17 + } +) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end +test.register_coroutine_test( + "heartbeat battery events - normal status", + function() + local attr_report_data = { + { PRIVATE_HEART_BATTERY_ENERGY_ID, data_types.OctetString.ID, "\x01\x21\x44\x0C\x03\x28\x19\x04\x21\xA8\x13\x05\x21\x8E\x00\x06\x24\x04\x00\x00\x00\x00\x08\x21\x1E\x01\x0A\x21\x00\x00\x0C\x20\x01\x64\x10\x01\x66\x20\x03\x67\x20\x01\x68\x21\xA8\x00"} + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("normal"))) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "closed contact events", + "heartbeat battery events - critical status", function() local attr_report_data = { - { IASZone.attributes.ZoneStatus.ID, data_types.Bitmap16.ID, 0x0020 } + { PRIVATE_HEART_BATTERY_ENERGY_ID, data_types.OctetString.ID, "\x01\x21\x00\x00\x03\x28\x19\x04\x21\xA8\x13\x05\x21\x8E\x00\x06\x24\x04\x00\x00\x00\x00\x08\x21\x1E\x01\x0A\x21\x00\x00\x0C\x20\x01\x64\x10\x01\x66\x20\x03\x67\x20\x01\x68\x21\xA8\x00"} } test.socket.zigbee:__queue_receive({ mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, IASZone.ID, attr_report_data, 0x115F) + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) - end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("critical"))) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "open contact events", + "battery status events - normal status", function() local attr_report_data = { - { IASZone.attributes.ZoneStatus.ID, data_types.Bitmap16.ID, 0x0021} + {PowerConfiguration.attributes.BatteryVoltage.ID, data_types.Uint8.ID, 0x1C } } test.socket.zigbee:__queue_receive({ mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, IASZone.ID, attr_report_data, 0x115F) + zigbee_test_utils.build_attribute_report(mock_device, PowerConfiguration.ID, attr_report_data, MFG_CODE) }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("normal"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "battery status events - warning status", + function() + local attr_report_data = { + {PowerConfiguration.attributes.BatteryVoltage.ID, data_types.Uint8.ID, 0x1A } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PowerConfiguration.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("warning"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "battery status events - critical status", + function() + local attr_report_data = { + {PowerConfiguration.attributes.BatteryVoltage.ID, data_types.Uint8.ID, 0x9 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PowerConfiguration.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("critical"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "closed contact events - OnOff", + function() + local attr_report_data = { + { OnOff.attributes.OnOff.ID, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, OnOff.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.contactSensor.contact.closed())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "open contact events - OnOff", + function() + local attr_report_data = { + { OnOff.attributes.OnOff.ID, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, OnOff.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.contactSensor.contact.open())) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua index 1f36536d4e..a2bcfdf612 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +8,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local IASCIEAddress = IASZone.attributes.IASCIEAddress local EnrollResponseCode = IASZone.types.EnrollResponseCode @@ -52,13 +42,24 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -92,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua index e3628f6ec7..b13d48fc2e 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -117,7 +106,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, 100})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -140,7 +132,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, acceleration_report_inactive, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -173,7 +168,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -197,7 +195,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, 0xFC02, {0x0010}, 0x104E) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -218,7 +219,10 @@ test.register_coroutine_test( mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -276,7 +280,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -298,7 +305,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -321,7 +331,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device_old_firmware:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua index bc139a0904..62e0eee71b 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -55,13 +44,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -106,7 +106,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -122,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -138,6 +144,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua b/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua index f254c108f9..e092666737 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,13 +45,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -115,7 +115,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PollControl.commands.SetLongPollInterval(mock_device, 0xB1040000) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -131,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -147,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua index 3032ff3fc4..f3b94929b6 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +8,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local IASCIEAddress = IASZone.attributes.IASCIEAddress local EnrollResponseCode = IASZone.types.EnrollResponseCode @@ -53,13 +43,24 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -93,7 +94,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua new file mode 100644 index 0000000000..8be3dd430a --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua @@ -0,0 +1,290 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local IASZONE_ENDPOINT = 0x23 + +local base64 = require "base64" +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("contact-battery-profile.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "WISZB-121", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x000f, 0x0020, 0x0500 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 0 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device):to_endpoint(IASZONE_ENDPOINT) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua new file mode 100644 index 0000000000..f6a2e0ebd9 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua @@ -0,0 +1,433 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local IASZONE_ENDPOINT = 0x23 +local TEMPERATURE_MEASUREMENT_ENDPOINT = 0x26 + +local base64 = require "base64" +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("frient-contact-battery-temperature.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "WISZB-131", + server_clusters = { 0x0005, 0x0006 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x000f, 0x0020, 0x0500 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0000, 0x0003, 0x0402 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + test.wait_for_events() + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_MEASUREMENT_ENDPOINT + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 1800, + 100 + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 0 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device):to_endpoint(IASZONE_ENDPOINT) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval +-- test.socket.zigbee:__set_channel_ordering("relaxed") + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryVoltage:read(mock_device) +-- } +-- ) + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) +-- } +-- ) + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- IASZone.attributes.ZoneStatus:read(mock_device) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: Temperature Sensitivity", + function() + local updates = { + preferences = { + temperatureSensitivity = 0.9 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + local temperatureSensitivity = math.floor(0.9 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 1800, + temperatureSensitivity + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua new file mode 100644 index 0000000000..66881f9f0b --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua @@ -0,0 +1,453 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local IASZONE_ENDPOINT = 0x23 +local TEMPERATURE_MEASUREMENT_ENDPOINT = 0x26 + +local base64 = require "base64" +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("contact-battery-temperature-tamper.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "WISZB-120", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x000f, 0x0020, 0x0500 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0000, 0x0003, 0x0402 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + test.wait_for_events() + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_MEASUREMENT_ENDPOINT + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 1800, + 100 + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 0 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device):to_endpoint(IASZONE_ENDPOINT) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval +-- test.socket.zigbee:__set_channel_ordering("relaxed") + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryVoltage:read(mock_device) +-- } +-- ) + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) +-- } +-- ) + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- IASZone.attributes.ZoneStatus:read(mock_device) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/closed, tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/open, tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/open, tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/closed, tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: Temperature Sensitivity", + function() + local updates = { + preferences = { + temperatureSensitivity = 0.9 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + local temperatureSensitivity = math.floor(0.9 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 1800, + temperatureSensitivity + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_sensor.lua deleted file mode 100644 index db72c3a5c2..0000000000 --- a/drivers/SmartThings/zigbee-contact/src/test/test_frient_sensor.lua +++ /dev/null @@ -1,150 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local test = require "integration_test" -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local IASZone = clusters.IASZone -local PowerConfiguration = clusters.PowerConfiguration -local TemperatureMeasurement = clusters.TemperatureMeasurement - -local IASCIEAddress = IASZone.attributes.IASCIEAddress -local EnrollResponseCode = IASZone.types.EnrollResponseCode - -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("contact-battery-temperature.yml"), - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "frient A/S", - model = "WISZB-120", - server_clusters = { 0x0001, 0x0402, 0x0500 } - } - } - } -) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init() - test.mock_device.add_test_device(mock_device) -end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "Refresh necessary attributes", - function() - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) - test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end -) - -test.register_coroutine_test( - "Configure should configure all necessary attributes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASZone.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, TemperatureMeasurement.ID) - }) - - test.socket.zigbee:__expect_send( - { - mock_device.id, - IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 30, 300, 1) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 1800, 100) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 1800, 100):to_endpoint(0x26) - } - ) - - test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) }) - test.socket.zigbee:__expect_send({ mock_device.id, IASZone.server.commands.ZoneEnrollResponse(mock_device, EnrollResponseCode.SUCCESS, 0x00) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end -) - -test.register_message_test( - "Max battery voltage report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) - } - } -) - -test.register_message_test( - "Min battery voltage report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_vibration_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_vibration_sensor.lua new file mode 100644 index 0000000000..7829e32736 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_vibration_sensor.lua @@ -0,0 +1,541 @@ + +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT = 0x2D +local TEMPERATURE_ENDPOINT = 0x26 + +local base64 = require "base64" +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("acceleration-motion-temperature-battery.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "WISZB-137", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x2D] = { + id = 0x2D, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x0020, 0x0500, 0xFC04 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0402 } + } + } + } +) + +local mock_device_contact = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("acceleration-motion-temperature-contact-battery.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "WISZB-137", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x2D] = { + id = 0x2D, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x0020, 0x0500, 0xFC04 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0402 } + } + } + } +) + +local Frient_AccelerationMeasurementCluster = { + ID = 0xFC04, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValueX = { ID = 0x0000, data_type = data_types.name_to_id_map["Int16"] }, + MeasuredValueY = { ID = 0x0001, data_type = data_types.name_to_id_map["Int16"] }, + MeasuredValueZ = { ID = 0x0002, data_type = data_types.name_to_id_map["Int16"] } + }, +} + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_contact) +end + +test.set_test_init_function(test_init) + +local function custom_configure_reporting(device, cluster, attribute, data_type, min_interval, max_interval, reportable_change, mfg_code) + local message = cluster_base.configure_reporting(device, + data_types.ClusterId(cluster), + data_types.AttributeId(attribute), + data_type, + min_interval, + max_interval, + reportable_change) + + -- Set the manufacturer-specific bit and add the manufacturer code + message.body.zcl_header.frame_ctrl:set_mfg_specific() + message.body.zcl_header.mfg_code = data_types.validate_or_build_type(mfg_code, data_types.Uint16, "mfg_code") + + return message +end + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device_contact.id, "init" }) + + test.wait_for_events() + + --test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_contact.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device_contact, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_ENDPOINT + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device_contact, + 0x001E, + 0x012C, + 1 + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device_contact, + 30, + 600, + 100 + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.IASCIEAddress:write( + mock_device_contact, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + Frient_AccelerationMeasurementCluster.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + custom_configure_reporting( + mock_device_contact, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.data_type, + 0x0000, + 0x012C, + 0x0001, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + custom_configure_reporting( + mock_device_contact, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.data_type, + 0x0000, + 0x012C, + 0x0001, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + custom_configure_reporting( + mock_device_contact, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.data_type, + 0x0000, + 0x012C, + 0x0001, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device_contact, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ) + }) + + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write( + mock_device_contact, + 0x000A + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device_contact, + 0x001E, + 0x0E10, + 100 + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device_contact, + 0, + 3600, + 0 + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + Frient_AccelerationMeasurementCluster.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ) + }) + + mock_device_contact:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature measurement cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2300)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 23.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { "register_native_capability_attr_handler", + { + device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" + } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery min voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery max voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( +"Refresh necessary attributes", +function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) }) + test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_ENDPOINT) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) +end, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Reported ZoneStatus change should be handled: active motion and inactive acceleration", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active(mock_device)) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive(mock_device)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported ZoneStatus change should be handled: inactive motion and active acceleration", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0002)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive(mock_device)) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active(mock_device)) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Three Axis report should be correctly handled", + function() + local attr_report_data = { + { 0x0000, data_types.Int16.ID, 300}, + { 0x0001, data_types.Int16.ID, 200}, + { 0x0002, data_types.Int16.ID, 100}, + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Frient_AccelerationMeasurementCluster.ID, attr_report_data, 0x1015) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, 100})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Contact sensor open events should be correctly handled when preference is set", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + local attr_report_data = { + { 0x0000, data_types.Int16.ID, 300}, + { 0x0001, data_types.Int16.ID, 200}, + { 0x0002, data_types.Int16.ID, -902}, + } + test.socket.zigbee:__queue_receive({ + mock_device_contact.id, + zigbee_test_utils.build_attribute_report(mock_device_contact, Frient_AccelerationMeasurementCluster.ID, attr_report_data, 0x1015) + }) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, -902})) + ) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.contactSensor.contact.open()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Contact sensor close events should be correctly handled when preference is set", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + local attr_report_data = { + { 0x0000, data_types.Int16.ID, 300}, + { 0x0001, data_types.Int16.ID, 200}, + { 0x0002, data_types.Int16.ID, 100}, + } + test.socket.zigbee:__queue_receive({ + mock_device_contact.id, + zigbee_test_utils.build_attribute_report(mock_device_contact, Frient_AccelerationMeasurementCluster.ID, attr_report_data, 0x1015) + }) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, 100})) + ) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.contactSensor.contact.closed()) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_multiir_contact_tamper.lua b/drivers/SmartThings/zigbee-contact/src/test/test_multiir_contact_tamper.lua new file mode 100644 index 0000000000..123b9df58c --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/test/test_multiir_contact_tamper.lua @@ -0,0 +1,147 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local IASZone = clusters.IASZone + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("contact-battery-tamper-no-fw-update.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "MultIR", + model = "MIR-MC100", + server_clusters = { 0x0001, 0x0500 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.contactSensor.contact.closed())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tamperAlert.tamper.clear())) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/closed tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/open tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/open tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0005, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/closed tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua index dbf30b3d13..6ebb00d45e 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +8,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local IASCIEAddress = IASZone.attributes.IASCIEAddress local EnrollResponseCode = IASZone.types.EnrollResponseCode @@ -52,13 +42,24 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -92,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua index d8a23f4af5..d4082723c7 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -104,7 +93,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, 0xFC02, {0x0010}, 0x1241) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -125,7 +117,10 @@ test.register_coroutine_test( mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -183,7 +178,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -222,7 +220,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua index 623d55cce6..fc48ab8eaa 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +8,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local IASCIEAddress = IASZone.attributes.IASCIEAddress local EnrollResponseCode = IASZone.types.EnrollResponseCode @@ -52,13 +42,24 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -92,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua b/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua index 9db8033062..4713e6eb41 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua @@ -1,21 +1,13 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" + +local IASZone = clusters.IASZone local SMARTSENSE_PROFILE_ID = 0xFC01 local MFG_CODE = 0x110A @@ -122,7 +114,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -137,7 +132,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -152,7 +150,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -167,7 +168,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -182,7 +186,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -197,7 +204,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -212,7 +222,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -227,7 +240,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -242,7 +258,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -257,7 +276,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(97))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -272,7 +294,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -287,7 +312,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -302,7 +330,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -317,7 +348,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -332,7 +366,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -344,7 +381,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({1050, 3, 9})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -356,7 +396,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-1050, -3, -9})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -368,7 +411,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({10, 1020, 7})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -380,7 +426,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-10, -1020, -7})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -392,7 +441,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({116, 4, 1003})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -404,7 +456,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-116, -4, -1003})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -434,7 +489,86 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-116, -4, -826})) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatus attr report should generate contact event when garageSensor not set: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatus attr report should generate contact event when garageSensor not set: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua index 05e5fdd0d4..da87c0aee2 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -98,6 +87,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -114,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -137,7 +132,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, acceleration_report_inactive, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -151,7 +149,10 @@ test.register_coroutine_test( cluster_base.build_test_read_attr_response(attribute_def, mock_device, 1) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -167,7 +168,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 100, -200})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -200,7 +204,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -227,7 +234,48 @@ test.register_coroutine_test( mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0001) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -251,7 +299,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, 0xFC02, {0x0010}, 0x110A) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -272,7 +323,10 @@ test.register_coroutine_test( mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -334,7 +388,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -375,7 +432,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua b/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua index d0ddd9c6d9..c03ab49dde 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +8,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local IASCIEAddress = IASZone.attributes.IASCIEAddress local EnrollResponseCode = IASZone.types.EnrollResponseCode @@ -52,13 +42,24 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -92,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua index 24dc80b7b2..92f7274365 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -49,7 +52,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFFF1, acceleration_report_inactive, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -63,7 +69,10 @@ test.register_coroutine_test( cluster_base.build_test_read_attr_response(attribute_def, mock_device, 1) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -79,7 +88,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFFF1, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({200, 100, 300})) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua index 0c89cbb6aa..f27743d4ee 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -30,9 +19,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -48,7 +35,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } + } + }, + }, + { + min_api_version = 17 } ) @@ -64,7 +62,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } + } + }, + }, + { + min_api_version = 17 } ) @@ -82,7 +91,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } + } + }, + }, + { + min_api_version = 17 } ) @@ -100,7 +120,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } + } + }, + }, + { + min_api_version = 17 } ) @@ -117,10 +148,47 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + test.register_message_test( "Battery percentage report should be handled", { @@ -135,29 +203,40 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.wait_for_events() +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) +-- }) +-- test.wait_for_events() - test.mock_time.advance_time(50000) - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - test.wait_for_events() - end, - { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end - } -) +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) +-- test.wait_for_events() +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) test.register_coroutine_test( "Refresh necessary attributes", @@ -169,13 +248,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -252,7 +342,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua index 888b2da96f..d908aedba6 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -65,9 +54,7 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device_sengled) test.mock_device.add_test_device(mock_device_nyce) - test.mock_device.add_test_device(mock_device_visonic) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device_visonic)end local test_devices = {} test_devices[mock_device_visonic.id] = mock_device_visonic @@ -99,7 +86,10 @@ test.register_coroutine_test( test.wait_for_events() end end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -116,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device_sengled:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) @@ -133,6 +126,9 @@ test.register_message_test( direction = "send", message = mock_device_nyce:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua index 50bca9d9bb..6b482996de 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -40,9 +29,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -67,7 +54,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } + ) test.register_message_test( "Temperature report should be handled (C)", @@ -82,7 +73,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -99,8 +101,13 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) + mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() - end + end, + { + min_api_version = 17 + } + ) diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua index 1ee1a1e2b2..ed6969cbc8 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua @@ -1,20 +1,24 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local battery_attribute_configuration = { + cluster = zcl_clusters.PowerConfiguration.ID, + attribute = zcl_clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 30, + maximum_interval = 14300, -- ~4hrs + data_type = zcl_clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 1 +} + +local function device_init(driver, device) + device:add_configured_attribute(battery_attribute_configuration) +end local zigbee_dimmer_remote_driver_template = { supported_capabilities = { @@ -23,7 +27,12 @@ local zigbee_dimmer_remote_driver_template = { capabilities.switch, capabilities.switchLevel }, - sub_drivers = { require("zigbee-accessory-dimmer"), require("zigbee-battery-accessory-dimmer")} + lifecycle_handlers = { + init = device_init, + }, + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_dimmer_remote_driver_template, zigbee_dimmer_remote_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/sub_drivers.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/sub_drivers.lua new file mode 100644 index 0000000000..c489a2b5d2 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +local sub_drivers = { + lazy_load_if_possible("zigbee-accessory-dimmer"), + lazy_load_if_possible("zigbee-battery-accessory-dimmer"), +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua index fe9c711a87..75773fdfea 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -39,9 +39,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -61,6 +59,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -80,6 +81,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -96,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -112,6 +119,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -125,7 +135,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, move_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(10))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -138,7 +151,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, move_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -150,7 +166,10 @@ test.register_coroutine_test( step_command.body.zcl_header.frame_ctrl = frm_ctrl test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(90))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -162,7 +181,10 @@ test.register_coroutine_test( step_command.body.zcl_header.frame_ctrl = frm_ctrl test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 17 + } ) @@ -176,7 +198,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.held( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -189,7 +214,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -216,7 +244,105 @@ test.register_coroutine_test( Scenes.ID) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On command when current level is 0 should reset level then toggle switch", + function() + mock_device:set_field("current_level", 0) + test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.server.commands.On.build_test_rx(mock_device) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(10))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Step command(MoveStepMode.DOWN) to level 0 should emit switch off and level 0", + function() + mock_device:set_field("current_level", 10) + local step_command = Level.server.commands.Step.build_test_rx(mock_device, Level.types.MoveStepMode.DOWN, 0x00, + 0x0000, 0x00, 0x00) + local frm_ctrl = FrameCtrl(0x01) + step_command.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(0))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Step command(MoveStepMode.UP) when device is off should emit switch on", + function() + mock_device:set_field("current_status", "off") + local step_command = Level.server.commands.Step.build_test_rx(mock_device, Level.types.MoveStepMode.UP, 0x00, + 0x0000, 0x00, 0x00) + local frm_ctrl = FrameCtrl(0x01) + step_command.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability command setLevel should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(50))) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability command setLevel 0 should restore previous level", + function() + mock_device:set_field("current_level", 30) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 0 } } }) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(30))) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "device added lifecycle should initialize device state", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_battery_accessory_dimmer.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_battery_accessory_dimmer.lua index bcb7ea5b9836bfd3d76cdb12fde30d10f9b06b91..b3a1073fe22735c0eacfca41eb835f2c15383a67 100644 GIT binary patch delta 2544 zcmb_dOH3PQ5Y|!%m{$zukpPB~OA|mQ?1Ecl!qXVXA;C5cRZbDwcz?$mExTyFHX+4n zuRVl>mOW&+sBU%!K&8${ z_Q4;X_`^Q<_paj1bh~G#7~>#)&-bp zr{1>>I5ydy3HiaybuK z^NZ{JBHlmkz(M}6J^;oa(Uuq?%bb(HSGH^<^QlDx_(~LKjw6h=S)3vUb5j%e#%;H{x*ygPD}b_Clw{BGh6yAtO9 zQ$i?|^HV&n>+B6}$5(sAwKgBNR6^EM#v$W$1RZ#}w+XX-;~1T|#tgHLulWO5BY67v z8)rt#kVA5rjq~PVZ3~6yR$zx?19IXl$o%z5ZU?wUU9fHw|kpr8> zTpEnN5;#9Ke_?JaRxpyaW`#*566RiwA8f+jurB83XI!(ke5X!V2><5NfThVES@Cna^P0LN7sDca`@xXvYu{s OON*}?17-2^S@;_~H9FS- delta 257 zcmccqnDN$P#tnyACv&nXP0r<%nf!rWXmb>sH{0Z`s>+kk^6_o{$+ zTq3lc2`C&gdA$V3=2fD7V9w_C64Ox_LX+ 0 then + level = math.max(1, math.floor((level / 254.0 * 100) + 0.5)) + end + local evt = capabilities.switchLevel.level(level) device:emit_component_event(device.profile.components.light, evt) end @@ -111,9 +94,7 @@ local itm_fan_light = { [capabilities.fanSpeed.commands.setFanSpeed.NAME] = fan_speed_handler } }, - can_handle = can_handle_itm_fanlight + can_handle = require("fan-light.can_handle"), } return itm_fan_light - - diff --git a/drivers/SmartThings/zigbee-fan/src/init.lua b/drivers/SmartThings/zigbee-fan/src/init.lua index eff62434ef..33af1cb320 100644 --- a/drivers/SmartThings/zigbee-fan/src/init.lua +++ b/drivers/SmartThings/zigbee-fan/src/init.lua @@ -1,28 +1,17 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" -local configurationMap = require "configurations" local device_init = function(self, device) + local configurationMap = require "configurations" local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end @@ -33,15 +22,14 @@ local zigbee_fan_driver = { capabilities.switchLevel, capabilities.fanspeed }, - sub_drivers = { - require("fan-light") - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { init = device_init - } + }, + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_fan_driver,zigbee_fan_driver.supported_capabilities) local fan = ZigbeeDriver("zigbee-fan", zigbee_fan_driver) fan:run() - diff --git a/drivers/SmartThings/zigbee-fan/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-fan/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-fan/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-fan/src/sub_drivers.lua b/drivers/SmartThings/zigbee-fan/src/sub_drivers.lua new file mode 100644 index 0000000000..f43392d622 --- /dev/null +++ b/drivers/SmartThings/zigbee-fan/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("fan-light"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua b/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua index f83a33074b..b305bb8a9d 100644 --- a/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua +++ b/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua @@ -1,16 +1,6 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 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" @@ -39,9 +29,7 @@ local mock_base_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_base_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_base_device)end test.set_test_init_function(test_init) -- create test commands @@ -60,6 +48,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 254, 0) } } + }, + { + min_api_version = 17 } ) @@ -78,6 +69,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 127, 0) } } + }, + { + min_api_version = 17 } ) @@ -96,6 +90,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 127, 0) } } + }, + { + min_api_version = 17 } ) @@ -114,6 +111,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 0, 0) } } + }, + { + min_api_version = 17 } ) @@ -136,6 +136,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 17 } ) @@ -158,6 +161,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 17 } ) @@ -175,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -192,6 +201,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -209,6 +221,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switchLevel.level(100)) } + }, + { + min_api_version = 17 } ) @@ -226,6 +241,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switchLevel.level(50)) } + }, + { + min_api_version = 17 } ) @@ -243,6 +261,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switchLevel.level(0)) } + }, + { + min_api_version = 17 } ) @@ -264,6 +285,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 17 } ) @@ -285,6 +309,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 17 } ) @@ -306,6 +333,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 17 } ) @@ -333,6 +363,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(1)) } + }, + { + min_api_version = 17 } ) @@ -360,6 +393,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(2)) } + }, + { + min_api_version = 17 } ) @@ -387,6 +423,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(3)) } + }, + { + min_api_version = 17 } ) @@ -414,6 +453,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(4)) } + }, + { + min_api_version = 17 } ) @@ -441,7 +483,58 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(0)) } + }, + { + min_api_version = 17 } ) +test.register_message_test( + "Fan switch on command from main component", + { + { + channel = "capability", + direction = "receive", + message = { mock_base_device.id, { capability = "switch", component = "main", command = "on", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:write(mock_base_device, 1) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Fan switch off command from main component", + { + { + channel = "capability", + direction = "receive", + message = { mock_base_device.id, { capability = "switch", component = "main", command = "off", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:write(mock_base_device, 0x00) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } + } + }, + { + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index aeeca7e466..ee36659670 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -58,31 +58,76 @@ zigbeeManufacturer: manufacturer: HEIMAN model: HT-EF-3.0 deviceProfileName: humidity-temp-battery + - id: frient/AQSZB-110 + deviceLabel: Air Quality Sensor + manufacturer: frient A/S + model: AQSZB-110 + deviceProfileName: frient-airquality-humidity-temperature-battery - id: frient/HMSZB-110 - deviceLabel: frient Multipurpose Sensor + deviceLabel: frient Humidity Sensor manufacturer: frient A/S model: HMSZB-110 - deviceProfileName: humidity-temp-battery + deviceProfileName: frient-humidity-temperature-battery + - id: frient/HMSZB-120 + deviceLabel: frient Humidity Sensor + manufacturer: frient A/S + model: HMSZB-120 + deviceProfileName: frient-humidity-temperature-battery - id: eWeLink/TH01 deviceLabel: eWeLink Multipurpose Sensor manufacturer: eWeLink model: TH01 deviceProfileName: humidity-temp-battery - id: eWeLink/SNZB-02P - deviceLabel: eWeLink Multipurpose Sensor + deviceLabel: SONOFF Zigbee Temperature and Humidity Sensor manufacturer: eWeLink model: SNZB-02P deviceProfileName: humidity-temp-battery + - id: "SONOFF/SNZB-02DR2" + deviceLabel: "SONOFF SNZB-02DR2" + manufacturer: SONOFF + model: SNZB-02DR2 + deviceProfileName: humidity-temp-battery + - id: "SONOFF/SNZB-02B" + deviceLabel: "SONOFF SNZB-02B" + manufacturer: SONOFF + model: SNZB-02B + deviceProfileName: humidity-temp-battery + - id: "Third Reality/3RTHS24BZ" + deviceLabel: ThirdReality Temperature and Humidity Sensor + manufacturer: Third Reality, Inc + model: 3RTHS24BZ + deviceProfileName: humidity-temp-battery + - id: "Third Reality/3RSM0147Z" + deviceLabel: ThirdReality Smart Soil Moisture Sensor + manufacturer: Third Reality, Inc + model: 3RSM0147Z + deviceProfileName: humidity-temp-battery + - id: "NodOn/STPH-4-1-00" + deviceLabel: Zigbee Temperature and Humidity Sensor + manufacturer: NodOn + model: STPH-4-1-00 + deviceProfileName: humidity-temp-battery zigbeeGeneric: - id: "HumidityTempGeneric" deviceLabel: Multipurpose Sensor deviceIdentifiers: - 0x0302 - clusters: - server: + clusters: + server: - 0x0402 # Temperature Measurement Cluster - 0x0405 # Relative Humidity Measurement Cluster deviceProfileName: humidity-temperature + - id: "HumidityTempBatteryGeneric" + deviceLabel: Multipurpose Sensor + deviceIdentifiers: + - 0x0302 + clusters: + server: + - 0x0001 # PowerConfiguration + - 0x0402 # Temperature Measurement Cluster + - 0x0405 # Relative Humidity Measurement Cluster + deviceProfileName: humidity-temp-battery - id: "PlantLinkClusterSpecific" deviceLabel: Plant Link Humidity Sensor zigbeeProfiles: @@ -108,4 +153,4 @@ zigbeeGeneric: - 0x0B04 client: - 0x0003 - deviceProfileName: humidity-battery + deviceProfileName: humidity-battery \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml new file mode 100644 index 0000000000..1df453e4aa --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml @@ -0,0 +1,54 @@ +name: frient-airquality-humidity-temperature-battery +components: +- id: main + capabilities: + - id: airQualitySensor + version: 1 + - id: tvocMeasurement + version: 1 + - id: tvocHealthConcern + version: 1 + config: + values: + - key: "tvocHealthConcern.value" + enabledValues: + - good + - moderate + - slightlyUnhealthy + - unhealthy + - veryUnhealthy + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirQualityDetector +preferences: + - preferenceId: humidityOffset + explicit: true + - title: "Humidity Sensitivity (%)" + name: humiditySensitivity + description: "Minimum change in humidity level to report" + required: false + preferenceType: number + definition: + minimum: 1 + maximum: 50 + default: 3 + - preferenceId: tempOffset + explicit: true + - title: "Temperature Sensitivity (°)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-humidity-temperature-battery.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-humidity-temperature-battery.yml new file mode 100644 index 0000000000..cd186812bf --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-humidity-temperature-battery.yml @@ -0,0 +1,39 @@ +name: frient-humidity-temperature-battery +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: TempHumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true + - title: "Humidity Sensitivity (%)" + name: humiditySensitivity + description: "Minimum change in humidity level to report" + required: false + preferenceType: number + definition: + minimum: 1 + maximum: 50 + default: 3 + - preferenceId: tempOffset + explicit: true + - title: "Temperature Sensitivity (°C)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery-aqara.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery-aqara.yml index 1f125e0c88..f97a646078 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery-aqara.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery-aqara.yml @@ -6,19 +6,58 @@ components: version: 1 - id: relativeHumidityMeasurement version: 1 - - id: battery + - id: batteryLevel version: 1 - id: firmwareUpdate version: 1 - id: refresh version: 1 categories: - - name: Thermostat + - name: TempHumiditySensor preferences: - preferenceId: tempOffset explicit: true - preferenceId: humidityOffset explicit: true -metadata: - mnmn: SolutionsEngineering - vid: SmartThings-smartthings-Aqara_Humidity_Temperature +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: batteryLevel + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: batteryLevel + version: 1 + actions: [] diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery.yml index e1164ecb7d..f3c4eccc3a 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temp-battery.yml @@ -13,9 +13,51 @@ components: - id: refresh version: 1 categories: - - name: MultiFunctionalSensor + - name: TempHumiditySensor preferences: - preferenceId: tempOffset explicit: true - preferenceId: humidityOffset explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + actions: [] diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temperature.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temperature.yml index 351dcec312..c77760580d 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temperature.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/humidity-temperature.yml @@ -11,9 +11,45 @@ components: - id: refresh version: 1 categories: - - name: MultiFunctionalSensor + - name: TempHumiditySensor preferences: - preferenceId: tempOffset explicit: true - preferenceId: humidityOffset explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + actions: [] diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..da7a97c64b --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..8e6df9f7d6 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.sensor_ht.agl02" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/init.lua index dab7edf0cd..d3a8c7f89d 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/aqara/init.lua @@ -1,4 +1,6 @@ -local battery_defaults = require "st.zigbee.defaults.battery_defaults" +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" @@ -12,9 +14,6 @@ local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.sensor_ht.agl02" } -} -- temperature: 0.5C, humidity: 2% local configuration = { @@ -44,24 +43,30 @@ local configuration = { } } -local is_aqara_products = function(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end + +local function battery_level_handler(driver, device, value, zb_rx) + local voltage = value.value + local batteryLevel = "normal" + if voltage <= 25 then + batteryLevel = "critical" + elseif voltage < 28 then + batteryLevel = "warning" end - return false + device:emit_event(capabilities.batteryLevel.battery(batteryLevel)) end local function device_init(driver, device) - battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) - if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end + + local batt_level = device:get_latest_state("main", capabilities.batteryLevel.ID, capabilities.batteryLevel.battery + .NAME) or nil + if batt_level == nil then + device:emit_event(capabilities.batteryLevel.battery.normal()) + end end local function added_handler(self, device) @@ -69,16 +74,23 @@ local function added_handler(self, device) PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1)) device:emit_event(capabilities.temperatureMeasurement.temperature({ value = 0, unit = "C" })) device:emit_event(capabilities.relativeHumidityMeasurement.humidity(0)) - device:emit_event(capabilities.battery.battery(100)) + device:emit_event(capabilities.batteryLevel.battery("normal")) end local aqara_humidity_handler = { NAME = "Aqara Humidity Handler", + zigbee_handlers = { + attr = { + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryVoltage.ID] = battery_level_handler + } + } + }, lifecycle_handlers = { init = device_init, added = added_handler }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_humidity_handler diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/can_handle.lua new file mode 100644 index 0000000000..a36cc01536 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_centralite_sensor(opts, driver, device) + local FINGERPRINTS = require("centralite-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("centralite-sensor") + end + end + return false +end + +return can_handle_centralite_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/fingerprints.lua new file mode 100644 index 0000000000..df4f8a34a3 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CENTRALITE_SENSOR_FINGERPRINTS = { + { mfr = "CentraLite", model = "3310-S" }, + { mfr = "CentraLite", model = "3310-G" }, + { mfr = "CentraLite", model = "3310" } +} + +return CENTRALITE_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/init.lua index e1c11dd03b..11fedcfe24 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/centralite-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -23,20 +13,7 @@ local configurationMap = require "configurations" local HUMIDITY_CLUSTER_ID = 0xFC45 local HUMIDITY_MEASURE_ATTR_ID = 0x0000 -local CENTRALITE_SENSOR_FINGERPRINTS = { - { mfr = "CentraLite", model = "3310-S" }, - { mfr = "CentraLite", model = "3310-G" }, - { mfr = "CentraLite", model = "3310" } -} -local function can_handle_centralite_sensor(opts, driver, device) - for _, fingerprint in ipairs(CENTRALITE_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) device:remove_configured_attribute(clusters.RelativeHumidity.ID, clusters.RelativeHumidity.attributes.MeasuredValue.ID) @@ -89,7 +66,7 @@ local centralite_sensor = { } } }, - can_handle = can_handle_centralite_sensor + can_handle = require("centralite-sensor.can_handle"), } return centralite_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua index 5c8f6834e9..043359768f 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -22,31 +22,42 @@ local PowerConfiguration = clusters.PowerConfiguration local devices = { FRIENT_HUMIDITY_TEMP_SENSOR = { FINGERPRINTS = { - { mfr = "frient A/S", model = "HMSZB-110" } + { mfr = "frient A/S", model = "HMSZB-110" }, + { mfr = "frient A/S", model = "HMSZB-120" }, + { mfr = "frient A/S", model = "AQSZB-110" } }, CONFIGURATION = { { cluster = RelativeHumidity.ID, attribute = RelativeHumidity.attributes.MeasuredValue.ID, minimum_interval = 60, - maximum_interval = 600, + maximum_interval = 3600, data_type = RelativeHumidity.attributes.MeasuredValue.base_type, - reportable_change = 100 + reportable_change = 300 }, { cluster = TemperatureMeasurement.ID, attribute = TemperatureMeasurement.attributes.MeasuredValue.ID, - minimum_interval = 60, - maximum_interval = 600, + minimum_interval = 30, + maximum_interval = 3600, data_type = TemperatureMeasurement.attributes.MeasuredValue.base_type, - reportable_change = 10 + reportable_change = 100 + }, + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryVoltage.ID, + minimum_interval = 30 , + maximum_interval = 21600, + data_type = PowerConfiguration.attributes.BatteryVoltage.base_type, + reportable_change = 1 } } }, EWELINK_HUMIDITY_TEMP_SENSOR = { FINGERPRINTS = { { mfr = "eWeLink", model = "TH01" }, - { mfr = "eWeLink", model = "SNZB-02P" } + { mfr = "eWeLink", model = "SNZB-02P" }, + { mfr = "SONOFF", model = "SNZB-02DR2" } }, CONFIGURATION = { { diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/can_handle.lua new file mode 100644 index 0000000000..f82f572b43 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_frient(opts, driver, device, ...) + local FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS = require ("frient-sensor.air-quality.fingerprints") + for _, fingerprint in ipairs(FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model and fingerprint.subdriver == "airquality" then + return true, require("frient-sensor.air-quality") + end + end + return false +end + +return can_handle_frient diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/fingerprints.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/fingerprints.lua new file mode 100644 index 0000000000..82c98c39fe --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS = { + { mfr = "frient A/S", model = "AQSZB-110", subdriver = "airquality" } +} + +return FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/init.lua new file mode 100644 index 0000000000..25ff436988 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/init.lua @@ -0,0 +1,147 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local util = require "st.utils" +local data_types = require "st.zigbee.data_types" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement +local HumidityMeasurement = zcl_clusters.RelativeHumidity +local PowerConfiguration = zcl_clusters.PowerConfiguration +local device_management = require "st.zigbee.device_management" +local cluster_base = require "st.zigbee.cluster_base" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" + +local Frient_VOCMeasurement = { + ID = 0xFC03, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValue = { ID = 0x0000, base_type = data_types.Uint16 }, + MinMeasuredValue = { ID = 0x0001, base_type = data_types.Uint16 }, + MaxMeasuredValue = { ID = 0x0002, base_type = data_types.Uint16 }, + Resolution = { ID = 0x0003, base_type = data_types.Uint16 }, + }, +} + +Frient_VOCMeasurement.attributes.MeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MinMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MaxMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.Resolution._cluster = Frient_VOCMeasurement + +local MAX_VOC_REPORTABLE_VALUE = 5500 -- Max VOC reportable value + +--- Table to map VOC (ppb) to HealthConcern +local VOC_TO_HEALTHCONCERN_MAPPING = { + [2201] = "veryUnhealthy", + [661] = "unhealthy", + [221] = "slightlyUnhealthy", + [66] = "moderate", + [0] = "good", +} + +--- Map VOC (ppb) to HealthConcern +local function voc_to_healthconcern(raw_voc) + for voc, perc in util.rkeys(VOC_TO_HEALTHCONCERN_MAPPING) do + if raw_voc >= voc then + return perc + end + end +end +--- Map VOC (ppb) to CAQI +local function voc_to_caqi(raw_voc) + if (raw_voc > 5500) then + return 100 + else + return math.floor(raw_voc*99/5500) + end +end + +-- May take around 8 minutes for the first valid VOC measurement to be reported after the device is powered on +local function voc_measure_value_attr_handler(driver, device, attr_val, zb_rx) + local voc_value = attr_val.value + if (voc_value < 65535) then -- ignore it if it's outside the limits + voc_value = util.clamp_value(voc_value, 0, MAX_VOC_REPORTABLE_VALUE) + device:emit_event(capabilities.airQualitySensor.airQuality({ value = voc_to_caqi(voc_value)})) + device:emit_event(capabilities.tvocHealthConcern.tvocHealthConcern(voc_to_healthconcern(voc_value))) + device:emit_event(capabilities.tvocMeasurement.tvocLevel({ value = voc_value, unit = "ppb" })) + end +end + +-- The device sends the value of MeasuredValue to be 0x8000, which corresponds to -327.68C, until it gets the first valid measurement. Therefore we don't emit event before the value is correct. It may take up to 4 minutes +local function temperatureHandler(driver, device, attr_val, zb_rx) + local temp_value = attr_val.value + if (temp_value > -32768) then + device:emit_event(capabilities.temperatureMeasurement.temperature({ value = temp_value / 100, unit = "C" })) + end +end + +local function device_init(driver, device) + local configurationMap = require "configurations" + battery_defaults.build_linear_voltage_init(2.3, 3.0)(driver, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end + end +end + +local function device_added(driver, device) + device:emit_event(capabilities.airQualitySensor.airQuality(voc_to_caqi(0))) + device:emit_event(capabilities.tvocHealthConcern.tvocHealthConcern(voc_to_healthconcern(0))) + device:emit_event(capabilities.tvocMeasurement.tvocLevel({ value = 0, unit = "ppb" })) +end + +local function do_refresh(driver, device) + local FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS = require "frient-sensor.air-quality.fingerprints" + for _, fingerprint in ipairs(FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_VOCMeasurement.ID, Frient_VOCMeasurement.attributes.MeasuredValue.ID, Frient_VOCMeasurement.ManufacturerSpecificCode):to_endpoint(0x26)) + device:send(TemperatureMeasurement.attributes.MeasuredValue:read(device):to_endpoint(0x26)) + device:send(HumidityMeasurement.attributes.MeasuredValue:read(device):to_endpoint(0x26)) + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + end + end +end + +local function do_configure(driver, device) + device:configure() + device:send(device_management.build_bind_request(device, Frient_VOCMeasurement.ID, driver.environment_info.hub_zigbee_eui, 0x26)) + + device:send( + cluster_base.configure_reporting( + device, + data_types.ClusterId(Frient_VOCMeasurement.ID), + Frient_VOCMeasurement.attributes.MeasuredValue.ID, + Frient_VOCMeasurement.attributes.MeasuredValue.base_type.ID, + 60, 600, 10 + ):to_endpoint(0x26) + ) + + device.thread:call_with_delay(5, function() + do_refresh(driver, device) + end) +end + +local frient_airquality_sensor = { + NAME = "frient Air Quality Sensor", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + }, + zigbee_handlers = { + cluster = {}, + attr = { + [Frient_VOCMeasurement.ID] = { + [Frient_VOCMeasurement.attributes.MeasuredValue.ID] = voc_measure_value_attr_handler, + }, + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MeasuredValue.ID] = temperatureHandler, + }, + } + }, + can_handle = require("frient-sensor.air-quality.can_handle") +} + +return frient_airquality_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/can_handle.lua new file mode 100644 index 0000000000..060f58f76e --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_frient_sensor(opts, driver, device) + local FINGERPRINTS = require("frient-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("frient-sensor") + end + end + return false +end + +return can_handle_frient_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/fingerprints.lua new file mode 100644 index 0000000000..f7969cafdd --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_TEMP_HUMUDITY_SENSOR_FINGERPRINTS = { + { mfr = "frient A/S", model = "HMSZB-110" }, + { mfr = "frient A/S", model = "HMSZB-120" }, + { mfr = "frient A/S", model = "AQSZB-110" } +} + +return FRIENT_TEMP_HUMUDITY_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua index c63b319cea..d0d6190ad0 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua @@ -1,50 +1,55 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local battery_defaults = require "st.zigbee.defaults.battery_defaults" -local configurationMap = require "configurations" - -local FRIENT_TEMP_HUMUDITY_SENSOR_FINGERPRINTS = { - { mfr = "frient A/S", model = "HMSZB-110" }, -} - -local function can_handle_frient_sensor(opts, driver, device) - for _, fingerprint in ipairs(FRIENT_TEMP_HUMUDITY_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end +local zcl_clusters = require "st.zigbee.zcl.clusters" +local HumidityMeasurement = zcl_clusters.RelativeHumidity +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement local function device_init(driver, device) + local configurationMap = require "configurations" + local battery_defaults = require "st.zigbee.defaults.battery_defaults" battery_defaults.build_linear_voltage_init(2.3,3.0)(driver, device) local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end +local function do_configure(driver, device, event, args) + device:configure() + device.thread:call_with_delay(5, function() + device:refresh() + end) +end + +local function info_changed(driver, device, event, args) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + local sensitivity = math.floor((device.preferences[name]) * 100 + 0.5) + if (name == "temperatureSensitivity") then + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 3600, sensitivity)) + end + if (name == "humiditySensitivity") then + device:send(HumidityMeasurement.attributes.MeasuredValue:configure_reporting(device, 60, 3600, sensitivity)) + end + end + end + device.thread:call_with_delay(5, function() + device:refresh() + end) +end + local frient_sensor = { NAME = "Frient Humidity Sensor", lifecycle_handlers = { - init = device_init + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed }, - can_handle = can_handle_frient_sensor + sub_drivers = require("frient-sensor.sub_drivers"), + can_handle = require("frient-sensor.can_handle"), } return frient_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/sub_drivers.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/sub_drivers.lua new file mode 100644 index 0000000000..cc45e1b394 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient-sensor.air-quality") +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/can_handle.lua new file mode 100644 index 0000000000..4e3aff5517 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_heiman_sensor(opts, driver, device) + local FINGERPRINTS = require("heiman-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("heiman-sensor") + end + end + return false +end + +return can_handle_heiman_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/fingerprints.lua new file mode 100644 index 0000000000..b0f9e1c7ab --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local HEIMAN_TEMP_HUMUDITY_SENSOR_FINGERPRINTS = { + { mfr = "Heiman", model = "b467083cfc864f5e826459e5d8ea6079" }, + { mfr = "HEIMAN", model = "888a434f3cfc47f29ec4a3a03e9fc442" }, + { mfr = "HEIMAN", model = "HT-EM" }, + { mfr = "HEIMAN", model = "HT-EF-3.0" } +} + +return HEIMAN_TEMP_HUMUDITY_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/init.lua index 10a60c8392..8be2857f70 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/heiman-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -20,21 +10,7 @@ local RelativeHumidity = clusters.RelativeHumidity local TemperatureMeasurement = clusters.TemperatureMeasurement local PowerConfiguration = clusters.PowerConfiguration -local HEIMAN_TEMP_HUMUDITY_SENSOR_FINGERPRINTS = { - { mfr = "Heiman", model = "b467083cfc864f5e826459e5d8ea6079" }, - { mfr = "HEIMAN", model = "888a434f3cfc47f29ec4a3a03e9fc442" }, - { mfr = "HEIMAN", model = "HT-EM" }, - { mfr = "HEIMAN", model = "HT-EF-3.0" } -} -local function can_handle_heiman_sensor(opts, driver, device) - for _, fingerprint in ipairs(HEIMAN_TEMP_HUMUDITY_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function do_refresh(driver, device) device:send(RelativeHumidity.attributes.MeasuredValue:read(device):to_endpoint(0x02)) @@ -59,7 +35,7 @@ local heiman_sensor = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, - can_handle = can_handle_heiman_sensor + can_handle = require("heiman-sensor.can_handle"), } return heiman_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua index 0e19b78d02..6bde23ed25 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua @@ -1,51 +1,78 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" +local TemperatureMeasurement = (require "st.zigbee.zcl.clusters").TemperatureMeasurement local configurationMap = require "configurations" +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end +local function added_handler(driver, device) + device:send(TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) + device:send(TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) +end + local zigbee_humidity_driver = { supported_capabilities = { capabilities.battery, capabilities.relativeHumidityMeasurement, capabilities.temperatureMeasurement }, + zigbee_handlers = { + attr = { + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), + } + } + }, lifecycle_handlers = { - init = device_init + init = device_init, + added = added_handler, }, - sub_drivers = { - require("aqara"), - require("plant-link"), - require("plaid-systems"), - require("centralite-sensor"), - require("heiman-sensor"), - require("frient-sensor") - } + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(zigbee_humidity_driver, zigbee_humidity_driver.supported_capabilities) +defaults.register_for_default_handlers(zigbee_humidity_driver, zigbee_humidity_driver.supported_capabilities, {native_capability_attrs_enabled = true}) local driver = ZigbeeDriver("zigbee-humidity-sensor", zigbee_humidity_driver) driver:run() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/can_handle.lua new file mode 100644 index 0000000000..a11116318e --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_plaid_systems_humidity_sensor = function(opts, driver, device) + local FINGERPRINTS = require("plaid-systems.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("plaid-systems") + end + end + + return false +end + +return is_zigbee_plaid_systems_humidity_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/fingerprints.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/fingerprints.lua new file mode 100644 index 0000000000..fbacec9269 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS = { + { mfr = "PLAID SYSTEMS", model = "PS-SPRZMS-01" }, + { mfr = "PLAID SYSTEMS", model = "PS-SPRZMS-SLP1" }, + { mfr = "PLAID SYSTEMS", model = "PS-SPRZMS-SLP3" }, +} + +return ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/init.lua index 2233aab2a3..093007adf9 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/plaid-systems/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local device_management = require "st.zigbee.device_management" @@ -21,21 +11,7 @@ local PowerConfiguration = zcl_clusters.PowerConfiguration local RelativeHumidity = zcl_clusters.RelativeHumidity local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement -local ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS = { - { mfr = "PLAID SYSTEMS", model = "PS-SPRZMS-01" }, - { mfr = "PLAID SYSTEMS", model = "PS-SPRZMS-SLP1" }, - { mfr = "PLAID SYSTEMS", model = "PS-SPRZMS-SLP3" }, -} -local is_zigbee_plaid_systems_humidity_sensor = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - - return false -end local battery_mains_voltage_attr_handler = function(driver, device, value, zb_rx) local min = 2500 @@ -87,7 +63,7 @@ local plaid_systems_humdity_sensor = { } } }, - can_handle = is_zigbee_plaid_systems_humidity_sensor + can_handle = require("plaid-systems.can_handle"), } return plaid_systems_humdity_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/can_handle.lua new file mode 100644 index 0000000000..9dcbc3e407 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_plant_link_humidity_sensor = function(opts, driver, device) + local FINGERPRINTS = require("plant-link.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model and device:supports_server_cluster(fingerprint.cluster_id) then + return true, require("plant-link") + end + end + + return false +end + +return is_zigbee_plant_link_humidity_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/fingerprints.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/fingerprints.lua new file mode 100644 index 0000000000..3b5e5f648d --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/fingerprints.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local PLANT_LINK_MANUFACTURER_SPECIFIC_CLUSTER = 0xFC08 + +local ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS = { + { mfr = "", model = "", cluster_id = PLANT_LINK_MANUFACTURER_SPECIFIC_CLUSTER }, + { mfr = "", model = "", cluster_id = zcl_clusters.ElectricalMeasurement.ID } +} + +return ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/init.lua index 0a2d7fb225..07ab4a095e 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/plant-link/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -18,12 +8,7 @@ local PowerConfiguration = zcl_clusters.PowerConfiguration local RelativeHumidity = zcl_clusters.RelativeHumidity local utils = require "st.utils" -local PLANT_LINK_MANUFACTURER_SPECIFIC_CLUSTER = 0xFC08 -local ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS = { - { mfr = "", model = "", cluster_id = PLANT_LINK_MANUFACTURER_SPECIFIC_CLUSTER }, - { mfr = "", model = "", cluster_id = zcl_clusters.ElectricalMeasurement.ID } -} local humidity_value_attr_handler = function(driver, device, value, zb_rx) -- adc reading of 0x1ec0 produces a plant fuel level near 0 @@ -44,15 +29,6 @@ local battery_mains_voltage_attr_handler = function(driver, device, value, zb_rx device:emit_event(capabilities.battery.battery(percent)) end -local is_zigbee_plant_link_humidity_sensor = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_HUMIDITY_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model and device:supports_server_cluster(fingerprint.cluster_id) then - return true - end - end - - return false -end local plant_link_humdity_sensor = { NAME = "PlantLink Soil Moisture Sensor", @@ -70,7 +46,7 @@ local plant_link_humdity_sensor = { } } }, - can_handle = is_zigbee_plant_link_humidity_sensor + can_handle = require("plant-link.can_handle"), } return plant_link_humdity_sensor diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..6f49a5f18c --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("plant-link"), + lazy_load_if_possible("plaid-systems"), + lazy_load_if_possible("centralite-sensor"), + lazy_load_if_possible("heiman-sensor"), + lazy_load_if_possible("frient-sensor"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua index 2231489167..762c1f4431 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -18,11 +18,17 @@ local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" local PowerConfiguration = clusters.PowerConfiguration local TemperatureMeasurement = clusters.TemperatureMeasurement local RelativeHumidity = clusters.RelativeHumidity +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local MFG_CODE = 0x115F + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("humidity-temp-battery-aqara.yml"), @@ -39,6 +45,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery.normal())) test.mock_device.add_test_device(mock_device) end @@ -86,7 +94,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 3600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -106,6 +117,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 17 } ) @@ -126,6 +140,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 0 })) } + }, + { + min_api_version = 17 } ) @@ -146,6 +163,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 100 })) } + }, + { + min_api_version = 17 } ) @@ -165,7 +185,18 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -184,8 +215,100 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) + mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "BatteryVoltage report should be handled(normal)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.normal()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "BatteryVoltage report should be handled(critical)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 10) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.critical()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "BatteryVoltage report should be handled(warning)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 26) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.warning()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Added handler should send manufacturer attribute and initialize device state", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.temperatureMeasurement.temperature({ value = 0, unit = "C" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.relativeHumidityMeasurement.humidity(0))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("normal"))) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua index 626d704f2a..b35c590b91 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -105,7 +94,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -194,7 +184,10 @@ test.register_coroutine_test( ) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) local function build_test_attr_report(device, value) @@ -236,6 +229,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 75 })) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua index 17d9afd2a5..aefbd3c350 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -76,7 +65,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -122,7 +112,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua new file mode 100644 index 0000000000..06e836a007 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua @@ -0,0 +1,352 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" + +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local HumidityMeasurement = clusters.RelativeHumidity + +local Frient_VOCMeasurement = { + ID = 0xFC03, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValue = { ID = 0x0000, base_type = data_types.Uint16 }, + MinMeasuredValue = { ID = 0x0001, base_type = data_types.Uint16 }, + MaxMeasuredValue = { ID = 0x0002, base_type = data_types.Uint16 }, + Resolution = { ID = 0x0003, base_type = data_types.Uint16 }, + }, +} + +Frient_VOCMeasurement.attributes.MeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MinMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MaxMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.Resolution._cluster = Frient_VOCMeasurement + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-airquality-humidity-temperature-battery.yml"), + zigbee_endpoints = { + [0x26] = { + id = 0x26, + manufacturer = "frient A/S", + model = "AQSZB-110", + server_clusters = {0x0001, 0x0402, 0x0405, 0xFC03} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 0x001E, 0x0E10, 100) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + HumidityMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 60, 3600, 300) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + Frient_VOCMeasurement.ID, + 38 + ):to_endpoint(0x26) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting( + mock_device, + data_types.ClusterId(Frient_VOCMeasurement.ID), + Frient_VOCMeasurement.attributes.MeasuredValue.ID, + Frient_VOCMeasurement.attributes.MeasuredValue.base_type.ID, + 60, 600, 10 + ):to_endpoint(0x26) + }) + + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + --refresh happens after configure + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Frient_VOCMeasurement.ID, Frient_VOCMeasurement.attributes.MeasuredValue.ID, Frient_VOCMeasurement.ManufacturerSpecificCode):to_endpoint(0x26) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(0x26) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(0x26) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Humidity report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 0x1950) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 65 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "info_changed to check for necessary preferences settings: Temperature Sensitivity", + function() + local updates = { + preferences = { + temperatureSensitivity = 0.9, + humiditySensitivity = 10 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + local temperatureSensitivity = math.floor(0.9 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 3600, + temperatureSensitivity + ) + }) + local humiditySensitivity = math.floor(10 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 60, + 3600, + humiditySensitivity + ) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "VOC measurement report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, cluster_base.build_test_attr_report(Frient_VOCMeasurement.attributes.MeasuredValue, mock_device, 300) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airQualitySensor.airQuality({ value = 5 })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tvocHealthConcern.tvocHealthConcern({ value = "slightlyUnhealthy" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tvocMeasurement.tvocLevel({ value = 300, unit = "ppb" })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Added handler should initialize VOC and air quality state", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualitySensor.airQuality({ value = 0 }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocHealthConcern.tvocHealthConcern({ value = "good" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocMeasurement.tvocLevel({ value = 0, unit = "ppb" }))) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua index e61ae9e22d..81bc408f32 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua @@ -1,36 +1,26 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" local PowerConfiguration = clusters.PowerConfiguration local TemperatureMeasurement = clusters.TemperatureMeasurement -local RelativeHumidity = clusters.RelativeHumidity +local HumidityMeasurement = clusters.RelativeHumidity local mock_device = test.mock_device.build_test_zigbee_device( { - profile = t_utils.get_profile_definition("humidity-temp-battery.yml"), + profile = t_utils.get_profile_definition("frient-humidity-temperature-battery.yml"), zigbee_endpoints = { - [1] = { - id = 1, + [26] = { + id = 26, manufacturer = "frient A/S", - model = "HMSZB-110", + model = "HMSZB-120", server_clusters = {0x0001, 0x0402, 0x0405} - } + }, } } ) @@ -63,7 +53,7 @@ test.register_message_test( direction = "send", message = { mock_device.id, - RelativeHumidity.attributes.MeasuredValue:read(mock_device) + HumidityMeasurement.attributes.MeasuredValue:read(mock_device) } }, { @@ -76,20 +66,60 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes", function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, - RelativeHumidity.ID + HumidityMeasurement.ID ) }) test.socket.zigbee:__expect_send({ @@ -108,7 +138,7 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - RelativeHumidity.attributes.MeasuredValue:configure_reporting(mock_device, 60, 600, 100) + HumidityMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 60, 3600, 300) }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -116,13 +146,130 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 60, 600, 10) + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 0x001E, 0x0E10, 100) }) - test.socket.zigbee:__expect_send({ mock_device.id, RelativeHumidity.attributes.MeasuredValue:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + test.wait_for_events() + + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Humidity report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 0x1950) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 65 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "info_changed to check for necessary preferences settings: Temperature Sensitivity", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + local updates = { + preferences = { + temperatureSensitivity = 0.9, + humiditySensitivity = 10 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + local temperatureSensitivity = math.floor(0.9 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 3600, + temperatureSensitivity + ) + }) + local humiditySensitivity = math.floor(10 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 60, + 3600, + humiditySensitivity + ) + }) + test.wait_for_events() + + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua index 3dd7f5c719..c75326503d 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -76,7 +65,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -133,7 +123,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua index 5d6b6981f4..3d9e24e2eb 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -37,9 +26,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -59,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 50.0 })) } + }, + { + min_api_version = 17 } ) @@ -78,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 0.0 })) } + }, + { + min_api_version = 17 } ) @@ -97,6 +90,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 100.0 })) } + }, + { + min_api_version = 17 } ) @@ -121,7 +117,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua index 9334c03045..c8aa3a6191 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local base64 = require "st.base64" local capabilities = require "st.capabilities" @@ -60,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 17 } ) @@ -79,6 +71,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 0 })) } + }, + { + min_api_version = 17 } ) @@ -98,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 100 })) } + }, + { + min_api_version = 17 } ) @@ -116,7 +114,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -140,7 +149,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc))) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -156,8 +168,12 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) + mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -192,6 +208,9 @@ test.register_message_test( TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) } }, + }, + { + min_api_version = 17 } ) @@ -229,6 +248,9 @@ test.register_message_test( TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) } }, + }, + { + min_api_version = 17 } ) @@ -263,7 +285,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua index 3fa1b5719b..e821dd3d56 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -48,7 +37,48 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 } ) @@ -68,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 17 } ) @@ -102,7 +135,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,6 +160,7 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) + mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() test.socket.zigbee:__queue_receive( { @@ -132,7 +169,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 }))) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua new file mode 100644 index 0000000000..b03f27d59d --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua @@ -0,0 +1,198 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local base64 = require "st.base64" +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local TemperatureMeasurement = clusters.TemperatureMeasurement +local RelativeHumidity = clusters.RelativeHumidity +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local PowerConfiguration = clusters.PowerConfiguration + + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("humidity-temp-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "", + model = "", + server_clusters = { 0x0001, 0x0402, 0x0405 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Humidity report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + RelativeHumidity.attributes.MeasuredValue:build_test_attr_report(mock_device, 7900) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + RelativeHumidity.attributes.MeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + RelativeHumidity.attributes.MeasuredValue:configure_reporting(mock_device, 30, 3600, 100) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, RelativeHumidity.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 600, 100) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, TemperatureMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle tempOffset/humidityOffset preference infochanged", + function() + local updates = { + preferences = { --offsets doesn't effect to the output value in this TC + tempOffset = -5, + humidityOffset = -5 + } + } + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) + mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + RelativeHumidity.attributes.MeasuredValue:build_test_attr_report(mock_device, 7900) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 }))) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua index 1defdb1b71..f01b9534bb 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -55,7 +44,48 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 } ) @@ -75,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 17 } ) @@ -109,7 +142,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..69218a197a --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.sen_ill.agl01" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua index ed4c5a476c..0eb5e1b059 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local zcl_commands = require "st.zigbee.zcl.global_commands" @@ -19,9 +22,6 @@ local FREQUENCY_ATTRIBUTE_ID = 0x0000 local FREQUENCY_DEFAULT_VALUE = 5 local FREQUENCY_PREF = "frequencyPref" -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.sen_ill.agl01" } -} local configuration = { { @@ -42,14 +42,6 @@ local configuration = { } } -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function detection_frequency_capability_handler(driver, device, command) local frequency = command.args.frequency @@ -69,24 +61,27 @@ local function device_init(driver, device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end +local function do_configure(self, device) + device:configure() + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, + MFG_CODE, data_types.Uint8, 1)) +end + local function added_handler(self, device) device:emit_event(capabilities.illuminanceMeasurement.illuminance(0)) device:emit_event(detectionFrequency.detectionFrequency(FREQUENCY_DEFAULT_VALUE, {visibility = {displayed = false}})) device:emit_event(capabilities.battery.battery(100)) - - device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, - MFG_CODE, data_types.Uint8, 1)) end local aqara_illuminance_handler = { NAME = "Aqara Illuminance Handler", lifecycle_handlers = { init = device_init, + doConfigure = do_configure, added = added_handler }, capability_handlers = { @@ -101,7 +96,7 @@ local aqara_illuminance_handler = { } } }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_illuminance_handler diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua index ea159c323a..6f9d3c5554 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua @@ -1,29 +1,27 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" +local do_configure = function(self, device) + device:configure() + device:refresh() +end + local zigbee_illuminance_driver = { supported_capabilities = { capabilities.illuminanceMeasurement, capabilities.battery }, - sub_drivers = { - require("aqara") - } + lifecycle_handlers = { + doConfigure = do_configure, + }, + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_illuminance_driver, zigbee_illuminance_driver.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..6211f46578 --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua index 6f180711d8..fb97045c03 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -35,9 +24,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -57,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 17 } ) @@ -76,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) @@ -109,7 +102,12 @@ test.register_coroutine_test( zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) } ) - end + test.socket.zigbee:__expect_send({ mock_device.id, IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua index d3e8e225a7..32ee9eae11 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -20,6 +9,10 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local write_attribute_response = require "st.zigbee.zcl.global_commands.write_attribute_response" +local zcl_messages = require "st.zigbee.zcl" test.add_package_capability("detectionFrequency.yaml") local IlluminanceMeasurement = clusters.IlluminanceMeasurement @@ -30,8 +23,10 @@ local detectionFrequency = capabilities["stse.detectionFrequency"] local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F +local FREQUENCY_ATTRIBUTE_ID = 0x0000 local FREQUENCY_DEFAULT_VALUE = 5 +local FREQUENCY_PREF = "frequencyPref" local mock_device = test.mock_device.build_test_zigbee_device( { @@ -49,9 +44,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -64,15 +57,14 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", detectionFrequency.detectionFrequency(FREQUENCY_DEFAULT_VALUE, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE - , data_types.Uint8, 1) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "Configure should configure all necessary attributes", + "Handle doConfigure lifecycle", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") @@ -100,8 +92,16 @@ test.register_coroutine_test( zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) } ) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE + , data_types.Uint8, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -121,6 +121,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 17 } ) @@ -140,6 +143,61 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 + } +) + +local function build_write_attr_res(cluster, status) + local addr_header = messages.AddressHeader( + mock_device:get_short_address(), + mock_device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local write_attribute_body = write_attribute_response.WriteAttributeResponse(status, {}) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(write_attribute_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = write_attribute_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +test.register_coroutine_test( + "Handle setDetectionFrequency capability command", + function() + local frequency = 10 + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.detectionFrequency", component = "main", command = "setDetectionFrequency", args = { frequency } } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, FREQUENCY_ATTRIBUTE_ID, + MFG_CODE, data_types.Uint16, frequency) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle write attr res on PRIVATE_CLUSTER_ID emits detectionFrequency", + function() + mock_device:set_field(FREQUENCY_PREF, FREQUENCY_DEFAULT_VALUE) + test.socket.zigbee:__queue_receive({ mock_device.id, + build_write_attr_res(PRIVATE_CLUSTER_ID, 0x00) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + detectionFrequency.detectionFrequency(FREQUENCY_DEFAULT_VALUE, { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-lock/fingerprints.yml b/drivers/SmartThings/zigbee-lock/fingerprints.yml index d7c1a0e974..309a0f2883 100644 --- a/drivers/SmartThings/zigbee-lock/fingerprints.yml +++ b/drivers/SmartThings/zigbee-lock/fingerprints.yml @@ -1,4 +1,35 @@ zigbeeManufacturer: + # GELUBU + - id: "GELUBU/S93" + deviceLabel: GELUBU Door Lock S93 + manufacturer: GELUBU + model: S93 + deviceProfileName: lock-battery + - id: "GELUBU/G30" + deviceLabel: GELUBU Door Lock G30 + manufacturer: GELUBU + model: G30 + deviceProfileName: lock-battery + - id: "GELUBU/R60" + deviceLabel: GELUBU Door Lock R60 + manufacturer: GELUBU + model: R60 + deviceProfileName: lock-battery + - id: "GELUBU/R50" + deviceLabel: GELUBU Door Lock R50 + manufacturer: GELUBU + model: R50 + deviceProfileName: lock-battery + - id: "GELUBU/P90" + deviceLabel: GELUBU Door Lock P90 + manufacturer: GELUBU + model: P90 + deviceProfileName: lock-battery + - id: "GELUBU/R71" + deviceLabel: GELUBU Door Lock R71 + manufacturer: GELUBU + model: R71 + deviceProfileName: lock-battery # YALE - id: "Yale YRD220/240" deviceLabel: "Yale Door Lock" @@ -124,6 +155,83 @@ zigbeeManufacturer: manufacturer: model: E261-KR0B0Z0-HA deviceProfileName: lock-battery + # TOTEM + - id: TOTEM/G30 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: G30 + deviceProfileName: lock-battery + - id: TOTEM/H60/H90 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: H60/H90 + deviceProfileName: lock-battery + - id: TOTEM/N60 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: N60 + deviceProfileName: lock-battery + - id: TOTEM/P30 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: P30 + deviceProfileName: lock-battery + - id: TOTEM/P70 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: P70 + deviceProfileName: lock-battery + - id: TOTEM/P90 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: P90 + deviceProfileName: lock-battery + - id: TOTEM/Q30 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: Q30 + deviceProfileName: lock-battery + - id: TOTEM/R50 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: R50 + deviceProfileName: lock-battery + - id: TOTEM/R60 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: R60 + deviceProfileName: lock-battery + - id: TOTEM/R71 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: R71 + deviceProfileName: lock-battery + - id: TOTEM/S50V + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: S50V + deviceProfileName: lock-battery + - id: TOTEM/S90 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: S90 + deviceProfileName: lock-battery + - id: TOTEM/S93 + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: S93 + deviceProfileName: lock-battery + - id: TOTEM/W31V + deviceLabel: TOTEM Door Lock + manufacturer: TOTEM + model: W31V + deviceProfileName: lock-battery + # Greentown + - id: Greentown/SG20 + deviceLabel: Greentown Lock(SG20) + manufacturer: Zhenchen + model: SG20 + deviceProfileName: lock-battery zigbeeGeneric: - id: "genericLock" deviceLabel: Zigbee Lock diff --git a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml b/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml index 159e6939f3..c9c98898c6 100644 --- a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua new file mode 100644 index 0000000000..7d7d566118 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local fingerprints = require("bad-battery-reporter.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("bad-battery-reporter") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua new file mode 100644 index 0000000000..3024aeeace --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local BAD_BATTERY_REPORTING_LOCK_FINGERPRINTS = { + { mfr = "Yale", model = "YRD220/240 TSDB" }, + { mfr = "Yale", model = "YRL220 TS LL" }, + { mfr = "Yale", model = "YRD210 PB DB" }, + { mfr = "Yale", model = "YRL210 PB LL" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +return BAD_BATTERY_REPORTING_LOCK_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua new file mode 100644 index 0000000000..5fbdabd1b7 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua @@ -0,0 +1,26 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + + + +local battery_report_handler = function(driver, device, value) + device:emit_event(capabilities.battery.battery(value.value)) +end + +local bad_battery_reporter_driver = { + NAME = "Bad Battery Reporter Driver", + zigbee_handlers = { + attr = { + [clusters.PowerConfiguration.ID] = { + [clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_report_handler + } + } + }, + can_handle = require("bad-battery-reporter.can_handle") +} + +return bad_battery_reporter_driver diff --git a/drivers/SmartThings/zigbee-lock/src/configurations.lua b/drivers/SmartThings/zigbee-lock/src/configurations.lua index a2429252b0..88e4e59f80 100644 --- a/drivers/SmartThings/zigbee-lock/src/configurations.lua +++ b/drivers/SmartThings/zigbee-lock/src/configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index 091384e3da..b8b290af4d 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -1,448 +1,159 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- Zigbee Driver utilities +local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" local device_management = require "st.zigbee.device_management" -local ZigbeeDriver = require "st.zigbee" - --- Zigbee Spec Utils -local clusters = require "st.zigbee.zcl.clusters" -local Alarm = clusters.Alarms -local LockCluster = clusters.DoorLock -local PowerConfiguration = clusters.PowerConfiguration - --- Capabilities -local capabilities = require "st.capabilities" -local Battery = capabilities.battery -local Lock = capabilities.lock -local LockCodes = capabilities.lockCodes - --- Enums -local UserStatusEnum = LockCluster.types.DrlkUserStatus -local UserTypeEnum = LockCluster.types.DrlkUserType -local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode - -local socket = require "cosock.socket" -local lock_utils = require "lock_utils" - -local DELAY_LOCK_EVENT = "_delay_lock_event" -local MAX_DELAY = 10 - -local reload_all_codes = function(driver, device, command) - -- starts at first user code index then iterates through all lock codes as they come in - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MinPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - end - if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end - device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) - device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) -end - -local refresh = function(driver, device, cmd) - device:refresh() - device:send(LockCluster.attributes.LockState:read(device)) - device:send(Alarm.attributes.AlarmCount:read(device)) - -- we can't determine from fingerprints if devices support lock codes, so - -- here in the driver we'll do a check once to see if the device responds here - -- and if it does, we'll switch it to a profile with lock codes - if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(lock_utils.CHECKED_CODE_SUPPORT) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - -- we won't make this value persist because it's not that important - device:set_field(lock_utils.CHECKED_CODE_SUPPORT, true) - end -end - -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - - device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) - device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - - device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) - device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - -- Don't send a reload all codes if this is a part of migration - if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then - device.thread:call_with_delay(2, function(d) - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end) - else - device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local table_utils = require "lock_utils.tables" +local zigbee_handlers = require "lock_handlers.zigbee_responses" +local capability_handlers = require "lock_handlers.capabilities" + +local LockLifecycle = {} + +function LockLifecycle.device_added(driver, device) + if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then + -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. + -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, + -- and should be migrated manually by the user. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore end + -- set initial state + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) end -local alarm_handler = function(driver, device, zb_mess) - local ALARM_REPORT = { - [0] = Lock.lock.unknown(), - [1] = Lock.lock.unknown(), - -- Events 16-19 are low battery events, but are presented as descriptionText only - } - if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then - device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) +function LockLifecycle.init(driver, device) + -- Restore users/credentials capability state from the persistent store in case + -- the capability state cache was wiped since the last driver run. + table_utils.restore_from_persistent_store(device) + + local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) + if lock_pins_supported_by_profile and device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) == true then + -- ensure lockCodes capability state is reflected correctly for already migrated devices + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + elseif not lock_pins_supported_by_profile then + -- generically fingerprinted profiles do not have any codes/users/credentials capabilities. + -- We should check its PIN users if it should be re-profiled. + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) end end -local get_pin_response_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {codeName = lock_utils.get_code_name(device, code_slot)} - if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then - -- Code slot is occupied - event.value = code_slot .. lock_utils.get_change_type(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - else - -- Code slot is unoccupied - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - -- Code has been deleted - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - else - -- Code is unset - event.value = code_slot .. " unset" - device:emit_event(event) - end - end +function LockLifecycle.do_configure(driver, device) + device:send(device_management.build_bind_request(device, clusters.PowerConfiguration.ID, driver.environment_info.hub_zigbee_eui)) + device:send(clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - code_slot = tonumber(code_slot) - if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then - -- the code we're checking has arrived - if (code_slot >= device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME)) then - device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) - device:set_field(lock_utils.CHECKING_CODE, nil) - else - local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 - device:set_field(lock_utils.CHECKING_CODE, checkingCode) - device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) - end - end -end + device:send(device_management.build_bind_request(device, clusters.DoorLock.ID, driver.environment_info.hub_zigbee_eui)) + device:send(clusters.DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0)) -local programming_event_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {} - if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - -- Master code changed - event.value = "0 set" - event.data = {codeName = "Master Code"} - device:emit_event(event) - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (zb_mess.body.zcl_body.user_id.value == 0xFF) then - -- All codes deleted - for cs, _ in pairs(lock_utils.get_lock_codes(device)) do - lock_utils.code_deleted(device, cs) - end - lock_utils.lock_codes_event(device, {}) - else - -- One code deleted - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - end - end - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then - -- Code added or changed - local change_type = lock_utils.get_change_type(device, code_slot) - local code_name = lock_utils.get_code_name(device, code_slot) - event.value = code_slot .. change_type - event.data = {codeName = code_name} - device:emit_event(event) - if (change_type == " set") then - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = code_name - lock_utils.lock_codes_event(device, lock_codes) - end - end -end + device:send(device_management.build_bind_request(device, clusters.Alarms.ID, driver.environment_info.hub_zigbee_eui)) + device:send(clusters.Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) -local handle_max_codes = function(driver, device, value) - if value.value ~= 0 then - -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, - -- but it gave us a non-zero number of pin users, so we want to switch the profile - if not device:supports_capability_by_id(LockCodes.ID) then - device:try_update_metadata({profile = "base-lock"}) -- switch to a lock with codes - lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes - if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes - -- so we'll load them manually - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end - end - device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) + if device:supports_capability(capabilities.lockCredentials) then + -- ensure our user/credential state is accurate to the current device state + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) end end -local handle_max_code_length = function(driver, device, value) - device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) -end - -local handle_min_code_length = function(driver, device, value) - device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) -end - -local update_codes = function(driver, device, command) - local delay = 0 - -- args.codes is json - for name, code in pairs(command.args.codes) do - -- these seem to come in the format "code[slot#]: code" - local code_slot = tonumber(string.gsub(name, "code", ""), 10) - if (code_slot ~= nil) then - if (code ~= nil and (code ~= "0" and code ~= "")) then - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.SetPINCode(device, - code_slot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - code)) - end) - delay = delay + 2 - else - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) - end) - delay = delay + 2 - end - device.thread:call_with_delay(delay, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) - end) - delay = delay + 2 +function LockLifecycle.info_changed(driver, device, event, args) + local profile_switched = device.profile.id ~= args.old_st_store.profile.id + if profile_switched and device:supports_capability(capabilities.lockCodes) then + -- ensure all slga migration steps are run, and that the latest device state is synced to the driver. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) end - end -end - -local delete_code = function(driver, device, command) - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) - device.thread:call_with_delay(2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) -end - -local request_code = function(driver, device, command) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) -end - -local set_code = function(driver, device, command) - if (command.args.codePIN == "") then + -- ensure all requisite initial state is set driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {command.args.codeSlot, command.args.codeName} + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} }) - else - device:send(LockCluster.server.commands.SetPINCode(device, - command.args.codeSlot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - command.args.codePIN) - ) - if (command.args.codeName ~= nil) then - -- wait for confirmation from the lock to commit this to memory - -- Groovy driver has a lot more info passed here as a description string, may need to be investigated - local codeState = device:get_field(lock_utils.CODE_STATE) or {} - codeState["setName"..command.args.codeSlot] = command.args.codeName - device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) - end - - device.thread:call_with_delay(4, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) - end -end - -local name_slot = function(driver, device, command) - local code_slot = tostring(command.args.codeSlot) - local lock_codes = lock_utils.get_lock_codes(device) - if (lock_codes[code_slot] ~= nil) then - lock_codes[code_slot] = command.args.codeName - device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) - lock_utils.lock_codes_event(device, lock_codes) - end -end - -local function device_added(driver, device) - lock_utils.populate_state_from_data(device) - - driver:inject_capability_command(device, { - capability = capabilities.refresh.ID, - command = capabilities.refresh.commands.refresh.NAME, - args = {} - }) -end - -local function init(driver, device) - lock_utils.populate_state_from_data(device) -end - --- The following two functions are from the lock defaults. They are in the base driver temporarily --- until the fix is widely released in the lua libs -local lock_state_handler = function(driver, device, value, zb_rx) - local attr = capabilities.lock.lock - local LOCK_STATE = { - [value.NOT_FULLY_LOCKED] = attr.unknown(), - [value.LOCKED] = attr.locked(), - [value.UNLOCKED] = attr.unlocked(), - [value.UNDEFINED] = attr.unknown(), - } - - -- this is where we decide whether or not we need to delay our lock event because we've - -- observed it coming before the event (or we're starting to compute the timer) - local delay = device:get_field(DELAY_LOCK_EVENT) or 100 - if (delay < MAX_DELAY) then - device.thread:call_with_delay(delay+.5, function () - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value]) - end) - else - device:set_field(DELAY_LOCK_EVENT, socket.gettime()) - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value]) + -- ensure our user/credential state is accurate to the current device state + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) end end -local lock_operation_event_handler = function(driver, device, zb_rx) - local event_code = zb_rx.body.zcl_body.operation_event_code.value - local source = zb_rx.body.zcl_body.operation_event_source.value - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" - } - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() - } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" - else - event.data.method = METHOD[source] - end - if (source == 0) then --keypad - local code_id = zb_rx.body.zcl_body.user_id.value - local code_name = "Code "..code_id - local lock_codes = device:get_field("lockCodes") - if (lock_codes ~= nil and - lock_codes[code_id] ~= nil) then - code_name = lock_codes[code_id] - end - event.data = {method = METHOD[0], codeId = code_id .. "", codeName = code_name} - end - - -- if this is an event corresponding to a recently-received attribute report, we - -- want to set our delay timer for future lock attribute report events - if device:get_latest_state( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) - end - end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) +function LockLifecycle.driver_switched(driver, device) + if device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME) == true then + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) end + device:try_update_metadata({ provisioning_state = "PROVISIONED" }) end local zigbee_lock_driver = { - supported_capabilities = { - Lock, - LockCodes, - Battery, + lifecycle_handlers = { + added = LockLifecycle.device_added, + init = LockLifecycle.init, + doConfigure = LockLifecycle.do_configure, + driver_switched = LockLifecycle.driver_switched, + infoChanged = LockLifecycle.info_changed, }, zigbee_handlers = { cluster = { - [Alarm.ID] = { - [Alarm.client.commands.Alarm.ID] = alarm_handler + [clusters.Alarms.ID] = { + [clusters.Alarms.client.commands.Alarm.ID] = zigbee_handlers.alarm }, - [LockCluster.ID] = { - [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, - [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, - [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + [clusters.DoorLock.ID] = { + [clusters.DoorLock.client.commands.ClearAllPINCodesResponse.ID] = zigbee_handlers.clear_all_pin_codes_response, + [clusters.DoorLock.client.commands.ClearPINCodeResponse.ID] = zigbee_handlers.clear_pin_code_response, + [clusters.DoorLock.client.commands.GetPINCodeResponse.ID] = zigbee_handlers.get_pin_code_response, + [clusters.DoorLock.client.commands.ProgrammingEventNotification.ID] = zigbee_handlers.programming_event_notification, + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = zigbee_handlers.operating_event_notification, + [clusters.DoorLock.client.commands.SetPINCodeResponse.ID] = zigbee_handlers.set_pin_code_response, } }, attr = { - [LockCluster.ID] = { - [LockCluster.attributes.LockState.ID] = lock_state_handler, - [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, - [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, - [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes + [clusters.DoorLock.ID] = { + [clusters.DoorLock.attributes.LockState.ID] = zigbee_handlers.lock_state, + [clusters.DoorLock.attributes.MaxPINCodeLength.ID] = zigbee_handlers.max_pin_code_length, + [clusters.DoorLock.attributes.MinPINCodeLength.ID] = zigbee_handlers.min_pin_code_length, + [clusters.DoorLock.attributes.NumberOfPINUsersSupported.ID] = zigbee_handlers.number_of_pin_users_supported, } } }, capability_handlers = { - [LockCodes.ID] = { - [LockCodes.commands.updateCodes.NAME] = update_codes, - [LockCodes.commands.deleteCode.NAME] = delete_code, - [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [LockCodes.commands.requestCode.NAME] = request_code, - [LockCodes.commands.setCode.NAME] = set_code, - [LockCodes.commands.nameSlot.NAME] = name_slot, + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = capability_handlers.lock, + [capabilities.lock.commands.unlock.NAME] = capability_handlers.unlock, + }, + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = capability_handlers.add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = capability_handlers.update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = capability_handlers.delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = capability_handlers.delete_all_users, + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = capability_handlers.add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = capability_handlers.update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = capability_handlers.delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = capability_handlers.delete_all_credentials, }, [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = refresh - } + [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh, + }, }, - sub_drivers = { - require("samsungsds"), - require("yale"), - require("yale-fingerprint-lock"), - require("lock-without-codes") + supported_capabilities = { + capabilities.lock, + capabilities.lockCredentials, + capabilities.lockUsers, + capabilities.battery, }, - lifecycle_handlers = { - doConfigure = do_configure, - added = device_added, - init = init, - } + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_lock_driver, zigbee_lock_driver.supported_capabilities) -local lock = ZigbeeDriver("zigbee-lock", zigbee_lock_driver) -lock:run() +local driver = ZigbeeDriver("zigbee-lock", zigbee_lock_driver) +driver:run() diff --git a/drivers/SmartThings/zigbee-lock/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-lock/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua new file mode 100644 index 0000000000..81ff8f0e8a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/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 consts = require "lock_utils.constants" + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + if not slga_migrated then + local subdriver = require("legacy-handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua new file mode 100644 index 0000000000..b9d5ce78ad --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -0,0 +1,495 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Zigbee Driver utilities +local device_management = require "st.zigbee.device_management" + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +-- Capabilities +local capabilities = require "st.capabilities" +local Battery = capabilities.battery +local Lock = capabilities.lock +local LockCodes = capabilities.lockCodes + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode + +local socket = require "cosock.socket" +local lock_utils = require "legacy-handlers.legacy_lock_utils" + +local DELAY_LOCK_EVENT = "_delay_lock_event" +local MAX_DELAY = 10 + +local reload_all_codes = function(driver, device, command) + -- starts at first user code index then iterates through all lock codes as they come in + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + end + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end + device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) +end + +local refresh = function(driver, device, cmd) + device:refresh() + device:send(LockCluster.attributes.LockState:read(device)) + device:send(Alarm.attributes.AlarmCount:read(device)) + -- we can't determine from fingerprints if devices support lock codes, so + -- here in the driver we'll do a check once to see if the device responds here + -- and if it does, we'll switch it to a profile with lock codes + if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(lock_utils.CHECKED_CODE_SUPPORT) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + -- we won't make this value persist because it's not that important + device:set_field(lock_utils.CHECKED_CODE_SUPPORT, true) + end +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + -- Don't send a reload all codes if this is a part of migration + if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then + device.thread:call_with_delay(2, function(d) + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end) + else + device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) + end +end + +local alarm_handler = function(driver, device, zb_mess) + local ALARM_REPORT = { + [0] = Lock.lock.unknown(), + [1] = Lock.lock.unknown(), + -- Events 16-19 are low battery events, but are presented as descriptionText only + } + if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) + end +end + +local get_pin_response_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = {codeName = lock_utils.get_code_name(device, code_slot)} + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then + -- Code slot is occupied + event.value = code_slot .. lock_utils.get_change_type(device, code_slot) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + else + -- Code slot is unoccupied + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + -- Code has been deleted + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + else + -- Code is unset + event.value = code_slot .. " unset" + device:emit_event(event) + end + end + + code_slot = tonumber(code_slot) + if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then + -- the code we're checking has arrived + local last_slot = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - 1 + if (code_slot >= last_slot) then + device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) + device:set_field(lock_utils.CHECKING_CODE, nil) + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = {} + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code changed + event.value = "0 set" + event.data = {codeName = "Master Code"} + device:emit_event(event) + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (zb_mess.body.zcl_body.user_id.value == 0xFF) then + -- All codes deleted + for cs, _ in pairs(lock_utils.get_lock_codes(device)) do + lock_utils.code_deleted(device, cs) + end + lock_utils.lock_codes_event(device, {}) + else + -- One code deleted + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then + -- Code added or changed + local change_type = lock_utils.get_change_type(device, code_slot) + local code_name = lock_utils.get_code_name(device, code_slot) + event.value = code_slot .. change_type + event.data = {codeName = code_name} + device:emit_event(event) + if (change_type == " set") then + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + end + end +end + +local handle_max_codes = function(driver, device, value) + if value.value ~= 0 then + -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, + -- but it gave us a non-zero number of pin users, so we want to switch the profile + if not device:supports_capability_by_id(LockCodes.ID) then + device:try_update_metadata({ profile = "base-lock" }) -- switch to a lock with codes + lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes + if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes + -- so we'll load them manually + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end + end + device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) + end +end + +local handle_max_code_length = function(driver, device, value) + device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) +end + +local handle_min_code_length = function(driver, device, value) + device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) +end + +local update_codes = function(driver, device, command) + local delay = 0 + -- args.codes is json + for name, code in pairs(command.args.codes) do + -- these seem to come in the format "code[slot#]: code" + local code_slot = tonumber(string.gsub(name, "code", ""), 10) + if (code_slot ~= nil) then + if (code ~= nil and (code ~= "0" and code ~= "")) then + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.SetPINCode(device, + code_slot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + code)) + end) + delay = delay + 2 + else + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) + end) + delay = delay + 2 + end + device.thread:call_with_delay(delay, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) + end) + delay = delay + 2 + end + end +end + +local delete_code = function(driver, device, command) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) +end + +local request_code = function(driver, device, command) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) +end + +local set_code = function(driver, device, command) + if (command.args.codePIN == "") then + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = { command.args.codeSlot, command.args.codeName } + }) + else + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.codeSlot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.codePIN) + ) + if (command.args.codeName ~= nil) then + -- wait for confirmation from the lock to commit this to memory + -- Groovy driver has a lot more info passed here as a description string, may need to be investigated + local codeState = device:get_field(lock_utils.CODE_STATE) or {} + codeState["setName" .. command.args.codeSlot] = command.args.codeName + device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) + end + + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) + end +end + +local name_slot = function(driver, device, command) + local code_slot = tostring(command.args.codeSlot) + local lock_codes = lock_utils.get_lock_codes(device) + if (lock_codes[code_slot] ~= nil) then + lock_codes[code_slot] = command.args.codeName + device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) + lock_utils.lock_codes_event(device, lock_codes) + end +end + +local function init(driver, device) + lock_utils.populate_state_from_data(device) + -- temp fix before this can be changed to non-persistent + device:set_field(lock_utils.CODE_STATE, nil, { persist = true }) +end + +-- The following two functions are from the lock defaults. They are in the base driver temporarily +-- until the fix is widely released in the lua libs +local lock_state_handler = function(driver, device, value, zb_rx) + local attr = capabilities.lock.lock + local LOCK_STATE = { + [value.NOT_FULLY_LOCKED] = attr.unknown(), + [value.LOCKED] = attr.locked(), + [value.UNLOCKED] = attr.unlocked(), + [value.UNDEFINED] = attr.unknown(), + } + + -- this is where we decide whether or not we need to delay our lock event because we've + -- observed it coming before the event (or we're starting to compute the timer) + local delay = device:get_field(DELAY_LOCK_EVENT) or 100 + if (delay < MAX_DELAY) then + device.thread:call_with_delay(delay+.5, function () + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end) + else + device:set_field(DELAY_LOCK_EVENT, socket.gettime()) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end +end + +local lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local lock_codes = device:get_field("lockCodes") + if (lock_codes ~= nil and + lock_codes[code_id] ~= nil) then + code_name = lock_codes[code_id] + end + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + if device:get_latest_state( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + +local function lock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) +end + +local function unlock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) +end + +local migrate = function(driver, device, command) + local post_migration_consts = require "lock_utils.constants" + local LockUsers = capabilities.lockUsers + local LockCredentials = capabilities.lockCredentials + + -- set supported credentials + device:emit_event(LockCredentials.supportedCredentials({ post_migration_consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + + -- migrate max/min credential length + local cached_min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) + local cached_max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) + if cached_min_code_len then + device:emit_event(LockCredentials.minPinCodeLen(cached_min_code_len, { visibility = { displayed = false } })) + end + if cached_max_code_len then + device:emit_event(LockCredentials.maxPinCodeLen(cached_max_code_len, { visibility = { displayed = false } })) + end + + -- migrate total codes supported + local cached_max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) + if cached_max_codes then + device:emit_event(LockCredentials.pinUsersSupported(cached_max_codes, { visibility = { displayed = false } })) + device:emit_event(LockUsers.totalUsersSupported(cached_max_codes, { visibility = { displayed = false } })) + end + + -- migrate stored lock codes slots and user names + local users, credentials = {}, {} + local cached_lock_codes = lock_utils.get_lock_codes(device) + local ordered_code_slots = {} + for code_slot_str, _ in pairs(cached_lock_codes) do + table.insert(ordered_code_slots, tonumber(code_slot_str)) + end + table.sort(ordered_code_slots) + for _, code_slot in ipairs(ordered_code_slots) do + local code_name = cached_lock_codes[tostring(code_slot)] + -- cached_lock_codes is formatted as { ["1"] = "Guest 1", ["2"] = "Guest 2" }, etc + if code_slot then + table.insert(users, { userIndex = code_slot, userType = "guest", userName = code_name }) + table.insert(credentials, { userIndex = code_slot, credentialIndex = code_slot, credentialType = post_migration_consts.CRED_TYPE_PIN, credentialName = code_name }) + end + end + -- manually ensure user/cred state is persisted, then emit events to populate the new capabilities with the migrated data. + device:set_field("persistedUsers", users, { persist = true }) + device:set_field("persistedCredentials", credentials, { persist = true }) + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.credentials(credentials, { visibility = { displayed = false } })) + + -- set and persist the migration complete flag and emit migrated event. Legacy subdriver will not be used again after this. + device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(post_migration_consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + -- try to refresh all unstored state + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) +end + +local legacy_capabilities_driver = { + NAME = "Lock Driver Using LockCodes Capability", + supported_capabilities = { + Lock, + LockCodes, + Battery, + }, + zigbee_handlers = { + cluster = { + [Alarm.ID] = { + [Alarm.client.commands.Alarm.ID] = alarm_handler + }, + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + } + }, + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.LockState.ID] = lock_state_handler, + [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, + [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes, + } + } + }, + capability_handlers = { + [LockCodes.ID] = { + [LockCodes.commands.updateCodes.NAME] = update_codes, + [LockCodes.commands.deleteCode.NAME] = delete_code, + [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [LockCodes.commands.requestCode.NAME] = request_code, + [LockCodes.commands.setCode.NAME] = set_code, + [LockCodes.commands.nameSlot.NAME] = name_slot, + [LockCodes.commands.migrate.NAME] = migrate, + }, + [Lock.ID] = { + [Lock.commands.lock.NAME] = lock, + [Lock.commands.unlock.NAME] = unlock, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh + } + }, + sub_drivers = require("legacy-handlers.sub_drivers"), + lifecycle_handlers = { + doConfigure = do_configure, + init = init, + }, + health_check = false, + can_handle = require("legacy-handlers.can_handle") +} + +return legacy_capabilities_driver diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua new file mode 100644 index 0000000000..6afa0aea3a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua @@ -0,0 +1,89 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local utils = require "st.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local LockCodes = capabilities.lockCodes + + +local legacy_lock_utils = { + -- Constants + LOCK_CODES = "lockCodes", + LOCK_USERS = "lockUsers", + CHECKING_CODE = "checkingCode", + CODE_STATE = "codeState", + MIGRATION_COMPLETE = "migrationComplete", + MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped", + CHECKED_CODE_SUPPORT = "checkedCodeSupport", +} + +legacy_lock_utils.get_lock_codes = function(device) + local lc = device:get_field(legacy_lock_utils.LOCK_CODES) + return lc ~= nil and lc or {} +end + +legacy_lock_utils.lock_codes_event = function(device, lock_codes) + device:set_field(legacy_lock_utils.LOCK_CODES, lock_codes, { persist = true } ) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(utils.deep_copy(lock_codes)), { visibility = { displayed = false } })) +end + + +function legacy_lock_utils.get_code_name(device, code_id) + if (device:get_field(legacy_lock_utils.CODE_STATE) ~= nil and device:get_field(legacy_lock_utils.CODE_STATE)["setName"..code_id] ~= nil) then + -- this means a code set operation succeeded + return device:get_field(legacy_lock_utils.CODE_STATE)["setName"..code_id] + elseif (legacy_lock_utils.get_lock_codes(device)[code_id] ~= nil) then + return legacy_lock_utils.get_lock_codes(device)[code_id] + else + return "Code " .. code_id + end +end + +function legacy_lock_utils.get_change_type(device, code_id) + if (legacy_lock_utils.get_lock_codes(device)[code_id] == nil) then + return " set" + else + return " changed" + end +end + +function legacy_lock_utils.reset_code_state(device, code_slot) + local codeState = device:get_field(legacy_lock_utils.CODE_STATE) + if (codeState ~= nil) then + codeState["setName".. code_slot] = nil + codeState["setCode".. code_slot] = nil + device:set_field(legacy_lock_utils.CODE_STATE, codeState, { persist = true }) + end +end + +function legacy_lock_utils.code_deleted(device, code_slot) + local lock_codes = legacy_lock_utils.get_lock_codes(device) + local event = LockCodes.codeChanged(code_slot.." deleted", { state_change = true }) + event.data = {codeName = legacy_lock_utils.get_code_name(device, code_slot)} + lock_codes[code_slot] = nil + device:emit_event(event) + legacy_lock_utils.reset_code_state(device, code_slot) + return lock_codes +end + +function legacy_lock_utils.populate_state_from_data(device) + if device.data.lockCodes ~= nil and device:get_field(legacy_lock_utils.MIGRATION_COMPLETE) ~= true then + -- build the lockCodes table + local lockCodes = {} + local lc_data = json.decode(device.data.lockCodes) + for k, v in pairs(lc_data) do + lockCodes[k] = v + end + -- Populate the devices `lockCodes` field + device:set_field(legacy_lock_utils.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) + -- Populate the devices state history cache + device.state_cache["main"] = device.state_cache["main"] or {} + device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} + device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} + + device:set_field(legacy_lock_utils.MIGRATION_COMPLETE, true, { persist = true }) + end +end + +return legacy_lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua new file mode 100644 index 0000000000..326df876df --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local fingerprints = require("legacy-handlers.lock-without-codes.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("legacy-handlers.lock-without-codes") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua new file mode 100644 index 0000000000..63ae82b46c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local LOCK_WITHOUT_CODES_FINGERPRINTS = { + { model = "E261-KR0B0Z0-HA" }, + { mfr = "Danalock", model = "V3-BTZB" } +} + +return LOCK_WITHOUT_CODES_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua similarity index 65% rename from drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua index 46f0867857..4522580297 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local configurationMap = require "configurations" local clusters = require "st.zigbee.zcl.clusters" @@ -19,26 +9,13 @@ local capabilities = require "st.capabilities" local DoorLock = clusters.DoorLock local PowerConfiguration = clusters.PowerConfiguration -local LOCK_WITHOUT_CODES_FINGERPRINTS = { - { model = "E261-KR0B0Z0-HA" }, - { mfr = "Danalock", model = "V3-BTZB" } -} -local function can_handle_lock_without_codes(opts, driver, device) - for _, fingerprint in ipairs(LOCK_WITHOUT_CODES_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end @@ -96,7 +73,7 @@ local lock_without_codes = { } } }, - can_handle = can_handle_lock_without_codes + can_handle = require("legacy-handlers.lock-without-codes.can_handle") } return lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua new file mode 100644 index 0000000000..8cbfd6701f --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers.yale"), + lazy_load_if_possible("legacy-handlers.yale-fingerprint-lock"), + lazy_load_if_possible("legacy-handlers.lock-without-codes") +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua new file mode 100644 index 0000000000..3baa8f368c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local fingerprints = require("yale-fingerprint-lock.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("legacy-handlers.yale-fingerprint-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua new file mode 100644 index 0000000000..7e982fd943 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua @@ -0,0 +1,27 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local LockCluster = clusters.DoorLock +local LockCodes = capabilities.lockCodes + +local YALE_FINGERPRINT_MAX_CODES = 0x1E +local handle_max_codes = function(driver, device, value) + device:emit_event(LockCodes.maxCodes(YALE_FINGERPRINT_MAX_CODES), { visibility = { displayed = false } }) +end + +local yale_fingerprint_lock_driver = { + NAME = "YALE Fingerprint Lock", + zigbee_handlers = { + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes + } + } + }, + can_handle = require("legacy-handlers.yale-fingerprint-lock.can_handle") +} + +return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua new file mode 100644 index 0000000000..832914f6ea --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function yale_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then + return true, require("legacy-handlers.yale") + end + return false +end + +return yale_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua similarity index 86% rename from drivers/SmartThings/zigbee-lock/src/yale/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua index e72d7dae3a..a85a9f8798 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + -- Zigbee Spec Utils local clusters = require "st.zigbee.zcl.clusters" @@ -24,7 +15,7 @@ local LockCodes = capabilities.lockCodes local UserStatusEnum = LockCluster.types.DrlkUserStatus local UserTypeEnum = LockCluster.types.DrlkUserType -local lock_utils = (require "lock_utils") +local lock_utils = (require "legacy-handlers.legacy_lock_utils") local reload_all_codes = function(driver, device, command) -- starts at first user code index then iterates through all lock codes as they come in @@ -51,10 +42,11 @@ local set_code = function(driver, device, command) args = {command.args.codeSlot, command.args.codeName} }) else + local user_type = command.args.codeSlot == 0 and UserTypeEnum.MASTER_USER or UserTypeEnum.UNRESTRICTED device:send(LockCluster.server.commands.SetPINCode(device, command.args.codeSlot, UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, + user_type, command.args.codePIN) ) if (command.args.codeName ~= nil) then @@ -150,11 +142,7 @@ local yale_door_lock_driver = { [LockCodes.commands.setCode.NAME] = set_code } }, - - sub_drivers = { require("yale.yale-bad-battery-reporter") }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" - end + can_handle = require("legacy-handlers.yale.can_handle") } return yale_door_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua new file mode 100644 index 0000000000..371cf7a765 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -0,0 +1,248 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local consts = require "lock_utils.constants" + +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType + + +local CapabilityHandlers = {} + + +-- [[ LOCK CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.lock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) +end + +function CapabilityHandlers.unlock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) +end + + +-- [[ LOCK USERS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- Find the smallest positive userIndex not already in the table + local next_available_index = tables.next_index(device, "users") + local status = tables.add_entry(device, "users", { + userIndex = next_available_index, + userName = command.args.userName, + userType = command.args.userType, + }) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = next_available_index } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, status, additional_info) +end + +function CapabilityHandlers.update_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + local result_status + if not tables.find_entry(device, "users", command.args.userIndex) then + result_status = tables.add_entry(device, "users", { + userIndex = command.args.userIndex, + userName = command.args.userName, + userType = command.args.userType, + }) + else + result_status = tables.update_entry(device, "users", + command.args.userIndex, + { userName = command.args.userName, userType = command.args.userType } + ) + end + + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, result_status, additional_info) +end + +function CapabilityHandlers.delete_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.BUSY) + return + end + + local associated_credentials = tables.find_all_entries_by(device, "credentials", "userIndex", command.args.userIndex) + for _, associated_credential in ipairs(associated_credentials) do + -- Set busy state with the full user+credential context BEFORE injecting. + -- Injected capability commands are schema-validated, so extra args like userIndex + -- would be stripped. By setting device fields here we preserve the full context. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE, { + userIndex = command.args.userIndex, + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + }) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + } + }) + end + if #associated_credentials == 0 then + -- No associated credentials: delete the user entry directly and report the result + local status = tables.delete_entry(device, "users", command.args.userIndex) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, status, additional_info) + end +end + +function CapabilityHandlers.delete_all_users(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + return + end + -- Set busy state with DELETE_ALL context BEFORE injecting so the response handler + -- knows to clear both tables and emit results for both capabilities. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE_ALL, {}) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteAllCredentials.NAME, + args = { credentialType = consts.CRED_TYPE_PIN } + }) +end + + +-- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- A userIndex of 0 means "auto-assign the next available slot" + local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex + local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + + if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) + return + end + + -- Set busy state and attempt to set the PIN on-device. + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = user_index, + credentialIndex = cred_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(LockCluster.server.commands.SetPINCode(device, + cred_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) +end + +function CapabilityHandlers.update_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) + return + end + + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { + userIndex = command.args.userIndex, + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.credentialIndex, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) +end + +function CapabilityHandlers.delete_credential(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == consts.LOCK_USERS.DELETE then + -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, credential_args.credentialIndex)) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) + else + -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. + local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) + if not found_cred then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) + return + end + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + userIndex = found_cred.userIndex, + }) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, command.args.credentialIndex)) + end +end + +function CapabilityHandlers.delete_all_credentials(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + -- Injected by deleteAllUsers; busy state was already set with LOCK_USERS.DELETE_ALL context. + device:send(LockCluster.server.commands.ClearAllPINCodes(device)) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + else + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE_ALL, command.args) + device:send(LockCluster.server.commands.ClearAllPINCodes(device)) + end +end + + +-- [[ REFRESH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.refresh(driver, device, cmd) + device:refresh() + device:send(LockCluster.attributes.LockState:read(device)) + device:send(Alarm.attributes.AlarmCount:read(device)) + + if device:supports_capability(capabilities.lockCredentials) then + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then + device:send(clusters.DoorLock.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then + device:send(clusters.DoorLock.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) or + (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) + end + elseif not device:supports_capability(capabilities.lockCodes) then + -- Generically fingerprinted devices may support PINs even though the profile does not yet + -- reflect it. Read NumberOfPINUsersSupported so the response handler can trigger a profile + -- migration when appropriate. + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) + end +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua new file mode 100644 index 0000000000..9d1c53947a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -0,0 +1,438 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local socket = require "cosock.socket" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" + +local ZigbeeHandlers = {} + + +-- [[ DOOR LOCK CLUSTER COMMAND RESPONSES ]] -- + +function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + -- zb response values + local set_pin_code_status = zb_rx.body.zcl_body.status.value + + local SetCodeStatus = clusters.DoorLock.types.DrlkSetCodeStatus + -- SUCCESS = 0 + -- GENERAL_FAILURE = 1 + -- MEMORY_FULL = 2 + -- DUPLICATE_CODE = 3 + + -- mapped failures states + local RESPONSE_RESULT_MAP = { + [SetCodeStatus.GENERAL_FAILURE] = consts.COMMAND_RESULT.FAILURE, + [SetCodeStatus.MEMORY_FULL] = consts.COMMAND_RESULT.RESOURCE_EXHAUSTED, + [SetCodeStatus.DUPLICATE_CODE] = consts.COMMAND_RESULT.DUPLICATE, + } + + -- apply result based on response and identify command result status + local result_status + if set_pin_code_status == SetCodeStatus.SUCCESS then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + tables.add_entry(device, "users", { + userIndex = credential_args_in_use.userIndex, + userName = "Guest " .. credential_args_in_use.userIndex, -- default + userType = "guest", -- default + }) + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = "Guest " .. credential_args_in_use.userIndex, -- default + }) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + end + elseif RESPONSE_RESULT_MAP[set_pin_code_status] then + result_status = RESPONSE_RESULT_MAP[set_pin_code_status] + else + result_status = consts.COMMAND_RESULT.FAILURE + end + + -- emit command result + if command_in_progress then + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) + lock_utils.clear_busy_state(device) + end +end + + +function ZigbeeHandlers.clear_all_pin_codes_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- zb response values + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + + local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus + -- PASS = 0 + -- FAIL = 1 + + -- apply result and identify command result statuses + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + -- Only clear the users table when this response is for a deleteAllUsers flow. + if command_in_progress == consts.LOCK_USERS.DELETE_ALL then + user_status = tables.delete_all_entries(device, "users") + end + credential_status = tables.delete_all_entries(device, "credentials") + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = consts.COMMAND_RESULT.FAILURE + credential_status = consts.COMMAND_RESULT.FAILURE + end + + -- emit command results + if command_in_progress == consts.LOCK_USERS.DELETE_ALL then + -- deleteAllUsers injects deleteAllCredentials, so both command results should be emitted. + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, user_status) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE_ALL then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + end + lock_utils.clear_busy_state(device) +end + + +function ZigbeeHandlers.clear_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- guard: avoid nil errors if the programming event failsafe already handled this command and cleared the busy state + if not credential_args_in_use then return end + + -- zb response values + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + + local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus + -- PASS = 0 + -- FAIL = 1 + + -- apply result and identify command result statuses + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + if command_in_progress == consts.LOCK_USERS.DELETE or tables.find_entry_by(device, "credentials", "userIndex", credential_args_in_use.userIndex) == nil then + user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + end + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = consts.COMMAND_RESULT.FAILURE + credential_status = consts.COMMAND_RESULT.FAILURE + end + + -- emit command results + if command_in_progress == consts.LOCK_USERS.DELETE then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE then + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + end + lock_utils.clear_busy_state(device) +end + + +function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- zb response values + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + + if command_in_progress == consts.SYNC.CODES_FROM_LOCK then + -- Only add entries for slots that are actually occupied on the lock. + local user_status = zb_rx.body.zcl_body.user_status.value + if user_status == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED then + -- If an entry already exists at this index, this will be a no-op. + tables.add_entry(device, "users", { + userIndex = user_id, + userName = "Guest " .. user_id, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = user_id, + credentialIndex = user_id, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. user_id, + }) + -- reset the consecutive unoccupied codes counter since we found an occupied code + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) + else + local consecutive_unoccupied_codes = (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1 + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, consecutive_unoccupied_codes) + end + + if user_id >= tables.get_max_entries(device, "credentials") or + (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then + -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. + device:set_field(consts.SYNC.CODE_INDEX, nil) + lock_utils.clear_busy_state(device) + else + local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 + device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) + end + end +end + + +-- [[ DOOR LOCK CLUSTER EVENT NOTIFICATIONS ]] -- + +function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) + -- zb response values + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + local event_code = tonumber(zb_rx.body.zcl_body.program_event_code.value) + + if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then + if user_id >= 256 then -- Index is incorrectly written on these devices. Attempt to shift it to get an actual value + user_id = user_id >> 8 + end + end + + local ProgramEventCode = clusters.DoorLock.types.ProgramEventCode + -- MASTER_CODE_CHANGED = 1 + -- PIN_CODE_ADDED = 2 + -- PIN_CODE_DELETED = 3 + -- PIN_CODE_CHANGED = 4 + -- RFID_CODE_ADDED = 5 + -- RFID_CODE_DELETED = 6 + + + -- failsafes: handle the case where we receive a programming event notification for a command we've just sent, + -- which can be verified by checking that the user id matches the one used in the command, but before we receive + -- the response for that command. This gives us double the chance to handle the command + -- in case the response handler doesn't execute properly for some reason. + -- + -- cached values from capability command, if applicable. + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + if command_in_progress and credential_args_in_use.credentialIndex == user_id then + local result_status + if event_code == ProgramEventCode.PIN_CODE_ADDED then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + tables.add_entry(device, "users", { + userIndex = credential_args_in_use.userIndex, + userName = "Guest " .. credential_args_in_use.userIndex, -- default + userType = "guest", -- default + }) + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + -- some devices emit PIN_CODE_ADDED even for UPDATE events + result_status = consts.COMMAND_RESULT.SUCCESS + end + elseif event_code == ProgramEventCode.PIN_CODE_DELETED and (command_in_progress == consts.LOCK_CREDENTIALS.DELETE or command_in_progress == consts.LOCK_USERS.DELETE) then + result_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + -- the deleteUser command injects a deleteCredential command, so ensure the lockUsers command results is emitted in this case. + if command_in_progress == consts.LOCK_USERS.DELETE or tables.find_entry_by(device, "credentials", "userIndex", credential_args_in_use.userIndex) == nil then + local user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + if command_in_progress == consts.LOCK_USERS.DELETE then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + command_in_progress = consts.LOCK_CREDENTIALS.DELETE -- ensure the lockCredentials result is emitted after this with the correct command context + end + end + elseif event_code == ProgramEventCode.PIN_CODE_CHANGED and command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + end + + if result_status then + lock_utils.emit_command_result(device, + capabilities.lockCredentials, + command_in_progress, + result_status, + { userIndex = credential_args_in_use.userIndex, credentialIndex = credential_args_in_use.credentialIndex } + ) + lock_utils.clear_busy_state(device) + return + end + end + + -- handle the case where we receive a programming event notification for a code we've just deleted, + if event_code == ProgramEventCode.PIN_CODE_ADDED then + -- if no "addCredential" command is in progress, check if the credential is already stored locally. + local credential = tables.find_entry(device, "credentials", user_id) + if not credential then + -- check what user slot we have to associate this with and add a default user + local next_index = tables.next_index(device, "users") + if next_index and next_index <= tables.get_max_entries(device, "users") then + tables.add_entry(device, "users", { + userIndex = next_index, + userName = "Guest " .. next_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = next_index, + credentialIndex = user_id, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. next_index, + }) + end + end + elseif event_code == ProgramEventCode.PIN_CODE_DELETED then + -- check if a credential exists locally for this user id, then + -- try to delete the entries in our tables corresponding to this code. + local credential = tables.find_entry(device, "credentials", user_id) + if credential then + tables.delete_entry(device, "credentials", user_id) + -- Only delete the user if they have no remaining credentials + if tables.find_entry_by(device, "credentials", "userIndex", credential.userIndex) == nil then + tables.delete_entry(device, "users", credential.userIndex) + end + end + end +end + +function ZigbeeHandlers.operating_event_notification(driver, device, zb_rx) + local op_event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) + local op_event_source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + + -- get lock event or return + local OpEventCode = clusters.DoorLock.types.OperationEventCode + local OP_EVENT_CODE_CAPABILITY_MAP = { + [OpEventCode.LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local lock_event = OP_EVENT_CODE_CAPABILITY_MAP[op_event_code] + if not lock_event then return end + lock_event.data = {} + + -- get method of lock event + local OpEventSource = clusters.DoorLock.types.DrlkOperationEventSource + local OP_EVENT_SOURCE_CAPABILITY_MAP = { + [OpEventSource.KEYPAD] = "keypad", + [OpEventSource.RF] = "command", + [OpEventSource.MANUAL] = "manual", + [OpEventSource.RFID] = "rfid", + } + if op_event_code == OpEventCode.AUTO_LOCK or + op_event_code == OpEventCode.SCHEDULE_LOCK or + op_event_code == OpEventCode.SCHEDULE_UNLOCK + then + lock_event.data.method = "auto" + else + lock_event.data.method = OP_EVENT_SOURCE_CAPABILITY_MAP[op_event_source] or "manual" + end + + -- get stored lockUsers data if applicable + if op_event_source == OpEventSource.KEYPAD and device:supports_capability(capabilities.lockUsers) then + local credential = tables.find_entry(device, "credentials", user_id) + local associated_user = credential and tables.find_entry_by(device, "users", "userIndex", credential.userIndex) or nil + if associated_user then + lock_event.data.userIndex = associated_user.userIndex + lock_event.data.userName = associated_user.userName + lock_event.data.userType = associated_user.userType + else + lock_event.data.userIndex = user_id + lock_event.data.userName = "Guest " .. user_id -- default + end + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + local endpoint_id = zb_rx.address_header.src_endpoint.value + if lock_event.value.value == device:get_latest_state( + device:get_component_id_for_endpoint(endpoint_id), + capabilities.lock.ID, + capabilities.lock.lock.ID + ) then + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(endpoint_id, lock_event) +end + + +-- [[ DOOR LOCK CLUSTER ATTRIBUTES ]] -- + +function ZigbeeHandlers.lock_state(driver, device, value, zb_rx) + local attr = capabilities.lock.lock + local LOCK_STATE = { + [value.NOT_FULLY_LOCKED] = attr.unknown(), + [value.LOCKED] = attr.locked(), + [value.UNLOCKED] = attr.unlocked(), + [value.UNDEFINED] = attr.unknown(), + } + + -- this is where we decide whether or not we need to delay our lock event because we've + -- observed it coming before the event (or we're starting to compute the timer) + local delay = device:get_field(consts.DELAY_LOCK_EVENT) or 100 + if (delay < consts.MAX_DELAY) then + device.thread:call_with_delay(delay+.5, function () + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end) + else + device:set_field(consts.DELAY_LOCK_EVENT, socket.gettime()) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end +end + +function ZigbeeHandlers.max_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function ZigbeeHandlers.min_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function ZigbeeHandlers.number_of_pin_users_supported(driver, device, value) + if not device:supports_capability_by_id(capabilities.lockCodes.ID) and value.value > 0 then + -- this device was generically fingerprinted, but supports PIN users, so we should migrate it. + device:try_update_metadata({ profile = "base-lock" }) + end + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) + end + if device:supports_capability(capabilities.lockUsers) then + device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + end +end + + +-- [[ ALARMS CLUSTER COMMANDS ]] -- + +function ZigbeeHandlers.alarm(driver, device, zb_rx) + local ALARM_REPORT = { + [0] = capabilities.lock.lock.unknown(), + [1] = capabilities.lock.lock.unknown(), + -- Events 16-19 are low battery events, but are presented as descriptionText only + } + if (ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value]) + end +end + + +return ZigbeeHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils.lua deleted file mode 100644 index 0a36a9685e..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua +++ /dev/null @@ -1,98 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. -local utils = require "st.utils" -local capabilities = require "st.capabilities" -local json = require "st.json" -local LockCodes = capabilities.lockCodes - - -local lock_utils = { - -- Constants - LOCK_CODES = "lockCodes", - CHECKING_CODE = "checkingCode", - CODE_STATE = "codeState", - MIGRATION_COMPLETE = "migrationComplete", - MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped", - CHECKED_CODE_SUPPORT = "checkedCodeSupport" -} - -lock_utils.get_lock_codes = function(device) - local lc = device:get_field(lock_utils.LOCK_CODES) - return lc ~= nil and lc or {} -end - -lock_utils.lock_codes_event = function(device, lock_codes) - device:set_field(lock_utils.LOCK_CODES, lock_codes, { persist = true } ) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(utils.deep_copy(lock_codes)), { visibility = { displayed = false } })) -end - - -function lock_utils.get_code_name(device, code_id) - if (device:get_field(lock_utils.CODE_STATE) ~= nil and device:get_field(lock_utils.CODE_STATE)["setName"..code_id] ~= nil) then - -- this means a code set operation succeeded - return device:get_field(lock_utils.CODE_STATE)["setName"..code_id] - elseif (lock_utils.get_lock_codes(device)[code_id] ~= nil) then - return lock_utils.get_lock_codes(device)[code_id] - else - return "Code " .. code_id - end -end - -function lock_utils.get_change_type(device, code_id) - if (lock_utils.get_lock_codes(device)[code_id] == nil) then - return " set" - else - return " changed" - end -end - -function lock_utils.reset_code_state(device, code_slot) - local codeState = device:get_field(lock_utils.CODE_STATE) - if (codeState ~= nil) then - codeState["setName".. code_slot] = nil - codeState["setCode".. code_slot] = nil - device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) - end -end - -function lock_utils.code_deleted(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - local event = LockCodes.codeChanged(code_slot.." deleted", { state_change = true }) - event.data = {codeName = lock_utils.get_code_name(device, code_slot)} - lock_codes[code_slot] = nil - device:emit_event(event) - lock_utils.reset_code_state(device, code_slot) - return lock_codes -end - -function lock_utils.populate_state_from_data(device) - if device.data.lockCodes ~= nil and device:get_field(lock_utils.MIGRATION_COMPLETE) ~= true then - -- build the lockCodes table - local lockCodes = {} - local lc_data = json.decode(device.data.lockCodes) - for k, v in pairs(lc_data) do - lockCodes[k] = v - end - -- Populate the devices `lockCodes` field - device:set_field(lock_utils.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) - -- Populate the devices state history cache - device.state_cache["main"] = device.state_cache["main"] or {} - device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} - device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} - - device:set_field(lock_utils.MIGRATION_COMPLETE, true, { persist = true }) - end -end - -return lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua new file mode 100644 index 0000000000..78537182f1 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua @@ -0,0 +1,47 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lock_constants = {} + +lock_constants.DRIVER_STATE = { + BUSY = "busy", + COMMAND_IN_PROGRESS = "commandInProgress", + CREDENTIAL_ARGS_IN_USE = "currentCredential", + SLGA_MIGRATED = "slgaMigrated", +} + +lock_constants.SYNC = { + CODES_FROM_LOCK = "syncCodesFromLock", + CODE_INDEX = "syncCodeIndex", + CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", +} + +lock_constants.COMMAND_RESULT = { + SUCCESS = "success", + FAILURE = "failure", + DUPLICATE = "duplicate", + OCCUPIED = "occupied", + INVALID_COMMAND = "invalidCommand", + RESOURCE_EXHAUSTED = "resourceExhausted", + BUSY = "busy" +} + +lock_constants.LOCK_CREDENTIALS = { + ADD = "addCredential", + UPDATE = "updateCredential", + DELETE = "deleteCredential", + DELETE_ALL = "deleteAllCredentials" +} + +lock_constants.LOCK_USERS = { + ADD = "addUser", + UPDATE = "updateUser", + DELETE = "deleteUser", + DELETE_ALL = "deleteAllUsers" +} + +lock_constants.CRED_TYPE_PIN = "pin" +lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" +lock_constants.MAX_DELAY = 10 + +return lock_constants diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua new file mode 100644 index 0000000000..bfe3562344 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -0,0 +1,268 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT + +local table_utils = {} + +-- DEFS describes how each capability-backed state table is structured: +-- +-- capability SmartThings capability (used for device support checks and to get latest state) +-- attribute Capability attribute function (used to emit state) +-- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table +-- match_key Key used to identify entries in flat tables +-- required_keys Keys that must be non-nil when adding an entry +-- persistent_field device:set_field key used to back up the table across restarts +-- + +local DEFS = { + users = { + capability = capabilities.lockUsers, + attribute = capabilities.lockUsers.users, + max_entries = capabilities.lockUsers.totalUsersSupported, + match_key = "userIndex", + required_keys = {"userIndex", "userType"}, + persistent_field = "persistedUsers", + }, + credentials = { + capability = capabilities.lockCredentials, + attribute = capabilities.lockCredentials.credentials, + max_entries = capabilities.lockCredentials.pinUsersSupported, + match_key = "credentialIndex", + required_keys = {"userIndex", "credentialIndex", "credentialType"}, + persistent_field = "persistedCredentials", + } +} + +-- Resolve a table name to its definition. Logs an error and returns nil if unknown. +local function resolve_table_def(device, table_name) + local def = DEFS[table_name] + if not def then + device.log.error(string.format("table_helpers: unknown table %q", table_name)) + end + return def +end + +-- Validate that an entry table contains all required keys. +local function validate_entry(device, entry, required_keys) + for _, key in ipairs(required_keys or {}) do + if entry[key] == nil then + device.log.error(string.format("table_helpers: entry missing required key %q", key)) + return false + end + end + return true +end + +-- Write the current table contents to the device's persistent field store so that +-- the state survives driver restarts and can be restored if the capability state +-- cache is wiped. +local function persist_table(device, def, data) + device:set_field(def.persistent_field, st_utils.deep_copy(data), { persist = true }) +end + +-- Read the current state for a table and return a deep-copied array. +-- Accepts either a string table name ("users", "credentials") or a DEFS entry directly. +-- Returns nil (with a warning) if the capability is unsupported by the device. +-- When the capability state cache has been wiped (get_latest_state returns nil), +-- falls back to the persistent field store so that callers always receive the +-- last-known state rather than an empty table. +--- @return table[] | nil +function table_utils.get_state(device, name_or_def) + local def = type(name_or_def) == "string" and resolve_table_def(device, name_or_def) or name_or_def + if not def then return nil end + if not device:supports_capability(def.capability, "main") then + device.log.warn(string.format( + "table_helpers: device does not support capability %q", def.capability.ID + )) + return + end + local state = device:get_latest_state("main", def.capability.ID, def.attribute.NAME) + if state ~= nil then + return st_utils.deep_copy(state) + end + -- Capability state cache is absent (e.g. after a hub reboot); fall back to the + -- persistent store so that callers see the last-known table contents. + return st_utils.deep_copy(device:get_field(def.persistent_field) or {}) +end + +-- Find an entry in a named table where the match_key equals value. +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry(device, table_name, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[def.match_key] == value then return entry end + end + return nil +end + +-- Find an entry in a named table where entry[key] equals value (arbitrary key search). +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[key] == value then return entry end + end + return nil +end + +-- Find all entries in a named table where entry[key] equals value (arbitrary key search). +-- Returns an array of matching entries, or an empty array if none found. +function table_utils.find_all_entries_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return {} end + local t = table_utils.get_state(device, def) + if not t then return {} end + local matches = {} + for _, entry in ipairs(t) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches +end + +-- Return the lowest positive integer not yet used as the match_key in the named table. +-- Used to auto-assign the next available slot for a new entry. +function table_utils.next_index(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return 1 end + local t = table_utils.get_state(device, def) or {} + local occupied = {} + for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end + local idx = 1 + while occupied[idx] do idx = idx + 1 end + return idx +end + +function table_utils.get_max_entries(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return end + return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing +end + +-- Add an entry to a named table. The entry must satisfy all required_keys for +-- that table. An entry whose match_key value already exists in the +-- table is skipped to prevent duplicates. If the table has a max_entries limit, +-- entries that exceed the limit are not added. +function table_utils.add_entry(device, table_name, entry) + device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + if not validate_entry(device, entry, def.required_keys) then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + if #t >= table_utils.get_max_entries(device, table_name) then + device.log.warn(string.format( + "table_helpers: cannot add entry to %q, max entries reached", table_name + )) + return COMMAND_RESULT.RESOURCE_EXHAUSTED + end + + -- Object entry: skip if an entry with the same match_key value already exists + if def.match_key then + for _, existing in ipairs(t) do + if existing[def.match_key] == entry[def.match_key] then + device.log.warn(string.format( + "table_helpers: entry with %s == %s already exists in %q, skipping", + def.match_key, tostring(entry[def.match_key]), table_name + )) + return COMMAND_RESULT.OCCUPIED + end + end + end + + table.insert(t, st_utils.deep_copy(entry)) + + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS +end + + +--- Update fields of an existing entry in a table. +--- The entry to update is identified by the match_key parameter in DEFS. +function table_utils.update_entry(device, table_name, match_value, updates) + device.log.debug("table_helpers: attempting to update entry " .. match_value .. " in " .. table_name .. ": " .. st_utils.stringify_table(updates)) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + for _, entry in ipairs(t) do + if entry[def.match_key] == match_value then + for k, v in pairs(updates) do + entry[k] = v + end + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + + device.log.warn(string.format( + "table_helpers: no entry found in %q with %s == %s", + table_name, def.match_key, tostring(match_value) + )) + return COMMAND_RESULT.FAILURE +end + + +-- Delete an entry from a table. +-- +-- Returns SUCCESS, or FAILURE if nothing matched. +function table_utils.delete_entry(device, table_name, matcher) + device.log.debug("table_helpers: attempting to delete entry " .. matcher .. " from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + local predicate = function(entry) return entry[def.match_key] == matcher end + + for i, entry in ipairs(t) do + if predicate(entry) then + table.remove(t, i) + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + return COMMAND_RESULT.FAILURE +end + +-- Delete all entries from a table. +function table_utils.delete_all_entries(device, table_name) + device.log.debug("table_helpers: attempting to delete all entries from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + device:emit_event(def.attribute({}, {visibility = {displayed = false}})) + persist_table(device, def, {}) + return COMMAND_RESULT.SUCCESS +end + +-- Restore capability state from the persistent field store. +-- Called during init to re-emit table events if the capability state cache +-- has been wiped (e.g. after a hub reboot). Only emits for tables that have +-- persisted data, are in a nil state, and whose capability is supported by the device. +function table_utils.restore_from_persistent_store(device) + for _, internal in pairs(DEFS) do + if device:supports_capability(internal.capability, "main") and + device:get_latest_state("main", internal.capability.ID, internal.attribute.NAME) == nil + then + local persisted = st_utils.deep_copy(device:get_field(internal.persistent_field)) + if persisted and #persisted > 0 then + device:emit_event(internal.attribute(persisted, {visibility = {displayed = false}})) + end + end + end +end + +return table_utils diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua new file mode 100644 index 0000000000..90bdd96f33 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua @@ -0,0 +1,66 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local consts = require "lock_utils.constants" + +local lock_utils = {} + +-- [[ BUSY STATE MANAGEMENT ]] -- + +-- Check if we are currently busy performing a task, or at least 10 seconds have passed since the busy state was last set. +-- If busy, return true. If not busy, clear any stale state and return false. +function lock_utils.is_device_busy(device) + local c_time = os.time() + local busy_since = device:get_field(consts.DRIVER_STATE.BUSY) or false + + if (busy_since == false) or (c_time - busy_since > 10) then + lock_utils.clear_busy_state(device) + return false + end + return true +end + +-- Set states that may be required when in busy state +function lock_utils.set_busy_state(device, command_name, command_args) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) + device:set_field(consts.DRIVER_STATE.BUSY, os.time()) +end + +-- Clear states that were set when in busy state +function lock_utils.clear_busy_state(device) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) + device:set_field(consts.DRIVER_STATE.BUSY, false) +end + + +-- [[ SYNC STATE MANAGEMENT ]] -- + +function lock_utils.sync_device_state(device) + local clusters = require "st.zigbee.zcl.clusters" + -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. + device:send(clusters.DoorLock.attributes.SendPINOverTheAir:write(device, true)) + + -- if consts.SYNC.CODE_INDEX is nil, we haven't started syncing codes from the lock yet, + -- so start the process from 1. note: the Zigbee "Master Code" is associated with index 0. + if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then + device:set_field(consts.SYNC.CODE_INDEX, 1) + end + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, device:get_field(consts.SYNC.CODE_INDEX))) +end + + +-- [[ COMMAND RESULT STATE MANAGEMENT ]] -- + +function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) + local info = additional_info or {} + info.commandName = command_name + info.statusCode = status_code + if capability then + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) + end +end + +return lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua new file mode 100644 index 0000000000..c483b2fe27 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function samsungsds_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "SAMSUNG SDS" then + return true, require("samsungsds") + end + return false +end + +return samsungsds_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index c0a597a99d..e09a598efa 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local clusters = require "st.zigbee.zcl.clusters" @@ -20,7 +10,9 @@ local cluster_base = require "st.zigbee.cluster_base" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Lock = capabilities.lock -local lock_utils = require "lock_utils" +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" +local socket = require "cosock.socket" local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F local SAMSUNG_SDS_MFR_CODE = 0x0003 @@ -57,9 +49,16 @@ local refresh = function(driver, device, cmd) -- do nothing in refresh capability handler end +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + local device_added = function(self, device) - lock_utils.populate_state_from_data(device) - device:emit_event(capabilities.lock.lock.unlocked()) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. + + emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) device:emit_event(capabilities.battery.battery(100)) end @@ -72,10 +71,86 @@ end local battery_init = battery_defaults.build_linear_voltage_init(4.0, 6.0) local device_init = function(driver, device, event) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. battery_init(driver, device, event) device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) - lock_utils.populate_state_from_data(device) +end + +local operating_event_notification = function(driver, device, zb_rx) + local op_event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) + local op_event_source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) + + -- get lock event or return + local OpEventCode = clusters.DoorLock.types.OperationEventCode + local OP_EVENT_CODE_CAPABILITY_MAP = { + [OpEventCode.LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local lock_event = OP_EVENT_CODE_CAPABILITY_MAP[op_event_code] + if not lock_event then return end + lock_event.data = {} + + -- get method of lock event + local OpEventSource = clusters.DoorLock.types.DrlkOperationEventSource + local OP_EVENT_SOURCE_CAPABILITY_MAP = { + [OpEventSource.KEYPAD] = "keypad", + [OpEventSource.RF] = "command", + [OpEventSource.MANUAL] = "manual", + [OpEventSource.RFID] = "rfid", + -- These last two sources are not found in the spec, but they were in + -- the legacy driver and appear to be related to the Samsung SDS + [4] = "fingerprint", + [5] = "bluetooth", + } + if (op_event_source ~= OpEventSource.KEYPAD and ( + op_event_code == OpEventCode.AUTO_LOCK or + op_event_code == OpEventCode.SCHEDULE_LOCK or + op_event_code == OpEventCode.SCHEDULE_UNLOCK + )) then + lock_event.data.method = "auto" + else + lock_event.data.method = OP_EVENT_SOURCE_CAPABILITY_MAP[op_event_source] or "manual" + end + + -- get stored lockUsers data if applicable + if op_event_source == OpEventSource.KEYPAD and device:supports_capability(capabilities.lockUsers) then + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + local associated_user = tables.find_entry(device, "users", user_id) + if associated_user then + lock_event.data.userIndex = user_id .. "" + lock_event.data.userName = associated_user.userName + lock_event.data.userType = associated_user.userType + else + lock_event.data.userIndex = user_id .. "" + lock_event.data.userName = "Guest " .. user_id -- default + end + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + local endpoint_id = zb_rx.address_header.src_endpoint.value + if lock_event.value.value == device:get_latest_state( + device:get_component_id_for_endpoint(endpoint_id), + capabilities.lock.ID, + capabilities.lock.lock.ID + ) then + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(endpoint_id, lock_event) end local samsung_sds_driver = { @@ -83,7 +158,8 @@ local samsung_sds_driver = { zigbee_handlers = { cluster = { [DoorLock.ID] = { - [SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND] = mfg_lock_door_handler + [SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND] = mfg_lock_door_handler, + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = operating_event_notification, } }, attr = { @@ -106,9 +182,7 @@ local samsung_sds_driver = { added = device_added, init = device_init }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "SAMSUNG SDS" - end + can_handle = require("samsungsds.can_handle"), } return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua new file mode 100644 index 0000000000..d3491ed664 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers"), + lazy_load_if_possible("samsungsds"), + lazy_load_if_possible("bad-battery-reporter"), + lazy_load_if_possible("yale-fingerprint-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua b/drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua similarity index 52% rename from drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua index 5fd267b4e0..61fb28ce8d 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -35,9 +24,7 @@ local mock_device = test.mock_device.build_test_zigbee_device({ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -57,7 +44,48 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) } + }, + { + min_api_version = 17 + } +) + +local mock_device_yrd = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints ={ + [1] = { + id = 1, + manufacturer ="Yale", + model ="YRD220/240 TSDB", + server_clusters = {} + } } +}) + +local function test_init_yrd() + test.mock_device.add_test_device(mock_device_yrd) +end + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device_yrd.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_yrd, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_yrd:generate_test_message("main", capabilities.battery.battery(55)) + } + }, + { + test_init = test_init_yrd, + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua index b6fa3d1323..4bde45ec1a 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -50,7 +39,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -81,7 +73,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -91,7 +86,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -101,7 +99,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "lock", component = "main", command = "lock", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.commands.LockDoor(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -111,7 +112,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "lock", component = "main", command = "unlock", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.commands.UnlockDoor(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -123,7 +127,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(5) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -135,14 +142,20 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(5) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "PinUsersSupported report should be a no-op", function () test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 8)}) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua similarity index 50% rename from drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua index 3b7d777d24..e6d6a22b78 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua @@ -1,22 +1,12 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock @@ -27,9 +17,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -44,7 +32,22 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device)}) test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 8)}) mock_device:expect_metadata_update({profile = "base-lock"}) - end + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") })) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + -- inject_capability_command(refresh) is called inline by info_changed + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua new file mode 100644 index 0000000000..1ff448a719 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua @@ -0,0 +1,484 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the four lifecycle handlers defined in init.lua: +-- added (device_added), doConfigure (do_configure), +-- infoChanged (info_changed), init (LockLifecycle.init) + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration +local Alarms = clusters.Alarms +local constants = require "lock_utils.constants" + +-- ── Shared mock devices ──────────────────────────────────────────────────── +-- base-lock profile: lock + lockCodes + lockCredentials + lockUsers + battery +local mock_device_base = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +-- Same profile but provisioning_state = "TYPED" (freshly fingerprinted) +local mock_device_typed = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + _provisioning_state = "TYPED", +}) + +-- lock-battery profile: lock + battery only (no lockCodes / lockCredentials) +local mock_device_battery = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +-- Helper: make a test_init function that suppresses startup messages and +-- registers the given device. +local function make_test_init(device) + return function() + test.disable_startup_messages() + test.mock_device.add_test_device(device) + end +end + +-- Helper: expect the five zigbee messages produced by sync_device_state on a +-- freshly-started device (no cached capability state, CODE_INDEX starts at 1). +local function expect_sync_device_state(device) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.SendPINOverTheAir:write(device, true) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 1) }) +end + +-- Helper: expect the messages produced by the legacy reload_all_codes path after +-- the 2-second doConfigure timer fires on a non-SLGA_MIGRATED device. +-- Unlike sync_device_state, reload_all_codes emits scanCodes("Scanning") and +-- starts iterating from code slot 0 (CHECKING_CODE = 0). +local function expect_reload_all_codes_messages(device) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.SendPINOverTheAir:write(device, true) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MaxPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MinPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(device) }) + test.socket.capability:__expect_send( + device:generate_test_message("main", + capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) + ) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 0) }) +end + +-- Helper: expect the six zigbee messages sent by do_configure for any device. +local function expect_do_configure_zigbee(device) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1), + }) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, DoorLock.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0), + }) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, Alarms.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0), + }) +end + +-- ============================================================================ +-- added (device_added) +-- ============================================================================ + +test.register_coroutine_test( + "added: TYPED device with lockCodes emits migrated event, persists SLGA_MIGRATED, and injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + + -- Migrated event is emitted before the injected refresh + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler inline + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.LockState:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + Alarms.attributes.AlarmCount:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), + }) + test.wait_for_events() + + assert( + mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be true after added fires for a TYPED device" + ) + end, + { test_init = make_test_init(mock_device_typed) } +) + +test.register_coroutine_test( + "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "added" }) + + -- No migrated capability event expected + test.socket.zigbee:__expect_send({ + mock_device_base.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_base), + }) + test.socket.zigbee:__expect_send({ + mock_device_base.id, + DoorLock.attributes.LockState:read(mock_device_base), + }) + test.socket.zigbee:__expect_send({ + mock_device_base.id, + Alarms.attributes.AlarmCount:read(mock_device_base), + }) + test.wait_for_events() + + assert( + mock_device_base:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) ~= true, + "SLGA_MIGRATED must NOT be set for a non-TYPED device" + ) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "added: device without lockCodes does NOT emit migrated event but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + + -- No migrated capability event expected. + -- For non-SLGA_MIGRATED devices the legacy-handlers refresh fires, which reads + -- NumberOfPINUsersSupported when the device has no lockCodes and no cached code support. + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.LockState:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + Alarms.attributes.AlarmCount:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_battery), + }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- doConfigure (do_configure) +-- ============================================================================ + +test.register_coroutine_test( + "doConfigure: SLGA_MIGRATED device with lockCredentials sends bind/configure then calls sync_device_state after 2-second delay", + function() + -- Pre-seed SLGA_MIGRATED so the main driver's do_configure fires (not legacy-handlers). + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_base) + mock_device_base:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + -- Timer fires: sync_device_state is called (GetPINCode slot 1) + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(mock_device_base) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "doConfigure: SLGA_MIGRATED device without lockCredentials sends bind/configure but does NOT create sync timer", + function() + -- Pre-seed SLGA_MIGRATED so the main driver's do_configure fires. + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_battery) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + -- lock-battery has no lockCredentials, so no 2-second timer is created. + end, + { test_init = make_test_init(mock_device_battery) } +) + +test.register_coroutine_test( + "doConfigure: non-SLGA_MIGRATED device triggers legacy reloadAllCodes (with scanCodes emit) after 2-second delay", + function() + -- mock_device_typed has no SLGA_MIGRATED → legacy-handlers' do_configure fires, + -- which injects a reloadAllCodes capability command after a 2-second delay. + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_typed) + mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + -- Timer fires: legacy reload_all_codes runs, iterating from code slot 0. + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_reload_all_codes_messages(mock_device_typed) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_typed) } +) + +-- ============================================================================ +-- infoChanged (info_changed) +-- Each test uses a per-test fresh device (upvalue pattern) to avoid +-- raw_st_data contamination from generate_info_changed across tests. +-- init is triggered first so the driver loads the device into device_cache, +-- allowing infoChanged to correctly identify the old profile. +-- ============================================================================ + +do + local dev + test.register_coroutine_test( + "infoChanged: switching from non-lockCodes to lockCodes+lockCredentials profile triggers full SLGA migration and two syncs", + function() + -- Warm up device_cache with the original (lock-battery) profile via init. + -- For a non-SLGA_MIGRATED device, legacy-handlers' init fires (no zigbee sends). + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- Switch to base-lock (lockCodes + lockCredentials) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.device_lifecycle:__queue_receive( + dev:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") }) + ) + -- Migration events + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler inline + test.socket.zigbee:__expect_send({ + dev.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.LockState:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + Alarms.attributes.AlarmCount:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.MaxPINCodeLength:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.MinPINCodeLength:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(dev), + }) + -- Immediate sync_device_state + test.wait_for_events() + + assert( + dev:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be set after infoChanged profile switch" + ) + + -- Delayed sync_device_state (2 s) + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(dev) + test.wait_for_events() + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +do + local dev + test.register_coroutine_test( + "infoChanged: no profile change does nothing", + function() + -- Warm up device_cache + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- infoChanged with no profile change + test.socket.device_lifecycle:__queue_receive(dev:generate_info_changed({})) + test.wait_for_events() + -- No capability events, no zigbee sends expected + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + + +-- ============================================================================ +-- init (LockLifecycle.init) +-- ============================================================================ + +test.register_coroutine_test( + "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: device with lockCodes but SLGA_MIGRATED not set does nothing", + function() + -- SLGA_MIGRATED is not set; lockCodes is supported; elseif branch does not apply + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.wait_for_events() + -- No capability events and no zigbee sends expected + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: SLGA_MIGRATED device without lockCodes sends NumberOfPINUsersSupported read to detect re-profiling", + function() + -- Pre-seed SLGA_MIGRATED=true so legacy-handlers is bypassed and the main driver's + -- init fires. lock-battery has no lockCodes, so the elseif branch executes and + -- reads NumberOfPINUsersSupported to detect whether the device should be re-profiled. + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_battery), + }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- driverSwitched (LockLifecycle.driver_switched) +-- ============================================================================ + +test.register_coroutine_test( + "driver_switched: device with lockCodes and migrated=true persists SLGA_MIGRATED and updates metadata", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + + -- Migrated event is emitted before the injected refresh + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler inline + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.LockState:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + Alarms.attributes.AlarmCount:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), + }) + test.wait_for_events() + + -- driverSwitched occurs after added, so migrated=true is already set in the capability state cache + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "driver_switched" }) + mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + assert(mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true) + end, + { test_init = make_test_init(mock_device_typed) } +) + +-- ============================================================================ +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua new file mode 100644 index 0000000000..e29788fe36 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua @@ -0,0 +1,76 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration +local Alarm = clusters.Alarms +local capabilities = require "st.capabilities" +local constants = require "lock_utils.constants" + +local json = require "st.json" + +local mock_datastore = require "integration_test.mock_env_datastore" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + data = { + lockCodes = json.encode({ + ["1"] = "Zach", + ["5"] = "Steven" + }), + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + -- Validate lockCodes field + mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["5"] = "Steven" }) + -- Validate migration complete flag + mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) + -- Set min/max code length attributes + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 5) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 10) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate `migrate` command functionality. + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=5, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1, credentialName="Zach"}, {credentialIndex=5, credentialType="pin", userIndex=5, credentialName="Steven"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) + test.wait_for_events() + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua new file mode 100644 index 0000000000..33293e784d --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -0,0 +1,578 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockCredentials capability commands: +-- addCredential, updateCredential, deleteCredential, deleteAllCredentials + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS) + end + test.wait_for_events() +end + +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS) + end + test.wait_for_events() +end + +-- ============================================================================ +-- addCredential +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", + function() + -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + -- Expect SetPINCode to be sent to the lock + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + -- Lock responds with SUCCESS + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + -- Handler adds the credential and user to the credentials table + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + -- commandResult with userIndex and credentialIndex + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits failure when the lock returns GENERAL_FAILURE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.GENERAL_FAILURE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits resourceExhausted when the lock returns MEMORY_FULL", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.MEMORY_FULL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits duplicate when the lock returns DUPLICATE_CODE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.DUPLICATE_CODE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: returns busy when another operation is already in progress", + function() + -- Put the device into busy state by starting an addCredential operation + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + -- Second addCredential while first is still pending → should get busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: sends SetPINCode and emits success when the lock acknowledges", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin9" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "newPin9"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns failure immediately when the credential does not exist in the table", + function() + -- No credentials seeded — credentialIndex 99 does not exist + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + -- No zigbee message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- Start first updateCredential to make device busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin1111" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin1111"), + }) + test.wait_for_events() + + -- Second updateCredential while first is pending + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin2222" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: sends ClearPINCode and emits success with indices when the lock returns PASS", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- credential table and user should be deleted + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: emits failure when the lock returns FAIL", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure immediately when the credentialIndex does not exist in the table", + function() + -- No credentials seeded — index 5 is unknown + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 5, "pin" } }, + }) + + -- No zigbee messages should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns busy when another operation is already in progress", + function() + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + -- Start first deleteCredential to make device busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + -- Second deleteCredential while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 2, "pin" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials: sends ClearAllPINCodes and emits success when the lock returns PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- deleteAllCredentials only clears credentials; users table is untouched + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: emits failure when the lock returns FAIL", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- First deleteAllCredentials starts the zigbee flow (device is now busy) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + -- Second deleteAllCredentials while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua new file mode 100644 index 0000000000..0eb4ac396f --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua @@ -0,0 +1,653 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for lockUsers and lockCredentials commands with state +-- pre-configured before each test. Two users and two credentials are seeded +-- at the start of every test so tests can focus on the various response states +-- produced by the zigbee response handlers in commands.lua. + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +-- ── Shared device ────────────────────────────────────────────────────────── + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- ── Seeding helpers ──────────────────────────────────────────────────────── + +local INITIAL_USERS = { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, +} +local INITIAL_CREDS = { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, +} + +-- Seed a list of entries into a named table, consuming the resulting events. +local function seed_table(attribute_fn, table_name, entries) + local accumulated = {} + for _, entry in ipairs(entries) do + table.insert(accumulated, entry) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + attribute_fn(accumulated, { visibility = { displayed = false } })) + ) + assert( + table_utils.add_entry(mock_device, table_name, entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_table: add_entry failed for " .. table_name + ) + end + test.wait_for_events() +end + +-- Pre-configure each test with 2 users and 2 credentials. +local function setup_state() + seed_table(capabilities.lockUsers.users, "users", INITIAL_USERS) + seed_table(capabilities.lockCredentials.credentials, "credentials", INITIAL_CREDS) +end + +-- ============================================================================ +-- addUser — pre-configured device (2 users already present) +-- ============================================================================ + +test.register_coroutine_test( + "addUser (pre-configured): assigns the next available index (3) when two users already exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser — pre-configured device +-- ============================================================================ + +test.register_coroutine_test( + "updateUser (pre-configured): updates Alice's name and emits success with userIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceRenamed", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceRenamed", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + + +-- ============================================================================ +-- deleteUser with associated credential — exercises clear_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser (pre-configured, PASS): removes both user and credential and emits success for each", + function() + setup_state() + + -- Delete user 1 who has credential 1 → driver injects deleteCredential → ClearPINCode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser (pre-configured, FAIL): emits failure for both capabilities when the lock rejects", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- addCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "addCredential (pre-configured): succeeds for a new slot and emits userIndex + credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Guest 3", userType = "guest" }, -- default name since lock doesn't provide one + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits duplicate when the lock rejects with DUPLICATE_CODE", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin01" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin01"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.DUPLICATE_CODE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits resourceExhausted when the lock returns MEMORY_FULL", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.MEMORY_FULL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential (pre-configured): succeeds and emits success with userIndex and credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin1" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "newPin1"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential (pre-configured): returns failure immediately for a non-existent credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential standalone (lockCredentials.DELETE path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential (pre-configured, PASS): removes credential and emits success with indices", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential (pre-configured, FAIL): emits failure when the lock rejects the clear", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 2, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials standalone (lockCredentials.DELETE_ALL path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials (pre-configured, PASS): clears only credentials and emits success", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- Only credentials table is cleared; users table is untouched (no lockUsers.users event) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials (pre-configured, FAIL): emits failure and leaves tables unchanged", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (lockUsers.DELETE_ALL path — clears both tables) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers (pre-configured, PASS): clears both tables and emits success for both capabilities", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers (pre-configured, FAIL): emits failure for both capabilities when the lock rejects", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua new file mode 100644 index 0000000000..3c06afef66 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -0,0 +1,574 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Tests for the ProgrammingEventNotification handler in lock_handlers/zigbee_responses.lua. +-- +-- Cases covered: +-- • PIN_CODE_ADDED received while NOT busy (manual addition at the lock) +-- • PIN_CODE_DELETED received while NOT busy (manual deletion at the lock) +-- • PIN_CODE_CHANGED received while NOT busy (manual update — not currently handled) +-- • PIN_CODE_ADDED while addCredential in flight with matching user (failsafe path) +-- • PIN_CODE_CHANGED while updateCredential in flight with matching user (failsafe path) +-- • PIN_CODE_DELETED while deleteCredential in flight with matching user (failsafe path) +-- • PIN_CODE_ADDED while busy with DIFFERENT user (processed as manual event) +-- • PIN_CODE_DELETED while busy with DIFFERENT user (processed as manual event) +-- • PIN_CODE_ADDED received after BUSY ends (late notification from our SetPINCode; credential +-- not double-added) +-- • PIN_CODE_ADDED received after BUSY ends (both entries already exist; complete no-op) +-- • PIN_CODE_DELETED received after BUSY ends (late notification from our ClearPINCode; credential +-- already deleted) +-- • PIN_CODE_CHANGED received after BUSY ends (late notification from our SetPINCode update; +-- not handled, no effect) + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local capabilities = require "st.capabilities" +local constants = require "lock_utils.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +-- Build a ProgrammingEventNotification ZigBee receive message for the given event code and user ID. +local function build_programming_event(event_code, user_id) + return { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x00, -- program_event_source (keypad) + event_code, + user_id, + "1234", -- PIN (not used by the handler) + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, -- local_alarm_mask + "data" -- user_description + ) + } +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- NOT-BUSY CASES (manual events from the lock, no command in flight) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED while not busy syncs user and credential entries", + function() + -- Route events to the new capabilities handler, not the legacy lockCodes handler. + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + -- Users table receives the new entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + -- Credentials table receives the new entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED while not busy removes user and credential entries", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Set up an existing entry by processing a manual addition first. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Now delete the same entry via a manual deletion event. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_CHANGED while not busy has no effect on table state", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- PIN_CODE_CHANGED is not handled by the notification handler, so no events should + -- be emitted and table state should remain unchanged. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- BUSY CASES (notification arrives while one of our commands is in flight) +-- The handler must be a no-op so we do not double-process the result. +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED while addCredential is in flight acts as failsafe", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an addCredential command being in flight with matching user ID. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + -- Failsafe path: notification handled as command success, user and credential added, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_CHANGED while updateCredential is in flight acts as failsafe", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an updateCredential command being in flight with matching user ID. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.UPDATE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + -- Failsafe path: notification handled as command success, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED while deleteCredential is in flight acts as failsafe", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- First, add a credential so we have something to delete via the failsafe path. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Simulate a deleteCredential command being in flight with matching user ID. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.DELETE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + -- Failsafe path: notification handled as command success, credential and user removed, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- BUSY WITH DIFFERENT USER (notification for a different user arrives while +-- a command is in flight; processed as a normal manual event) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED for different user while busy is processed as manual event", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an addCredential command in flight for user 1. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + -- Notification arrives for user 2 (different user) — should be processed as manual event. + -- The manual handler assigns userIndex = next_index (1, the first available slot), not user_id (2). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED for different user while busy is processed as manual event", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- First, add a credential for user 2 so we have something to delete. + -- The manual handler assigns userIndex = next_index (1, the first available slot), not user_id (2). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Simulate a deleteCredential command in flight for user 1. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.DELETE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + -- Notification arrives for user 2 (different user) — should be processed as manual deletion. + -- Delete handler emits credentials first, then users. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- POST-BUSY CASES (busy state was already cleared by the ZigBee response +-- handler before the ProgrammingEventNotification arrives; the notification was +-- sent by the lock in response to our own SetPINCode / ClearPINCode command) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "Late PIN_CODE_ADDED after addCredential: user and credential already exist; complete no-op", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Complete an addCredential flow so both the user and credentials tables already have entries. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- User and credential added; commandResult emitted; busy state cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late ProgrammingEventNotification PIN_CODE_ADDED for the same slot arrives after busy ends. + -- The credential is already in the table → find_entry returns it → handler exits early. + -- Both user and credential already exist, so no events are emitted. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_ADDED when both user and credential already exist: both add_entry calls are no-ops", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Populate both tables by processing a not-busy PIN_CODE_ADDED (simulates the completed command state). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late notification for the same slot; both add_entry calls return OCCUPIED → no events. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_DELETED after deleteCredential: credential and user already removed; no-op", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Populate both tables (user at index 1, credential at index 1). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Run a standalone deleteCredential flow; this removes the credential but leaves the user. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + -- Credential and user entries removed (no remaining credentials for this user). Busy state cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late PIN_CODE_DELETED for the same slot arrives after busy ends. + -- The credential and user are already gone → therefore nothing happens. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_CHANGED after updateCredential: not handled by notification handler, no events emitted", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Seed user and credential so updateCredential has an existing entry to modify. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Complete an updateCredential flow so the credential entry is updated and busy state is cleared. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "5678" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- UPDATE doesn't modify the credentials table metadata, only the PIN code (not stored). + -- The response handler just emits commandResult; busy state is cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late PIN_CODE_CHANGED notification arrives after busy ends. + -- The notification handler does not handle PIN_CODE_CHANGED → no events emitted. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua new file mode 100644 index 0000000000..82ecd7f964 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -0,0 +1,728 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Unit tests for lock_utils/tables.lua +-- Tests directly call table_utils functions and verify both return values +-- and emitted capability events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +-- --------------------------------------------------------------------------- +-- Shared mock device +-- --------------------------------------------------------------------------- + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- Seed the users table with `entries` and consume the resulting emit_events. +-- After this call the state cache has those entries and the socket is clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + -- Build the expected post-insert table up to this entry. + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for entry userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Seed the credentials table with `entries` and consume the resulting emit_events. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for entry credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- =========================================================================== +-- add_entry +-- =========================================================================== + +test.register_coroutine_test( + "add_entry: adds a new user entry and emits the updated table", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "add_entry: returns OCCUPIED when an entry with the same userIndex already exists", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- Attempt to add a different entry with the same userIndex (match_key) + local duplicate = { userIndex = 1, userType = "unrestricted", userName = "Bob" } + local result = table_utils.add_entry(mock_device, "users", duplicate) + assert(result == constants.COMMAND_RESULT.OCCUPIED, + "Expected OCCUPIED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns RESOURCE_EXHAUSTED when table is at max capacity", + function() + local entries = {} + for i = 1, 20 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local overflow = { userIndex = 21, userType = "guest", userName = "Overflow" } + local result = table_utils.add_entry(mock_device, "users", overflow) + assert(result == constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + "Expected RESOURCE_EXHAUSTED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE when a required key is missing", + function() + -- userType is required for users table + local incomplete = { userIndex = 1 } + local result = table_utils.add_entry(mock_device, "users", incomplete) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.add_entry(mock_device, "nonexistent_table", + { userIndex = 1, userType = "guest" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: adds a credential entry and emits updated credentials table", + function() + local entry = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +-- =========================================================================== +-- update_entry +-- =========================================================================== + +test.register_coroutine_test( + "update_entry: updates an existing user entry and emits updated table", + function() + local original = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ original }) + + local expected_after_update = { + { userIndex = 1, userType = "adminMember", userName = "Alice_Updated" } + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_after_update, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 1, + { userName = "Alice_Updated", userType = "adminMember" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.update_entry(mock_device, "users", 99, + { userName = "Ghost" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: only updates the specified fields, leaves others intact", + function() + local entry1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local entry2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ entry1, entry2 }) + + -- Update only userName of entry 2; userType should remain "guest" + local expected = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 2, userType = "guest", userName = "Bob_Renamed" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 2, { userName = "Bob_Renamed" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.update_entry(mock_device, "bad_table", 1, { userName = "X" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_entry +-- =========================================================================== + +test.register_coroutine_test( + "delete_entry: deletes an existing entry and returns COMMAND_RESULT.SUCCESS", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- After deletion the table is empty + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 1) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.delete_entry(mock_device, "users", 99) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: remaining entries are intact after a deletion", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + -- Delete the middle entry; expect e1 and e3 remain + local expected_remaining = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 3, userType = "guest", userName = "Carol" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_remaining, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 2) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_entry(mock_device, "bad_table", 1) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_all_entries +-- =========================================================================== + +test.register_coroutine_test( + "delete_all_entries: emits an empty users table and returns SUCCESS", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_all_entries: returns SUCCESS even when the table is already empty", + function() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_all_entries: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_all_entries(mock_device, "bad_table") + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: emits an empty credentials table and returns SUCCESS", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + seed_credentials({ cred }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "credentials") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +-- =========================================================================== +-- find_entry +-- =========================================================================== + +test.register_coroutine_test( + "find_entry: returns the matching entry when it exists", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry(mock_device, "users", 2) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + assert(result.userName == "Bob", + "Expected userName == 'Bob', got: " .. tostring(result.userName)) + end +) + +test.register_coroutine_test( + "find_entry: returns nil when no entry matches the value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry(mock_device, "users", 99) + assert(result == nil, + "Expected nil for missing entry, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry: finds a credential entry by credentialIndex", + function() + local cred = { userIndex = 1, credentialIndex = 5, credentialType = "pin" } + seed_credentials({ cred }) + + local result = table_utils.find_entry(mock_device, "credentials", 5) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.credentialIndex == 5, + "Expected credentialIndex == 5, got: " .. tostring(result.credentialIndex)) + end +) + +-- =========================================================================== +-- find_entry_by +-- =========================================================================== + +test.register_coroutine_test( + "find_entry_by: returns the entry matching an arbitrary key", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "adminMember", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Bob") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns nil when no entry matches", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Nobody") + assert(result == nil, + "Expected nil for unmatched key/value, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns the first matching entry when multiple entries share the same value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userType", "guest") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 1, + "Expected first match userIndex == 1, got: " .. tostring(result.userIndex)) + end +) + +-- =========================================================================== +-- next_index +-- =========================================================================== + +test.register_coroutine_test( + "next_index: returns 1 when the table is empty", + function() + local result = table_utils.next_index(mock_device, "users") + assert(result == 1, + "Expected 1 for empty table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns the next sequential index after a contiguous range", + function() + local entries = {} + for i = 1, 3 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 4, + "Expected 4 after indices 1-3, got: " .. tostring(result)) + end +) + +-- this should not happen during normal operation. +test.register_coroutine_test( + "next_index: returns the lowest gap when indices are non-contiguous", + function() + -- Insert entries at indices 1 and 3, leaving a gap at 2 + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e3 }) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 2, + "Expected 2 as the lowest gap, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- get_max_entries +-- =========================================================================== + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "users") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 for the credentials table when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "credentials") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- Persistence — mutations write to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: add_entry writes the new users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "users", entry) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 1 and persisted[1].userName == "Alice", + "Persisted user data does not match the added entry") + end +) + +test.register_coroutine_test( + "persist: update_entry writes the updated users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userType = "adminMember", userName = "Alice" } }, + { visibility = { displayed = false } })) + ) + table_utils.update_entry(mock_device, "users", 1, { userType = "adminMember" }) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after update") + assert(persisted[1].userType == "adminMember", + "Persisted user type was not updated, got: " .. tostring(persisted[1].userType)) + end +) + +test.register_coroutine_test( + "persist: delete_entry removes the entry from the persistent store immediately", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ e2 }, { visibility = { displayed = false } })) + ) + table_utils.delete_entry(mock_device, "users", 1) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after delete, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 2, + "Expected remaining user to have userIndex == 2, got: " .. tostring(persisted[1].userIndex)) + end +) + +test.register_coroutine_test( + "persist: delete_all_entries writes an empty table to the persistent store", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + table_utils.delete_all_entries(mock_device, "users") + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 0, + "Expected empty persistent store after delete_all, got: " .. tostring(persisted and #persisted)) + end +) + +test.register_coroutine_test( + "persist: add_entry writes the new credentials table to the persistent store immediately", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "credentials", cred) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedCredentials")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted credential, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].credentialIndex == 1, + "Persisted credential index does not match") + end +) + +-- =========================================================================== +-- Persistence — get_state falls back to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: get_state returns data from the persistent store when capability state cache is absent", + function() + -- The device was loaded with a pre-seeded persistent field (set in test_init + -- below), but no capability event has been emitted for users, so get_latest_state + -- returns nil. get_state must fall back to the persistent store. + local state = table_utils.get_state(mock_device, "users") + assert(type(state) == "table" and #state == 1, + "Expected 1 user from persistent-store fallback, got: " .. tostring(state and #state)) + assert(state[1].userIndex == 1 and state[1].userName == "Alice", + "Fallback data does not match pre-seeded persistent entry") + end, + { + test_init = function() + -- Pre-seed persistent store BEFORE add_test_device so that wrapped_init + -- copies the field into the device's persistent_store on startup. + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +-- =========================================================================== +-- Persistence — restore_from_persistent_store re-emits stored tables +-- =========================================================================== + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits users capability event for stored data", + function() + local user = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ user }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end, + { + test_init = function() + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits credentials capability event for stored data", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end, + { + test_init = function() + mock_device:set_field( + "persistedCredentials", + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store is a no-op when the persistent store is empty", + function() + -- No capability events expected when there is nothing in the persistent store. + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua new file mode 100644 index 0000000000..2f68f0cfb6 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua @@ -0,0 +1,654 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockUsers capability commands: +-- addUser, updateUser, deleteUser, deleteAllUsers + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- Directly insert users into the device state via table_utils (mirrors test_lock_tables.lua). +-- Consumes the resulting capability events so the socket queue stays clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Directly insert credentials into the device state. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- Set totalUsersSupported on the mock device and consume the resulting event. +local function set_total_users_supported(n) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) + ) + mock_device:emit_event(capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) + test.wait_for_events() +end + +-- ============================================================================ +-- addUser +-- ============================================================================ + +test.register_coroutine_test( + "addUser: assigns userIndex 1 for the first user and emits a success commandResult", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Alice", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: assigns the next sequential userIndex when users already exist", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: fills a gap left by a deleted user rather than appending beyond max", + function() + -- Seed indices 1 and 3; index 2 is the expected gap to fill + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: returns resourceExhausted when totalUsersSupported has been reached", + function() + set_total_users_supported(2) + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser +-- ============================================================================ + +test.register_coroutine_test( + "updateUser: updates an existing user and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceUpdated", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceUpdated", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: adds user and returns success when the target userIndex does not exist", + function() + -- empty table, add user + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 99, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 99, userName = "Ghost", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 99 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: can change a user's type as well as name", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Alice", "adminMember" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "adminMember" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser (no associated credential — pure local delete) +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser: removes a user with no credential and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser: returns failure when the target userIndex is not in the users table", + function() + -- empty table + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 99 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (injects deleteAllCredentials → ClearAllPINCodes zigbee flow) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers: sends ClearAllPINCodes and emits success for both users and credentials on PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + -- deleteAllUsers injects deleteAllCredentials, which sends ClearAllPINCodes + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + local ResponseStatus = DoorLock.types.DrlkPassFailStatus + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers: emits failure for both users and credentials when the lock returns FAIL", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + local ResponseStatus = DoorLock.types.DrlkPassFailStatus + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- State-consistency: add → delete → re-add (indices 1, 2, 3 lifecycle) +-- ============================================================================ +-- +-- These tests verify that the users and credentials tables stay in sync +-- through a full lifecycle: populate three slots, remove the middle one, +-- then re-add a user and credential into the freed slot. The goal is to +-- confirm there is no stale index state that would cause duplicate entries, +-- wrong slot assignment, or mismatched user↔credential links. + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +test.register_coroutine_test( + "State-consistency: add users 1-3, deleteUser 2 (no credential), re-add user reclaims index 2", + function() + -- Populate three user slots directly (no credentials, so deleteUser will take + -- the no-ZigBee path and delete the user entry locally). + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + -- Delete user at index 2; no credential is linked so this is a pure local delete. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3}, so it assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "State-consistency: add users+credentials 1-3, deleteUser 2 (with credential), re-add user+credential reclaims index 2 cleanly", + function() + -- Populate three user and credential slots. + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }) + + -- Delete user at index 2. The handler finds a linked credential and injects + -- deleteCredential, which sends ClearPINCode to the lock. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + -- Lock acknowledges the deletion. + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + -- clear_pin_code_response (LOCK_USERS.DELETE path): deletes credentials first, then user. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 2, userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3} and assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a credential for the new user at slot 2. The old credential at + -- credentialIndex 2 was cleanly removed, so add_entry must succeed without + -- returning OCCUPIED or any stale-state error. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "9999" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "9999"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- Credential is freshly added at index 2 alongside the retained entries at 1 and 3. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 2, credentialIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua deleted file mode 100644 index 51f5cbd21b..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua +++ /dev/null @@ -1,246 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Mock out globals -local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local t_utils = require "integration_test.utils" - -local clusters = require "st.zigbee.zcl.clusters" -local PowerConfiguration = clusters.PowerConfiguration -local DoorLock = clusters.DoorLock -local Alarm = clusters.Alarms - -local json = require "st.json" - -local mock_datastore = require "integration_test.mock_env_datastore" - -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - data = { - lockCodes = json.encode({ - ["1"] = "Zach", - ["2"] = "Steven" - }) - } - } -) - -local mock_device_no_data = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - data = {} - } -) -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - zigbee_test_utils.init_noop_health_check_timer() -end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "Device added data lock codes population", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device added without data should function", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, DoorLock.attributes.LockState:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, Alarm.attributes.AlarmCount:read(mock_device_no_data) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", nil) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", nil) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", nil) - end -) - -test.register_coroutine_test( - "Device init after added shouldn't change the datastores", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device init with new data should populate fields", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, DoorLock.attributes.LockState:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, Alarm.attributes.AlarmCount:read(mock_device_no_data) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "lockCodes", nil) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "__state_cache", {}) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - test.socket.device_lifecycle():__queue_receive(mock_device_no_data:generate_info_changed( - { - data = { - lockCodes = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) - } - } - )) - test.wait_for_events() - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device added data lock codes population, device response produces no events", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.wait_for_events() - - -- run do_configure step after added and verify no refresh all codes - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, - 600, - 21600, - 1) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - DoorLock.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, - 0, - 3600, - 0) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - Alarm.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, - 0, - 21600, - 0) }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - -- Validate migration reload skipped datastore - test.wait_for_events() - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationReloadSkipped", true) - -- Verify the timer doesn't fire as it wasn't created - test.mock_time.advance_time(4) - test.wait_for_events() - end -) - - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua new file mode 100644 index 0000000000..c3b316f486 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua @@ -0,0 +1,946 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local PowerConfiguration = clusters.PowerConfiguration +local DoorLock = clusters.DoorLock +local Alarm = clusters.Alarms +local capabilities = require "st.capabilities" + +local DoorLockState = DoorLock.attributes.LockState +local OperationEventCode = DoorLock.types.OperationEventCode +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode + +local consts = require "lock_utils.constants" +local json = require "dkjson" + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("base-lock.yml") } +) +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +local expect_reload_all_codes_messages = function() + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) }) +end + +test.register_coroutine_test( + "Configure should configure all necessary attributes and begin reading codes", + function() + mock_device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, false, { persist = true }) + test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, + 600, + 21600, + 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + DoorLock.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, + 0, + 3600, + 0) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + Alarm.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, + 0, + 21600, + 0) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + test.mock_time.advance_time(2) + expect_reload_all_codes_messages() + + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should read expected attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }}) + + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Lock status reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, + DoorLockState.LOCKED) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, + 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Lock operation event reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x02, + OperationEventCode.LOCK, + 0x0000, + "", + 0x0000, + "") } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Pin response reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x02, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 set", + { data = { codeName = "Code 2" }, state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"} ), { visibility = { displayed = false } })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Sending the lock command should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "lock", component = "main", command = "lock", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, DoorLock.server.commands.LockDoor(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min lock code length report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 4) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max lock code length report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 4) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(4, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max user code number report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reloading all codes of an unconfigured lock should generate correct attribute checks", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) + expect_reload_all_codes_messages() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Requesting a user code should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = capabilities.lockCodes.ID, command = "requestCode", args = { 1 } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Deleting a user code should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", + { data = { codeName = "Code 1" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode( {["1"] = "Code 1"} ), { visibility = { displayed = false }}) + )) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "deleteCode", args = { 1 } } }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + "")}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 deleted", + { data = { codeName = "Code 1"}, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) + )) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code should result in the named code changed event firing", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +local function init_code_slot(slot_number, name, device) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { slot_number, "1234", name } } }) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.SetPINCode(device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.GetPINCode(device, slot_number) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCodes.codeChanged(slot_number .. " set", { data = { codeName = name }, state_change = true })) + ) +end + +test.register_coroutine_test( + "Setting a user code name should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "nameSlot", args = { 1, "foo" } } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code name via setCode should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "", "foo"} } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Calling updateCodes should send properly spaced commands", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "updateCodes", args = {{code1 = "1234", code2 = "2345", code3 = "3456", code4 = ""}}}}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "2345" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "3456" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 2) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 3) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 4) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting all user codes should result in a code set event for each", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "updateCodes", args = {{code1 = "1234", code2 = "2345", code3 = "3456", code4 = ""}}}}) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "2345")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 2) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "3456")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 3) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Master code programming event should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x00, + ProgrammingEventCode.MASTER_CODE_CHANGED, + 0, + "1234", + DoorLockUserType.MASTER_USER, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "The lock reporting a single code has been set should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "Code 1"}, state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The lock reporting a code has been deleted should be handled", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "Code 1"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The lock reporting that all codes have been deleted should be handled", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + init_code_slot(2, "Code 2", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2"}), { visibility = { displayed = false } }))) + init_code_slot(3, "Code 3", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2", ["3"] = "Code 3"}), { visibility = { displayed = false } }))) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 0xFF, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + } + ) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "Code 1"}, state_change = true }) + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("2 deleted", { data = { codeName = "Code 2"}, state_change = true }) + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("3 deleted", { data = { codeName = "Code 3"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The lock reporting unlock via code should include the code info in the report", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.UNLOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Lock state attribute reports (after the first) should be delayed if they come before event notifications ", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.UNLOCKED)}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked() + ) + ) + test.mock_time.advance_time(2) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.UNLOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + test.mock_time.advance_time(2) + test.timer.__create_and_queue_test_time_advance_timer(2.5, "oneshot") + test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.LOCKED)}) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.LOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + test.mock_time.advance_time(2.5) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked() + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Alarm code 0 should generate lock unknown event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x00, DoorLock.ID) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Alarm code 1 should generate lock unknown event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x01, DoorLock.ID) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Pin response for unoccupied slot with no existing code should generate unset event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x05, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("5 unset", + { data = { codeName = "Code 5" }, state_change = true })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot should use changed change type", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 changed", { data = { codeName = "Code 1" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_v10.lua similarity index 96% rename from drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_v10.lua index b3fff00323..9ce880efe6 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_v10.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -22,6 +11,10 @@ local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Alarm = clusters.Alarms local capabilities = require "st.capabilities" +-- Note: This is not the proper way to test against previous versions. +-- Instead, testing should be run against different lua lib artifacts +local version = require "version" +version.api = 10 local DoorLockState = DoorLock.attributes.LockState local OperationEventCode = DoorLock.types.OperationEventCode @@ -36,9 +29,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -88,7 +79,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -100,7 +94,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -117,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) } + }, + { + min_api_version = 17 } ) @@ -134,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) @@ -158,6 +161,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -188,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"} ), { visibility = { displayed = false } })) } + }, + { + min_api_version = 17 } ) @@ -204,6 +213,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, DoorLock.server.commands.LockDoor(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -220,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false }})) } + }, + { + min_api_version = 17 } ) @@ -236,6 +251,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(4, { visibility = { displayed = false }})) } + }, + { + min_api_version = 17 } ) @@ -253,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false }})) } + }, + { + min_api_version = 17 } ) @@ -261,7 +282,10 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -277,6 +301,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) } } + }, + { + min_api_version = 17 } ) @@ -316,7 +343,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -363,7 +393,10 @@ test.register_coroutine_test( capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test" }, state_change = true }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 17 + } ) local function init_code_slot(slot_number, name, device) @@ -418,7 +451,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -433,7 +469,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -504,7 +543,10 @@ test.register_coroutine_test( DoorLock.server.commands.GetPINCode(mock_device, 4) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -536,7 +578,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -568,6 +613,9 @@ test.register_message_test( capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) ) } + }, + { + min_api_version = 17 } ) @@ -604,6 +652,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } })) } + }, + { + min_api_version = 17 } ) @@ -636,7 +687,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -690,7 +744,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -718,7 +775,10 @@ test.register_coroutine_test( capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -781,7 +841,10 @@ test.register_coroutine_test( capabilities.lock.lock.locked() ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua new file mode 100644 index 0000000000..e561a4867a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -0,0 +1,566 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Additional tests for lock_handlers/zigbee_responses.lua to improve coverage. +-- Covers: +-- • get_pin_code_response: sync codes from lock flow +-- • programming_event_notification: Yale/ASSA ABLOY user_id >= 256 shift +-- • operating_event_notification: keypad source with lockUsers capability +-- • operating_event_notification: schedule events with non-keypad source (auto method) +-- • alarm: alarm codes 0 and 1 +-- • lock_state: attribute handler with delay logic +-- • max_pin_code_length / min_pin_code_length: attribute handlers +-- • number_of_pin_users_supported: attribute handler with profile migration + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local Alarms = clusters.Alarms +local constants = require "lock_utils.constants" +local table_utils = require "lock_utils.tables" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local OperationEventCode = DoorLock.types.OperationEventCode +local OperationEventSource = DoorLock.types.DrlkOperationEventSource +local ProgrammingEventCode = DoorLock.types.ProgramEventCode +local LockState = DoorLock.attributes.LockState + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +local mock_device_yale = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Yale", + model = "YRD256", + server_clusters = { DoorLock.ID }, + }, + }, +}) + +local mock_device_no_lock_codes = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-without-codes.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_yale) + test.mock_device.add_test_device(mock_device_no_lock_codes) +end + +test.set_test_init_function(test_init) + +-- Helper: build OperatingEventNotification +local function build_operating_event(device, event_code, event_source, user_id) + return { + device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + device, + event_source, + event_code, + user_id or 0, + "1234", + 0x0000, + "data" + ), + } +end + +-- Helper: build ProgrammingEventNotification +local function build_programming_event(device, event_code, user_id) + return { + device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + device, + 0x00, + event_code, + user_id, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ), + } +end + +-- ============================================================================ +-- get_pin_code_response: sync codes from lock +-- ============================================================================ + +test.register_coroutine_test( + "get_pin_code_response: syncs codes from lock and requests next code", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Set up the sync state + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- Receive GetPINCodeResponse for user_id 1 + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, -- user_id + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" -- PIN code + ), + }) + + -- Should add user entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + -- Should add credential entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + -- Should request next code + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 2), + }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "get_pin_code_response: completes sync when max entries reached", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Set up the sync state at the last code index + -- get_max_entries defaults to 20 when attribute is missing, so use user_id 20 + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + -- Receive GetPINCodeResponse for user_id 20 (at default max) + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 20, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ), + }) + + -- Should add entries + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 20, userName = "Guest 20", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 20, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 20" } }, + { visibility = { displayed = false } } + )) + ) + -- Should NOT request next code (sync complete) + test.wait_for_events() + + -- Verify sync state is cleared (clear_busy_state sets BUSY to false, not nil) + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil) + assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false) + end +) + +-- ============================================================================ +-- programming_event_notification: Yale user_id shift +-- ============================================================================ + +test.register_coroutine_test( + "programming_event_notification: Yale device shifts user_id >= 256", + function() + mock_device_yale:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Send a PIN_CODE_ADDED with user_id 256 (0x100), which should shift to 1 + test.socket.zigbee:__queue_receive( + build_programming_event(mock_device_yale, ProgrammingEventCode.PIN_CODE_ADDED, 256) + ) + + -- After shifting 256 >> 8 = 1, should add entries for user_id 1 + test.socket.capability:__expect_send( + mock_device_yale:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device_yale:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- operating_event_notification: keypad with lockUsers (user info lookup) +-- ============================================================================ + +test.register_coroutine_test( + "operating_event_notification: keypad unlock includes user info when user and credential exist", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- First, add a user entry (using valid userType "guest") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "John Doe", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + table_utils.add_entry(mock_device, "users", { + userIndex = 1, + userName = "John Doe", + userType = "guest", + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "John's PIN" } }, + { visibility = { displayed = false } } + )) + ) + table_utils.add_entry(mock_device, "credentials", { + userIndex = 1, + credentialIndex = 1, + credentialType = "pin", + credentialName = "John's PIN", + }) + test.wait_for_events() + + -- Send unlock event from keypad with user_id 1 + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.UNLOCK, OperationEventSource.KEYPAD, 1) + ) + + -- Should emit unlocked with user info + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 1, + userName = "John Doe", + userType = "guest", + }, + }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: keypad unlock with unknown user uses default name", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Send unlock event from keypad with user_id 99 (no matching entry) + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.UNLOCK, OperationEventSource.KEYPAD, 99) + ) + + -- Should emit unlocked with default user name + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 99, + userName = "Guest 99", + }, + }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- operating_event_notification: schedule events with "auto" method +-- ============================================================================ + +test.register_coroutine_test( + "operating_event_notification: SCHEDULE_LOCK with RF source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.SCHEDULE_LOCK, OperationEventSource.RF, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: SCHEDULE_UNLOCK with MANUAL source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.SCHEDULE_UNLOCK, OperationEventSource.MANUAL, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: AUTO_LOCK with RF source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.AUTO_LOCK, OperationEventSource.RF, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- alarm: alarm handler +-- ============================================================================ + +test.register_coroutine_test( + "alarm: alarm code 0 emits lock.unknown", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 0, DoorLock.ID), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "alarm: alarm code 1 emits lock.unknown", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 1, DoorLock.ID), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "alarm: unrecognized alarm code does not emit event", + function() + -- Alarm code 16 (low battery) is not in ALARM_REPORT map + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 16, DoorLock.ID), + }) + + -- No event should be emitted + test.wait_for_events() + end +) + +-- ============================================================================ +-- lock_state: attribute handler +-- ============================================================================ + +test.register_coroutine_test( + "lock_state: LOCKED state emits locked event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.LOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "lock_state: UNLOCKED state emits unlocked event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.UNLOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "lock_state: NOT_FULLY_LOCKED state emits unknown event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.NOT_FULLY_LOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- max_pin_code_length / min_pin_code_length: attribute handlers +-- ============================================================================ + +test.register_coroutine_test( + "max_pin_code_length: emits maxPinCodeLen event", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 8), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.maxPinCodeLen(8, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "min_pin_code_length: emits minPinCodeLen event", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 4), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- number_of_pin_users_supported: attribute handler +-- ============================================================================ + +test.register_coroutine_test( + "number_of_pin_users_supported: emits pinUsersSupported and totalUsersSupported", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 20), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(20, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(20, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "number_of_pin_users_supported: triggers profile migration when device has no lockCodes", + function() + test.socket.zigbee:__queue_receive({ + mock_device_no_lock_codes.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device_no_lock_codes, 10), + }) + + -- Should trigger profile update to base-lock + mock_device_no_lock_codes:expect_metadata_update({ profile = "base-lock" }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "number_of_pin_users_supported: zero value does not trigger profile migration", + function() + test.socket.zigbee:__queue_receive({ + mock_device_no_lock_codes.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device_no_lock_codes, 0), + }) + + -- No profile update should occur (value is 0) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua index ba1b60d674..882d8f5efe 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua @@ -30,6 +30,7 @@ local SAMSUNG_SDS_MFR_CODE = 0x0003 local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("lock-without-codes.yml"), + provisioning_state = "TYPED", zigbee_endpoints = { [1] = { id = 1, @@ -49,6 +50,20 @@ end test.set_test_init_function(test_init) +local constants = require "lock_utils.constants" +test.register_coroutine_test( + "Device init function handler", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init"}) + test.socket.capability:__set_channel_ordering("relaxed") + test.wait_for_events() + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "Device init did not set migrated field to true") + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes", function() @@ -74,7 +89,10 @@ test.register_coroutine_test( ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -91,6 +109,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) } + }, + { + min_api_version = 17 } ) @@ -108,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) } + }, + { + min_api_version = 17 } ) @@ -120,6 +144,9 @@ test.register_message_test( message = { mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.NOT_FULLY_LOCKED) } } + }, + { + min_api_version = 17 } ) @@ -143,9 +170,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.locked({ data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -169,9 +199,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.unlocked({ data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.unlocked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -195,9 +228,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.locked({ data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -221,9 +257,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.locked({ data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -247,9 +286,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.unlocked({ data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.unlocked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -273,9 +315,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.locked({ data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -299,9 +344,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.locked({data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.locked({data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -325,9 +373,12 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", - capabilities.lock.lock.unlocked({ data = { codeId = "0", codeName = "Code 0", method = "keypad"} }) + capabilities.lock.lock.unlocked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -352,6 +403,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -376,6 +430,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -400,6 +457,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -424,6 +484,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -448,6 +511,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -472,6 +538,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -496,6 +565,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -520,6 +592,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -544,6 +619,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -568,6 +646,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -592,6 +673,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -616,6 +700,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -640,6 +727,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -664,6 +754,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -688,6 +781,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -712,6 +808,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -736,6 +835,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -760,6 +862,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -784,6 +889,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -808,6 +916,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -832,6 +943,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -856,6 +970,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -880,6 +997,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -904,6 +1024,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -928,6 +1051,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -952,6 +1078,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -976,6 +1105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1000,6 +1132,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1024,6 +1159,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1048,6 +1186,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -1072,6 +1213,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1096,6 +1240,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1120,6 +1267,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1144,6 +1294,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1168,6 +1321,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1192,6 +1348,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1216,6 +1375,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1240,6 +1402,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -1264,6 +1429,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1288,6 +1456,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1308,7 +1479,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -1327,6 +1501,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) }, + }, + { + min_api_version = 19 } ) @@ -1341,6 +1518,9 @@ test.register_message_test( zigbee_test_utils.build_custom_command_id(mock_device, DoorLock.ID, SAMSUNG_SDS_MFR_SPECIFIC_COMMAND, SAMSUNG_SDS_MFR_CODE, "") } } + }, + { + min_api_version = 19 } ) @@ -1358,7 +1538,10 @@ test.register_coroutine_test( zigbee_test_utils.build_tx_custom_command_id(mock_device, DoorLock.ID, SAMSUNG_SDS_MFR_SPECIFIC_COMMAND, SAMSUNG_SDS_MFR_CODE, "1235") }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1371,18 +1554,30 @@ test.register_coroutine_test( } ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Device added function handler", function() + -- The initial lock event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked())) test.wait_for_events() - end + -- Avoid sending the initial lock event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua deleted file mode 100644 index 11a3193d93..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua +++ /dev/null @@ -1,51 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Mock out globals -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local PowerConfiguration = clusters.PowerConfiguration - -local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), zigbee_endpoints ={ [1] = {id = 1, manufacturer ="Yale", model ="YRD220/240 TSDB", server_clusters = {}} } }) - -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Battery percentage report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, - 55) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua deleted file mode 100644 index 2c1ad70a0b..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua +++ /dev/null @@ -1,51 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Mock out globals -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local DoorLock = clusters.DoorLock - -local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), zigbee_endpoints ={ [1] = {id = 1, manufacturer ="ASSA ABLOY iRevo", model ="iZBModule01", server_clusters = {}} } }) - -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Max user code number report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, - 16) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(30)) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua new file mode 100644 index 0000000000..b0f4d1ffcf --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua @@ -0,0 +1,78 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local DoorLock = clusters.DoorLock +local Alarm = clusters.Alarms + +local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), zigbee_endpoints ={ [1] = {id = 1, manufacturer ="ASSA ABLOY iRevo", model ="iZBModule01", server_clusters = {}} } }) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +local function test_init_new_capabilities() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) +end + +test.register_message_test( + "Max user code number report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(30)) + } + }, + { + test_init = test_init, + min_api_version = 17 + } +) + +test.register_message_test( + "Max user code number report should be handled for migrated locks", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(30)) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(30)) + } + }, + { test_init = test_init_new_capabilities } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua similarity index 65% rename from drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua index c31ab48eb5..95d496e786 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -37,9 +26,7 @@ local mock_device = test.mock_device.build_test_zigbee_device({ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -58,7 +45,10 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -97,7 +87,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -144,7 +137,60 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false }}))) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting the master code should result in the correct user type being used", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 0, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 0, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.MASTER_USER, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 0) + } + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x00, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.MASTER_USER, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "test"}, state_change = true })) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["0"] = "test"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } ) @@ -170,6 +216,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("0 unset", { data = { codeName = "Code 0" }, state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -225,7 +274,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -267,7 +319,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -282,7 +337,113 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting a user code for a slot that is empty should indicate failure and unset", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 is not set", { state_change = true }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot without pending operation should use changed change type", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 changed", { data = { codeName = "initialName" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot that is now empty should delete the code", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "initialName" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua new file mode 100644 index 0000000000..48a6521059 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local yale_fingerprint_lock_models = function(opts, driver, device) + local consts = require "lock_utils.constants" + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + if not slga_migrated then return false end + local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("yale-fingerprint-lock") + end + end + return false +end + +return yale_fingerprint_lock_models diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua new file mode 100644 index 0000000000..b3db27d719 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local YALE_FINGERPRINT_LOCK = { + { mfr = "ASSA ABLOY iRevo", model = "iZBModule01" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "0700000001" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +return YALE_FINGERPRINT_LOCK diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua index 9d0a0b4148..9965ef181e 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua @@ -1,42 +1,20 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local LockCluster = clusters.DoorLock -local LockCodes = capabilities.lockCodes +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers local YALE_FINGERPRINT_MAX_CODES = 0x1E -local YALE_FINGERPRINT_LOCK = { - { mfr = "ASSA ABLOY iRevo", model = "iZBModule01" }, - { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, - { mfr = "ASSA ABLOY iRevo", model = "0700000001" }, - { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } -} -local yale_fingerprint_lock_models = function(opts, driver, device) - for _, fingerprint in ipairs(YALE_FINGERPRINT_LOCK) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local handle_max_codes = function(driver, device, value) - device:emit_event(LockCodes.maxCodes(YALE_FINGERPRINT_MAX_CODES), { visibility = { displayed = false } }) + device:emit_event(LockCredentials.pinUsersSupported(YALE_FINGERPRINT_MAX_CODES)) + device:emit_event(LockUsers.totalUsersSupported(YALE_FINGERPRINT_MAX_CODES)) end local yale_fingerprint_lock_driver = { @@ -48,7 +26,7 @@ local yale_fingerprint_lock_driver = { } } }, - can_handle = yale_fingerprint_lock_models + can_handle = require("yale-fingerprint-lock.can_handle") } return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua deleted file mode 100644 index 59fdbf228b..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua +++ /dev/null @@ -1,52 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" - -local BAD_YALE_LOCK_FINGERPRINTS = { - { mfr = "Yale", model = "YRD220/240 TSDB" }, - { mfr = "Yale", model = "YRL220 TS LL" }, - { mfr = "Yale", model = "YRD210 PB DB" }, - { mfr = "Yale", model = "YRL210 PB LL" }, - { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, - { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } -} - -local is_bad_yale_lock_models = function(opts, driver, device) - for _, fingerprint in ipairs(BAD_YALE_LOCK_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - -local battery_report_handler = function(driver, device, value) - device:emit_event(capabilities.battery.battery(value.value)) -end - -local bad_yale_driver = { - NAME = "YALE BAD Lock Driver", - zigbee_handlers = { - attr = { - [clusters.PowerConfiguration.ID] = { - [clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_report_handler - } - } - }, - can_handle = is_bad_yale_lock_models -} - -return bad_yale_driver diff --git a/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml index 2037992e9c..89d3df1586 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml @@ -24,6 +24,11 @@ zigbeeManufacturer: manufacturer: eWeLink model: MSO1 deviceProfileName: motion-battery + - id: eWeLink/SNZB-03P + deviceLabel: Zigbee Motion Sensor + manufacturer: eWeLink + model: SNZB-03P + deviceProfileName: motion - id: "ORVIBO/895a2d80097f4ae2b2d40500d5e0" deviceLabel: Orvibo Motion Sensor manufacturer: ORVIBO @@ -159,15 +164,20 @@ zigbeeManufacturer: model: VMS_ADUROLIGHT deviceProfileName: motion-battery - id: frientA/S/140 - deviceLabel: frient Motion Sensor + deviceLabel: frient Motion Sensor Pro manufacturer: frient A/S model: MOSZB-140 - deviceProfileName: motion-temp-battery + deviceProfileName: frient-motion-temp-illuminance-tamper-battery - id: frientA/S/141 deviceLabel: frient Motion Sensor manufacturer: frient A/S model: MOSZB-141 - deviceProfileName: motion-battery + deviceProfileName: frient-motion-battery + - id: frientA/S/153 + deviceLabel: frient Motion Sensor 2 Pet + manufacturer: frient A/S + model: MOSZB-153 + deviceProfileName: frient-motion-temp-illuminance-battery - id: Compacta/ZBMS3-1 deviceLabel: Smartenit Motion Sensor manufacturer: Compacta @@ -199,6 +209,11 @@ zigbeeManufacturer: manufacturer: sengled model: E1M-G7H deviceProfileName: motion-battery + - id: "MultIR/MIR-IR100" + deviceLabel: MultiIR Motion Detector MIR-IR100 + manufacturer: MultIR + model: MIR-IR100 + deviceProfileName: motion-battery-illuminance-sensitivity-frequency-no-fw-update zigbeeGeneric: - id: kickstarter/motion/1 deviceLabel: SmartThings Motion Sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-battery.yml b/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-battery.yml new file mode 100644 index 0000000000..d23907609d --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-battery.yml @@ -0,0 +1,42 @@ +name: frient-motion-battery +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - title: "Motion Turn-Off Delay (s)" + name: occupiedToUnoccupiedD + description: "Delay in seconds to report after no motion is detected" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 + - title: "Motion Turn-On Delay (s)" + name: unoccupiedToOccupiedD + description: "Delay in seconds to report after motion is detected" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 0 + - title: "Movement Threshold in Turn-On Delay" + name: unoccupiedToOccupiedT + description: "Number of movements to detect before reporting motion during the Motion Turn-On Delay" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 254 + default: 1 diff --git a/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-battery.yml b/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-battery.yml new file mode 100644 index 0000000000..1c8f50f822 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-battery.yml @@ -0,0 +1,57 @@ +name: frient-motion-temp-illuminance-battery +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature Sensitivity (°C)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - title: "Motion Turn-Off Delay (s)" + name: occupiedToUnoccupiedD + description: "Delay in seconds to report after no motion is detected" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 + - title: "Motion Turn-On Delay (s)" + name: unoccupiedToOccupiedD + description: "Delay in seconds to report after motion is detected" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 0 + - title: "Movement Threshold in Turn-On Delay" + name: unoccupiedToOccupiedT + description: "Number of movements to detect before reporting motion during the Motion Turn-On Delay" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 254 + default: 1 diff --git a/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-tamper-battery.yml b/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-tamper-battery.yml new file mode 100644 index 0000000000..f347d0148d --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/profiles/frient-motion-temp-illuminance-tamper-battery.yml @@ -0,0 +1,59 @@ +name: frient-motion-temp-illuminance-tamper-battery +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: battery + version: 1 + - id: tamperAlert + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature Sensitivity (°C)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - title: "Motion Turn-Off Delay (s)" + name: occupiedToUnoccupiedD + description: "Delay in seconds to report after no motion is detected" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 + - title: "Motion Turn-On Delay (s)" + name: unoccupiedToOccupiedD + description: "Delay in seconds to report after motion is detected" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 0 + - title: "Movement Threshold in Turn-On Delay" + name: unoccupiedToOccupiedT + description: "Number of movements to detect before reporting motion during the Motion Turn-On Delay" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 254 + default: 1 diff --git a/drivers/SmartThings/zigbee-motion-sensor/profiles/motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml b/drivers/SmartThings/zigbee-motion-sensor/profiles/motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml new file mode 100644 index 0000000000..5fc7d7ea8b --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/profiles/motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml @@ -0,0 +1,26 @@ +name: motion-battery-illuminance-sensitivity-frequency-no-fw-update +components: + - id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: stse.sensitivityAdjustment + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - title: "检测频率/秒(detection frequency/sec)" + name: detectionfrequency + description: "传感器检测频率(Sensor detects frequency unit: seconds)" + required: false + preferenceType: integer + definition: + minimum: 10 + maximum: 1800 + default: 60 diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..139d3aa054 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-IR100" } +} diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/init.lua new file mode 100644 index 0000000000..c3c47753e1 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/init.lua @@ -0,0 +1,117 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" +local zcl_messages = require "st.zigbee.zcl" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" + +local sensitivityAdjustment = capabilities["stse.sensitivityAdjustment"] +local sensitivityAdjustmentCommandName = "setSensitivityAdjustment" +local IASZone = zcl_clusters.IASZone +local IASZone_PRIVATE_COMMAND_ID = 0xF4 + +local PREF_SENSITIVITY_VALUE_HIGH = 3 +local PREF_SENSITIVITY_VALUE_MEDIUM = 2 +local PREF_SENSITIVITY_VALUE_LOW = 1 + +local function send_iaszone_private_cmd(device, priv_cmd, data) + local frame_ctrl = FrameCtrl(0x00) + frame_ctrl:set_cluster_specific() + + local zclh = zcl_messages.ZclHeader({ + frame_ctrl = frame_ctrl, + cmd = data_types.ZCLCommandId(priv_cmd) + }) + + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = data_types.Uint16(data) + }) + + local addr_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(IASZone.ID), + zb_const.HA_PROFILE_ID, + IASZone.ID + ) + + local zigbee_msg = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + + device:send(zigbee_msg) +end + +local function iaszone_attr_sen_handler(driver, device, value, zb_rx) + if value.value == PREF_SENSITIVITY_VALUE_HIGH then + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.High()) + elseif value.value == PREF_SENSITIVITY_VALUE_MEDIUM then + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.Medium()) + elseif value.value == PREF_SENSITIVITY_VALUE_LOW then + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.Low()) + end +end + +local function send_sensitivity_adjustment_value(device, value) + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:write(device, value)) +end + +local function sensitivity_adjustment_capability_handler(driver, device, command) + local sensitivity = command.args.sensitivity + if sensitivity == 'High' then + send_sensitivity_adjustment_value(device, PREF_SENSITIVITY_VALUE_HIGH) + elseif sensitivity == 'Medium' then + send_sensitivity_adjustment_value(device, PREF_SENSITIVITY_VALUE_MEDIUM) + elseif sensitivity == 'Low' then + send_sensitivity_adjustment_value(device, PREF_SENSITIVITY_VALUE_LOW) + end + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:read(device)) +end + +local function added_handler(self, device) + device:emit_event(capabilities.motionSensor.motion.inactive()) + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.High()) + device:emit_event(capabilities.battery.battery(100)) + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:read(device)) +end + +local function info_changed(driver, device, event, args) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "detectionfrequency") then + local detectionfrequency = tonumber(device.preferences.detectionfrequency) + send_iaszone_private_cmd(device, IASZone_PRIVATE_COMMAND_ID, detectionfrequency) + end + end + end +end + +local MultiIR_motion_handler = { + NAME = "MultiIR motion handler", + lifecycle_handlers = { + added = added_handler, + infoChanged = info_changed + }, + capability_handlers = { + [sensitivityAdjustment.ID] = { + [sensitivityAdjustmentCommandName] = sensitivity_adjustment_capability_handler, + } + }, + zigbee_handlers = { + attr = { + [IASZone.ID] = { + [IASZone.attributes.CurrentZoneSensitivityLevel.ID] = iaszone_attr_sen_handler + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_motion_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/aqara_utils.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/aqara_utils.lua index e48f8fde92..8a1b184693 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/aqara_utils.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/aqara_utils.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local aqara_utils = {} diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..da7a97c64b --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..705fa72746 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.motion.ac02" }, + { mfr = "LUMI", model = "lumi.motion.agl02" }, + { mfr = "LUMI", model = "lumi.motion.agl04" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/can_handle.lua new file mode 100644 index 0000000000..02c2f313cb --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/can_handle.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + if device:get_model() == "lumi.motion.agl04" then + return true, require("aqara.high-precision-motion") + end + return false +end diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/init.lua index 8c70857bdb..d6c89e3e0c 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/high-precision-motion/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -118,9 +121,7 @@ local aqara_high_precision_motion_handler = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "lumi.motion.agl04" - end + can_handle = require("aqara.high-precision-motion.can_handle") } return aqara_high_precision_motion_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/init.lua index e5649dab4d..e4586d4b0f 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_commands = require "st.zigbee.zcl.global_commands" local clusters = require "st.zigbee.zcl.clusters" @@ -16,11 +19,6 @@ local FREQUENCY_ATTRIBUTE_ID = 0x0102 local MOTION_DETECTED_UINT32 = 65536 -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.motion.ac02" }, - { mfr = "LUMI", model = "lumi.motion.agl02" }, - { mfr = "LUMI", model = "lumi.motion.agl04" } -} local CONFIGURATIONS = { { @@ -33,14 +31,6 @@ local CONFIGURATIONS = { } } -local is_aqara_products = function(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function motion_illuminance_attr_handler(driver, device, value, zb_rx) -- The low 16 bits for Illuminance @@ -96,7 +86,6 @@ local function device_init(driver, device) for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -124,10 +113,8 @@ local aqara_motion_handler = { } } }, - sub_drivers = { - require("aqara.high-precision-motion") - }, - can_handle = is_aqara_products + sub_drivers = require("aqara.sub_drivers"), + can_handle = require("aqara.can_handle"), } return aqara_motion_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aqara/sub_drivers.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/sub_drivers.lua new file mode 100644 index 0000000000..a0e42d2085 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aqara/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("aqara.high-precision-motion") +} diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aurora/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aurora/can_handle.lua new file mode 100644 index 0000000000..f58429a53c --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aurora/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function aurora_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Aurora" and device:get_model() == "MotionSensor51AU" then + return true, require("aurora") + end + return false +end + +return aurora_can_handle diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/aurora/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/aurora/init.lua index 060ed9d308..d08eb13c36 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/aurora/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/aurora/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local function added_handler(self, device) @@ -24,9 +14,7 @@ local aurora_motion_driver = { lifecycle_handlers = { added = added_handler, }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Aurora" and device:get_model() == "MotionSensor51AU" - end + can_handle = require("aurora.can_handle"), } return aurora_motion_driver diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/can_handle.lua new file mode 100644 index 0000000000..01bf292b1a --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local can_handle_battery_voltage = function(opts, driver, device, ...) + local FINGERPRINTS = require("battery-voltage.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("battery-voltage") + end + end + return false +end + +return can_handle_battery_voltage diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/fingerprints.lua new file mode 100644 index 0000000000..4d3006c943 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/fingerprints.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local DEVICES_REPORTING_BATTERY_VOLTAGE = { + { mfr = "Bosch", model = "RFPR-ZB" }, + { mfr = "Bosch", model = "RFDL-ZB-MS" }, + { mfr = "Ecolink", model = "PIRZB1-ECO" }, + { mfr = "ADUROLIGHT", model = "VMS_ADUROLIGHT" }, + { mfr = "AduroSmart Eria", model = "VMS_ADUROLIGHT" } +} + +return DEVICES_REPORTING_BATTERY_VOLTAGE diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/init.lua index 316b626afe..d031aee153 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/battery-voltage/init.lua @@ -1,35 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local battery_defaults = require "st.zigbee.defaults.battery_defaults" - -local DEVICES_REPORTING_BATTERY_VOLTAGE = { - { mfr = "Bosch", model = "RFPR-ZB" }, - { mfr = "Bosch", model = "RFDL-ZB-MS" }, - { mfr = "Ecolink", model = "PIRZB1-ECO" }, - { mfr = "ADUROLIGHT", model = "VMS_ADUROLIGHT" }, - { mfr = "AduroSmart Eria", model = "VMS_ADUROLIGHT" } -} -local can_handle_battery_voltage = function(opts, driver, device, ...) - for _, fingerprint in ipairs(DEVICES_REPORTING_BATTERY_VOLTAGE) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end +local battery_defaults = require "st.zigbee.defaults.battery_defaults" local battery_voltage_motion = { @@ -37,7 +10,7 @@ local battery_voltage_motion = { lifecycle_handlers = { init = battery_defaults.build_linear_voltage_init(2.1, 3.0) }, - can_handle = can_handle_battery_voltage + can_handle = require("battery-voltage.can_handle"), } return battery_voltage_motion diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/centralite/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/centralite/can_handle.lua new file mode 100644 index 0000000000..750bcab752 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/centralite/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function centralite_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "CentraLite" then + return true, require("centralite") + end + return false +end + +return centralite_can_handle diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/centralite/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/centralite/init.lua index 6a40360224..e2492e7c78 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/centralite/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/centralite/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -46,9 +36,7 @@ local centralite_handler = { lifecycle_handlers = { init = init_handler }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "CentraLite" - end + can_handle = require("centralite.can_handle"), } return centralite_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/compacta/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/compacta/can_handle.lua new file mode 100644 index 0000000000..7946b15125 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/compacta/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function compacta_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Compacta" then + return true, require("compacta") + end + return false +end + +return compacta_can_handle diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/compacta/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/compacta/init.lua index b303f6f8fd..85295b6391 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/compacta/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/compacta/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- ZCL local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -43,9 +33,7 @@ local compacta_driver = { added = added_handler, doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Compacta" - end + can_handle = require("compacta.can_handle"), } return compacta_driver diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/frient/can_handle.lua new file mode 100644 index 0000000000..236ec1f3e5 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/frient/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_frient_motion_sensor(opts, driver, device) + local FINGERPRINTS = require("frient.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("frient") + end + end + return false +end + +return can_handle_frient_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/frient/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/frient/fingerprints.lua new file mode 100644 index 0000000000..ddf11bbdfe --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/frient/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_DEVICE_FINGERPRINTS = { + { mfr = "frient A/S", model = "MOSZB-140"}, + { mfr = "frient A/S", model = "MOSZB-141"}, + { mfr = "frient A/S", model = "MOSZB-153"} +} + +return FRIENT_DEVICE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/frient/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/frient/init.lua index 421fef60ae..901dbf2cf2 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/frient/init.lua @@ -1,83 +1,195 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- ZCL +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" -local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement +local device_management = require "st.zigbee.device_management" + +local IASZone = zcl_clusters.IASZone +local IlluminanceMeasurement = zcl_clusters.IlluminanceMeasurement local OccupancySensing = zcl_clusters.OccupancySensing local PowerConfiguration = zcl_clusters.PowerConfiguration -local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement -local capabilities = require "st.capabilities" +local BATTERY_MIN_VOLTAGE = 2.3 +local BATTERY_MAX_VOLTAGE = 3.0 +local DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY = 240 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY = 0 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD = 0 + +local OCCUPANCY_ENDPOINT = 0x22 +local TAMPER_ENDPOINT = 0x23 +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local TEMPERATURE_ENDPOINT = 0x26 +local ILLUMINANCE_ENDPOINT = 0x27 -local FRIENT_TEMP_CONFIG = { - minimum_interval = 30, - maximum_interval = 300, - reportable_change = 100, - endpoint = 0x26 -} -local FRIENT_BATTERY_CONFIG = { - minimum_interval = 30, - maximum_interval = 21600, - reportable_change = 1, - endpoint = 0x23 -} local function occupancy_attr_handler(driver, device, occupancy, zb_rx) - device:emit_event( - occupancy.value == 1 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive() - ) + device:emit_event(occupancy.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) +end + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) end +local function ias_zone_status_attr_handler(driver, device, attr_val, zb_rx) + generate_event_from_zone_status(driver, device, attr_val, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + + +local CONFIGURATIONS = { + [OCCUPANCY_ENDPOINT] = { + cluster = OccupancySensing.ID, + attribute = OccupancySensing.attributes.Occupancy.ID, + data_type = OccupancySensing.attributes.Occupancy.base_type, + minimum_interval = 0, + maximum_interval = 3600, + endpoint = OCCUPANCY_ENDPOINT + }, + [TAMPER_ENDPOINT] = { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 30, + maximum_interval = 300, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1, + endpoint = TAMPER_ENDPOINT + }, + [TEMPERATURE_ENDPOINT] = { + cluster = TemperatureMeasurement.ID, + attribute = TemperatureMeasurement.attributes.MeasuredValue.ID, + minimum_interval = 30, + maximum_interval = 3600, + data_type = TemperatureMeasurement.attributes.MeasuredValue.base_type, + reportable_change = 10, + endpoint = TEMPERATURE_ENDPOINT + }, + [ILLUMINANCE_ENDPOINT] = { + cluster = IlluminanceMeasurement.ID, + attribute = IlluminanceMeasurement.attributes.MeasuredValue.ID, + data_type = IlluminanceMeasurement.attributes.MeasuredValue.base_type, + minimum_interval = 10, + maximum_interval = 3600, + reportable_change = 0x2711, + endpoint = ILLUMINANCE_ENDPOINT + } +} + +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(BATTERY_MIN_VOLTAGE, BATTERY_MAX_VOLTAGE)(driver, device) + + local attribute + if device:supports_capability_by_id(capabilities.temperatureMeasurement.ID) then + attribute = CONFIGURATIONS[TEMPERATURE_ENDPOINT] + device:add_configured_attribute(attribute) + end + if device:supports_capability_by_id(capabilities.illuminanceMeasurement.ID) then + attribute = CONFIGURATIONS[ILLUMINANCE_ENDPOINT] + device:add_configured_attribute(attribute) + end + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + attribute = CONFIGURATIONS[TAMPER_ENDPOINT] + device:add_configured_attribute(attribute) + end +end + +local function device_added(driver, device) + device:emit_event(capabilities.motionSensor.motion.inactive()) + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function do_refresh(driver, device) + device:send(OccupancySensing.attributes.Occupancy:read(device):to_endpoint(OCCUPANCY_ENDPOINT)) + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device):to_endpoint(POWER_CONFIGURATION_ENDPOINT)) + + if device:supports_capability_by_id(capabilities.temperatureMeasurement.ID) then + device:send(TemperatureMeasurement.attributes.MeasuredValue:read(device):to_endpoint(TEMPERATURE_ENDPOINT)) + end + if device:supports_capability_by_id(capabilities.illuminanceMeasurement.ID) then + device:send(IlluminanceMeasurement.attributes.MeasuredValue:read(device):to_endpoint(ILLUMINANCE_ENDPOINT)) + end + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device:send(IASZone.attributes.ZoneStatus:read(device):to_endpoint(TAMPER_ENDPOINT)) + end +end + + local function do_configure(driver, device) device:configure() - device:send(PowerConfiguration.attributes.BatteryVoltage:configure_reporting( - device, - FRIENT_BATTERY_CONFIG.minimum_interval, - FRIENT_BATTERY_CONFIG.maximum_interval, - FRIENT_BATTERY_CONFIG.reportable_change - ):to_endpoint(FRIENT_BATTERY_CONFIG.endpoint)) - device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( - device, - FRIENT_TEMP_CONFIG.minimum_interval, - FRIENT_TEMP_CONFIG.maximum_interval, - FRIENT_TEMP_CONFIG.reportable_change - ):to_endpoint(FRIENT_TEMP_CONFIG.endpoint)) + device:send(device_management.build_bind_request( + device, + zcl_clusters.OccupancySensing.ID, + driver.environment_info.hub_zigbee_eui, + OCCUPANCY_ENDPOINT + )) + + device:send(OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(device, tonumber(DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY)):to_endpoint(OCCUPANCY_ENDPOINT)) + device:send(OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(device, tonumber(DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY)):to_endpoint(OCCUPANCY_ENDPOINT)) + device:send(OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(device, tonumber(DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD)):to_endpoint(OCCUPANCY_ENDPOINT)) + device:send(OccupancySensing.attributes.Occupancy:configure_reporting(device, 0, 3600):to_endpoint(OCCUPANCY_ENDPOINT)) + + device.thread:call_with_delay(5, function() + do_refresh(driver, device) + end) end -local function added_handler(driver, device) - device:refresh() +local function info_changed(driver, device, event, args) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "temperatureSensitivity") then + local input = device.preferences.temperatureSensitivity + local temperatureSensitivity = math.floor(input * 100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 3600, temperatureSensitivity):to_endpoint(TEMPERATURE_ENDPOINT)) + elseif (name == "occupiedToUnoccupiedD") then + local occupiedToUnoccupiedDelay = device.preferences.occupiedToUnoccupiedD or DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY + device:send(OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(device, occupiedToUnoccupiedDelay):to_endpoint(OCCUPANCY_ENDPOINT)) + elseif (name == "unoccupiedToOccupiedD") then + local occupiedToUnoccupiedD = device.preferences.unoccupiedToOccupiedD or DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY + device:send(OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(device, occupiedToUnoccupiedD):to_endpoint(OCCUPANCY_ENDPOINT)) + elseif (name == "unoccupiedToOccupiedT") then + local unoccupiedToOccupiedThreshold = device.preferences.unoccupiedToOccupiedT or DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD + device:send(OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(device,unoccupiedToOccupiedThreshold):to_endpoint(OCCUPANCY_ENDPOINT)) + end + end + end end -local frient_driver = { - NAME = "Frient Sensor", +local frient_motion_driver = { + NAME = "frient motion driver", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure, + init = device_init, + infoChanged = info_changed + }, zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, attr = { [OccupancySensing.ID] = { [OccupancySensing.attributes.Occupancy.ID] = occupancy_attr_handler + }, + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler } } }, - lifecycle_handlers = { - init = battery_defaults.build_linear_voltage_init(2.3, 3.0), - added = added_handler, - doConfigure = do_configure + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" - end + can_handle = require("frient.can_handle"), } - -return frient_driver +return frient_motion_driver diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/can_handle.lua new file mode 100644 index 0000000000..4257bd7ff2 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function gatorsystem_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "GatorSystem" and device:get_model() == "GSHW01" then + return true, require("gatorsystem") + end + return false +end + +return gatorsystem_can_handle diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/init.lua index 1e90a65e94..ee88254233 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/gatorsystem/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -84,9 +74,7 @@ local gator_handler = { added = device_added, doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "GatorSystem" and device:get_model() == "GSHW01" - end + can_handle = require("gatorsystem.can_handle"), } return gator_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/ikea/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/ikea/can_handle.lua new file mode 100644 index 0000000000..7060fc8630 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/ikea/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_ikea_motion = function(opts, driver, device) + local FINGERPRINTS = require("ikea.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("ikea") + end + end + return false +end + +return is_ikea_motion diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/ikea/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/ikea/fingerprints.lua new file mode 100644 index 0000000000..27162e73cf --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/ikea/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IKEA_MOTION_SENSOR_FINGERPRINTS = { + { mfr = "IKEA of Sweden", model = "TRADFRI motion sensor" } +} + +return IKEA_MOTION_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/ikea/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/ikea/init.lua index b2af5e1c88..b7cc733959 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/ikea/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/ikea/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local constants = require "st.zigbee.constants" local clusters = require "st.zigbee.zcl.clusters" @@ -26,9 +16,6 @@ local OnOff = clusters.OnOff local PowerConfiguration = clusters.PowerConfiguration local Groups = clusters.Groups -local IKEA_MOTION_SENSOR_FINGERPRINTS = { - { mfr = "IKEA of Sweden", model = "TRADFRI motion sensor" } -} local MOTION_RESET_TIMER = "motionResetTimer" local ENTRIES_READ = "ENTRIES_READ" @@ -48,14 +35,6 @@ local function on_with_timed_off_command_handler(driver, device, zb_rx) device:set_field(MOTION_RESET_TIMER, motion_reset_timer) end -local is_ikea_motion = function(opts, driver, device) - for _, fingerprint in ipairs(IKEA_MOTION_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function zdo_binding_table_handler(driver, device, zb_rx) for _, binding_table in pairs(zb_rx.body.zdo_body.binding_table_entries) do @@ -141,7 +120,7 @@ local ikea_motion_sensor = { added = device_added, doConfigure = do_configure }, - can_handle = is_ikea_motion + can_handle = require("ikea.can_handle"), } return ikea_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/init.lua index 88e06eae68..9c3f33ad14 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/init.lua @@ -1,25 +1,56 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" local constants = require "st.zigbee.constants" local zcl_clusters = require "st.zigbee.zcl.clusters" +local lazy_load_if_possible = require "lazy_load_subdriver" + +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} local HAS_RECONFIGURED = "_has_reconfigured" +--- Default handler for Temperature min and max measured value on the Temperature measurement cluster +--- +--- This starts initially by performing the same conversion in the temperature_measurement_attr_handler function. +--- It then sets the field of whichever measured value is defined by the @param and checks if the fields +--- correctly compare +--- +--- @param minOrMax string the string that determines which attribute to set +--- @param driver Driver The current driver running containing necessary context for execution +--- @param device ZigbeeDevice The device this message was received from containing identifying information +--- @param value Int16 the value of the measured temperature +--- @param zb_rx containing the full message this report came in + +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + -- TODO: Remove when available in lua libs -- This is a temporary method to lower battery consumption in several devices. -- Disparities were noted between DTH implementations and driver defaults. -sg @@ -38,6 +69,11 @@ local do_refresh = function(driver, device, command) end end +local added_handler = function(self, device) + device:send(zcl_clusters.TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) + device:send(zcl_clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) +end + local zigbee_motion_driver = { supported_capabilities = { capabilities.motionSensor, @@ -45,38 +81,53 @@ local zigbee_motion_driver = { capabilities.relativeHumidityMeasurement, capabilities.battery, capabilities.presenceSensor, - capabilities.contactSensor + capabilities.contactSensor, + capabilities.illuminanceMeasurement + }, + zigbee_handlers = { + attr = { + [zcl_clusters.TemperatureMeasurement.ID] = { + [zcl_clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [zcl_clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), + } + } }, capability_handlers = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, + lifecycle_handlers = { + added = added_handler + }, sub_drivers = { - require("aqara"), - require("aurora"), - require("ikea"), - require("iris"), - require("gatorsystem"), - require("motion_timeout"), - require("nyce"), - require("zigbee-plugin-motion-sensor"), - require("compacta"), - require("frient"), - require("samjin"), - require("battery-voltage"), - require("centralite"), - require("smartthings"), - require("smartsense"), - require("thirdreality"), - require("sengled") + lazy_load_if_possible("aqara"), + lazy_load_if_possible("aurora"), + lazy_load_if_possible("ikea"), + lazy_load_if_possible("iris"), + lazy_load_if_possible("gatorsystem"), + lazy_load_if_possible("motion_timeout"), + lazy_load_if_possible("nyce"), + lazy_load_if_possible("zigbee-plugin-motion-sensor"), + lazy_load_if_possible("compacta"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("samjin"), + lazy_load_if_possible("battery-voltage"), + lazy_load_if_possible("centralite"), + lazy_load_if_possible("smartthings"), + lazy_load_if_possible("smartsense"), + lazy_load_if_possible("thirdreality"), + lazy_load_if_possible("sengled"), + lazy_load_if_possible("MultiIR"), }, additional_zcl_profiles = { [0xFC01] = true }, - ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE + ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, + health_check = false, + shared_device_thread_enabled = true, } - -defaults.register_for_default_handlers(zigbee_motion_driver, zigbee_motion_driver.supported_capabilities) +defaults.register_for_default_handlers(zigbee_motion_driver, + zigbee_motion_driver.supported_capabilities, {native_capability_attrs_enabled = true}) local motion = ZigbeeDriver("zigbee-motion", zigbee_motion_driver) motion:run() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/iris/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/iris/can_handle.lua new file mode 100644 index 0000000000..6d86812e50 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/iris/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_iris_motion_sensor = function(opts, driver, device) + local FINGERPRINTS = require("iris.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("iris") + end + end + return false +end + +return is_zigbee_iris_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/iris/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/iris/fingerprints.lua new file mode 100644 index 0000000000..2d4ebfc516 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/iris/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_IRIS_MOTION_SENSOR_FINGERPRINTS = { + { mfr = "iMagic by GreatStar", model = "1117-S" } +} + +return ZIGBEE_IRIS_MOTION_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/iris/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/iris/init.lua index 5a553b4325..f29c2772d3 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/iris/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/iris/init.lua @@ -1,23 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" -local ZIGBEE_IRIS_MOTION_SENSOR_FINGERPRINTS = { - { mfr = "iMagic by GreatStar", model = "1117-S" } -} -- TODO: the IAS Zone changes should be replaced after supporting functions are included in the lua libs local do_init = function(driver, device) @@ -26,21 +13,13 @@ local do_init = function(driver, device) device:remove_configured_attribute(zcl_clusters.IASZone.ID, zcl_clusters.IASZone.attributes.ZoneStatus.ID) end -local is_zigbee_iris_motion_sensor = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_IRIS_MOTION_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local iris_motion_handler = { NAME = "Iris Motion Handler", lifecycle_handlers = { init = do_init }, - can_handle = is_zigbee_iris_motion_sensor + can_handle = require("iris.can_handle"), } return iris_motion_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-motion-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/can_handle.lua new file mode 100644 index 0000000000..679675b85e --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_motion_sensor = function(opts, driver, device) + local FINGERPRINTS = require("motion_timeout.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("motion_timeout") + end + end + return false +end + +return is_zigbee_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/fingerprints.lua new file mode 100644 index 0000000000..a3ff0ec226 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_MOTION_SENSOR_FINGERPRINTS = { + { mfr = "ORVIBO", model = "895a2d80097f4ae2b2d40500d5e03dcc", timeout = 20 }, + { mfr = "Megaman", model = "PS601/z1", timeout = 20 }, + { mfr = "HEIMAN", model = "PIRSensor-N", timeout = 20 } +} + +return ZIGBEE_MOTION_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/init.lua index 0fd0e7c070..fb6f34ece4 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/motion_timeout/init.lua @@ -1,40 +1,18 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local IASZone = zcl_clusters.IASZone -local ZIGBEE_MOTION_SENSOR_FINGERPRINTS = { - { mfr = "ORVIBO", model = "895a2d80097f4ae2b2d40500d5e03dcc", timeout = 20 }, - { mfr = "Megaman", model = "PS601/z1", timeout = 20 }, - { mfr = "HEIMAN", model = "PIRSensor-N", timeout = 20 } -} -local is_zigbee_motion_sensor = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_MOTION_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local generate_event_from_zone_status = function(driver, device, zone_status, zigbee_message) + local FINGERPRINTS = require("motion_timeout.fingerprints") device:emit_event( (zone_status:is_alarm1_set() or zone_status:is_alarm2_set()) and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) - for _, fingerprint in ipairs(ZIGBEE_MOTION_SENSOR_FINGERPRINTS) do + for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then device.thread:call_with_delay(fingerprint.timeout, function(d) device:emit_event(capabilities.motionSensor.motion.inactive()) @@ -66,7 +44,7 @@ local motion_timeout_handler = { } } }, - can_handle = is_zigbee_motion_sensor + can_handle = require("motion_timeout.can_handle"), } return motion_timeout_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/nyce/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/nyce/can_handle.lua new file mode 100644 index 0000000000..5c603cfef2 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/nyce/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_nyce_motion_sensor = function(opts, driver, device) + local FINGERPRINTS = require("nyce.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("nyce") + end + end + return false +end + +return is_zigbee_nyce_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/nyce/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/nyce/fingerprints.lua new file mode 100644 index 0000000000..ecb5193f99 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/nyce/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_NYCE_MOTION_SENSOR_FINGERPRINTS = { + { mfr = "NYCE", model = "3041" }, + { mfr = "NYCE", model = "3043" }, + { mfr = "NYCE", model = "3045" } +} + +return ZIGBEE_NYCE_MOTION_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/nyce/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/nyce/init.lua index 8a8196afa6..71efbf3e23 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/nyce/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/nyce/init.lua @@ -1,36 +1,13 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local OccupancySensing = zcl_clusters.OccupancySensing -local ZIGBEE_NYCE_MOTION_SENSOR_FINGERPRINTS = { - { mfr = "NYCE", model = "3041" }, - { mfr = "NYCE", model = "3043" }, - { mfr = "NYCE", model = "3045" } -} -local is_zigbee_nyce_motion_sensor = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_NYCE_MOTION_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function occupancy_attr_handler(driver, device, occupancy, zb_rx) device:emit_event( @@ -49,7 +26,7 @@ local nyce_motion_handler = { } } }, - can_handle = is_zigbee_nyce_motion_sensor + can_handle = require("nyce.can_handle"), } return nyce_motion_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/samjin/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/samjin/can_handle.lua new file mode 100644 index 0000000000..1ad7fe5f7a --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/samjin/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function samjin_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Samjin" then + return true, require("samjin") + end + return false +end + +return samjin_can_handle diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/samjin/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/samjin/init.lua index 66fe8b4491..5d3ff95fd0 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/samjin/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/samjin/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- ZCL local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -44,9 +34,7 @@ local samjin_driver = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Samjin" - end + can_handle = require("samjin.can_handle"), } return samjin_driver diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/sengled/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/sengled/can_handle.lua new file mode 100644 index 0000000000..68d774fb8f --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/sengled/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sengled_products = function(opts, driver, device, ...) + local FINGERPRINTS = require("sengled.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sengled") + end + end + return false +end + +return is_sengled_products diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/sengled/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/sengled/fingerprints.lua new file mode 100644 index 0000000000..bae9a89938 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/sengled/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "sengled", model = "E1M-G7H" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/sengled/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/sengled/init.lua index 6ae1de27df..5c2402f32e 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/sengled/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/sengled/init.lua @@ -1,12 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "sengled", model = "E1M-G7H" } -} local CONFIGURATIONS = { { @@ -27,21 +27,12 @@ local CONFIGURATIONS = { } } -local is_sengled_products = function(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -50,7 +41,7 @@ local sengled_motion_sensor_handler = { lifecycle_handlers = { init = device_init }, - can_handle = is_sengled_products + can_handle = require("sengled.can_handle"), } return sengled_motion_sensor_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/smartsense/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/smartsense/can_handle.lua new file mode 100644 index 0000000000..e85561680b --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/smartsense/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device, ...) + + local SMARTSENSE_MFR = "SmartThings" + local SMARTSENSE_MODEL = "PGC314" + local SMARTSENSE_PROFILE_ID = 0xFC01 + + local endpoint = device.zigbee_endpoints[1] or device.zigbee_endpoints["1"] + if (device:get_manufacturer() == SMARTSENSE_MFR and device:get_model() == SMARTSENSE_MODEL) or + endpoint.profile_id == SMARTSENSE_PROFILE_ID then + return true, require("smartsense") + end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/smartsense/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/smartsense/init.lua index c1c3912db4..df48f6af95 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/smartsense/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/smartsense/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local utils = require "st.utils" @@ -18,9 +8,6 @@ local utils = require "st.utils" local motion = capabilities.motionSensor.motion local signalStrength = capabilities.signalStrength -local SMARTSENSE_MFR = "SmartThings" -local SMARTSENSE_MODEL = "PGC314" -local SMARTSENSE_PROFILE_ID = 0xFC01 local SMARTSENSE_MOTION_CLUSTER = 0xFC04 local SMARTSENSE_MOTION_STATUS_CMD = 0x00 local MOTION_MASK = 0x02 @@ -43,13 +30,6 @@ local battery_table = { [0] = 0 } -local function can_handle(opts, driver, device, ...) - if (device:get_manufacturer() == SMARTSENSE_MFR and device:get_model() == SMARTSENSE_MODEL) or - device.zigbee_endpoints[1].profileId == SMARTSENSE_PROFILE_ID then - return true - end - return false -end local function device_added(driver, device) device:emit_event(motion.inactive()) @@ -95,7 +75,7 @@ local smartsense_motion = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle + can_handle = require("smartsense.can_handle"), } return smartsense_motion diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/smartthings/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/smartthings/can_handle.lua new file mode 100644 index 0000000000..747f859062 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/smartthings/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function smartthings_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "SmartThings" then + return true, require("smartthings") + end + return false + end + +return smartthings_can_handle diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/smartthings/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/smartthings/init.lua index 586378edc5..0907e7de16 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/smartthings/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/smartthings/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" @@ -44,9 +34,7 @@ local smartthings_motion = { lifecycle_handlers = { init = init_handler }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "SmartThings" - end + can_handle = require("smartthings.can_handle"), } return smartthings_motion diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/st/zigbee/zdo/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/st/zigbee/zdo/init.lua new file mode 100644 index 0000000000..eb551c84fa --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/st/zigbee/zdo/init.lua @@ -0,0 +1,145 @@ +-- Copyright 2021 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.zigbee.data_types" +local utils = require "st.zigbee.utils" +local zdo_commands = require "st.zigbee.zdo.commands" + +local zdo_messages = {} + +--- A class representing a zigbee ZDO header +--- @class st.zigbee.zdo.Header +--- +--- @field public NAME string "ZDOHeader" used for printing +--- @field public seqno st.zigbee.data_types.Uint8 the sequence number for the Zigbee message +local ZdoHeader = { + NAME = "ZDOHeader", +} +ZdoHeader.__index = ZdoHeader +zdo_messages.ZdoHeader = ZdoHeader + +--- A function to take a stream of bytes and parse a Zigbee Zdo header +--- @param buf Reader The buf positioned at the beginning of the bytes representing the ZdoHeader +--- @return st.zigbee.zdo.Header a new instance of the ZdoHeader parsed from the bytes +function ZdoHeader.deserialize(buf) + local s = {} + s.seqno = data_types.Uint8.deserialize(buf, "seqno") + setmetatable(s, ZdoHeader) + return s +end + +--- A helper function used by common code to get all the component pieces of this message frame +---@return table An array formatted table with each component field in the order their bytes should be serialized +function ZdoHeader:get_fields() + return { self.seqno } +end + +--- A function to return the total length in bytes this frame uses when serialized +--- @return number the length in bytes of this frame +function ZdoHeader:get_length() end +ZdoHeader.get_length = utils.length_from_fields + +--- A function for serializing this ZdoHeader +--- @return string This message frame serialized +ZdoHeader._serialize = utils.serialize_from_fields + +--- A function for printing in a human readable format +--- @return string A human readable representation of this message frame +function ZdoHeader:pretty_print() end +ZdoHeader.pretty_print = utils.print_from_fields +ZdoHeader.__tostring = ZdoHeader.pretty_print + +--- This is a function to build a Zdo header from its individual components +--- @param orig table UNUSED This is the ZclMessageRx object when called with the syntax ZclMessageRx(...) +--- @param seqno st.zigbee.data_types.Uint8 the sequence number for the ZDO header +--- @return st.zigbee.zdo.Header The constructed ZdoHeader +function ZdoHeader.from_values(orig, seqno) + local out = {} + out.seqno = data_types.validate_or_build_type(seqno, data_types.Uint8, "seqno") + out.seqno.field_name = "seqno" + setmetatable(out, ZdoHeader) + return out +end + +setmetatable(zdo_messages.ZdoHeader, { __call = zdo_messages.ZdoHeader.from_values }) + +--- A class representing a received zigbee ZDO message (device -> hub). +--- @class st.zigbee.zdo.MessageBody +--- +--- @field public NAME string "ZdoMessageRx" used for printing +--- @field public type st.zigbee.data_types.Uint8 message type (internal use only) +--- @field public address_header st.zigbee.AddressHeader The addressing information for this message +--- @field public lqi st.zigbee.data_types.Uint8 The lqi of this message +--- @field public rssi st.zigbee.data_types.Int8 The rssi of this message +--- @field public zdo_header st.zigbee.zdo.Header The ZdoHeader for this message +--- @field public body st.zigbee.zdo.MessageBody The message body. This is a message frame that must implement serialize, get_length, pretty_print, etc. +local ZdoMessageBody = { + NAME = "ZDOMessageBody", +} +ZdoMessageBody.__index = ZdoMessageBody +zdo_messages.ZdoMessageBody = ZdoMessageBody + +--- A function to take a stream of bytes and parse a received Zigbee Zdo message (device -> hub) +--- @param parent table A parent table for the class that includes the type, address_header, lqi, rssi, and body_length +--- @param buf Reader The buf positioned at the beginning of the bytes representing the ZdoHeader +--- @return st.zigbee.zdo.MessageBody a new instance of the ZdoMessageRx parsed from the bytes +function ZdoMessageBody.deserialize(parent, buf) + local s = {} + s.zdo_header = zdo_messages.ZdoHeader.deserialize(buf) + -- there is a bug in hub FW that causes the zdo message payload to have an invalid first byte + -- and a missing last byte. Default to adding in a 1, which is a good default for the mgmt_bind_response + -- binding table entry endpoint_id + local version = require "version" + if version.rpc == 8 then + buf.buf = buf.buf .. "" + buf:seek(1) + end + s.zdo_body = zdo_commands.parse_zdo_command(parent.address_header.cluster.value, buf) + + setmetatable(s, ZdoMessageBody) + return s +end + +--- A helper function used by common code to get all the component pieces of this message frame +---@return table An array formatted table with each component field in the order their bytes should be serialized +function ZdoMessageBody:get_fields() + return { + self.zdo_header, + self.zdo_body + } +end + +--- A function to return the total length in bytes this frame uses when serialized +--- @return number the length in bytes of this frame +function ZdoMessageBody:get_length() end +ZdoMessageBody.get_length = utils.length_from_fields + +--- A function for serializing this Zdo Message +--- @return string the bytes representing this message +ZdoMessageBody._serialize = utils.serialize_from_fields + +--- A function for printing in a human readable format +--- @return string A human readable representation of this message frame +function ZdoMessageBody:pretty_print() end +ZdoMessageBody.pretty_print = utils.print_from_fields +ZdoMessageBody.__tostring = ZdoMessageBody.pretty_print + +--- This is a function to build an zdo rx message from its individual components +--- @param orig table UNUSED This is the ZdoMessageRx object when called with the syntax ZdoMessageRx(...) +--- @param data_table table a table containing the fields of this ZdoMessageRx. address_header, zdo_header, and body are required. type, lqi, rssi are optional with defaults +--- @return st.zigbee.zdo.MessageBody The constructed ZdoMessageRx +function ZdoMessageBody.from_values(orig, data_table) + if data_table.zdo_header == nil then + -- Just set seqno to 0 + data_table.zdo_header = zdo_messages.ZdoHeader(0) + end + if data_table.zdo_body == nil then + error(string.format("%s requires valid body", orig.NAME), 2) + end + setmetatable(data_table, ZdoMessageBody) + return data_table +end + +setmetatable(zdo_messages.ZdoMessageBody, { __call = zdo_messages.ZdoMessageBody.from_values }) + +return zdo_messages diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua index c68eb33ef9..72fbeb168b 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -32,9 +22,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -49,7 +37,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -65,7 +64,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -81,7 +91,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -97,7 +118,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -113,7 +145,44 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -130,6 +199,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 17 } ) @@ -146,35 +218,48 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) - test.wait_for_events() +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) +-- test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) }) +-- test.wait_for_events() - test.mock_time.advance_time(50000) -- 21600 is the battery max interval - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, RelativeHumidity.attributes.MeasuredValue:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end, - { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end - } -) +-- test.mock_time.advance_time(50000) -- 21600 is the battery max interval +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, RelativeHumidity.attributes.MeasuredValue:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) test.register_coroutine_test( "Configure should configure all necessary attributes", function () test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) test.socket.zigbee:__expect_send({ mock_device.id, @@ -242,7 +327,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -253,6 +341,22 @@ test.register_message_test( direction = "receive", message = {mock_device.id, "added"} }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, { channel = "capability", direction = "receive", @@ -311,7 +415,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -328,8 +433,12 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) + mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -346,7 +455,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 }))) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua index 797b6084b3..5e961e37ec 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -63,9 +53,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -83,7 +71,10 @@ test.register_coroutine_test( sensitivityAdjustment.sensitivityAdjustment.Medium())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -103,7 +94,10 @@ test.register_coroutine_test( , data_types.Uint8, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,7 +118,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -146,7 +143,10 @@ test.register_coroutine_test( test.mock_time.advance_time(detect_duration) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 17 + } ) local function build_write_attr_res(cluster, status) @@ -186,7 +186,10 @@ test.register_coroutine_test( local value = mock_device:get_field(PREF_CHANGED_VALUE) or 0 test.socket.capability:__expect_send(mock_device:generate_test_message("main", detectionFrequency.detectionFrequency(value, {visibility = {displayed = false}}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -200,7 +203,10 @@ test.register_coroutine_test( mock_device:set_field(PREF_CHANGED_VALUE, PREF_SENSITIVITY_VALUE_HIGH) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.High())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -214,7 +220,10 @@ test.register_coroutine_test( mock_device:set_field(PREF_CHANGED_VALUE, PREF_SENSITIVITY_VALUE_MEDIUM) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Medium())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -228,7 +237,10 @@ test.register_coroutine_test( mock_device:set_field(PREF_CHANGED_VALUE, PREF_SENSITIVITY_VALUE_LOW) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -263,7 +275,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, SENSITIVITY_ATTRIBUTE_ID, MFG_CODE , data_types.Uint8, PREF_SENSITIVITY_VALUE_LOW) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua index 351443100b..a75033bef8 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -20,6 +10,10 @@ local data_types = require "st.zigbee.data_types" local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local write_attribute_response = require "st.zigbee.zcl.global_commands.write_attribute_response" +local zcl_messages = require "st.zigbee.zcl" test.add_package_capability("sensitivityAdjustment.yaml") test.add_package_capability("detectionFrequency.yaml") @@ -52,12 +46,33 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) +local function build_write_attr_res(cluster, status) + local addr_header = messages.AddressHeader( + mock_device:get_short_address(), + mock_device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local write_attribute_body = write_attribute_response.WriteAttributeResponse(status, {}) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(write_attribute_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = write_attribute_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + test.register_coroutine_test( "Handle added lifecycle", function() @@ -71,7 +86,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", detectionFrequency.detectionFrequency(PREF_FREQUENCY_VALUE_DEFAULT, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -91,7 +109,10 @@ test.register_coroutine_test( , data_types.Uint8, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -116,7 +137,10 @@ test.register_coroutine_test( test.mock_time.advance_time(detect_duration) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -132,7 +156,71 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, FREQUENCY_ATTRIBUTE_ID, MFG_CODE , data_types.Uint8, PREF_FREQUENCY_VALUE_DEFAULT) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motion detected twice cancels existing timer and creates a new one", + function() + local detect_duration = PREF_FREQUENCY_VALUE_DEFAULT + -- Pre-register two timers: first will be cancelled, second will fire + test.timer.__create_and_queue_test_time_advance_timer(detect_duration, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(detect_duration, "oneshot") + local attr_report_data = { + { MOTION_ILLUMINANCE_ATTRIBUTE_ID, data_types.Int32.ID, 0x0001006E } -- 65646 + } + -- First motion event + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(110)) + ) + test.wait_for_events() + -- Second motion event before first timer fires - cancels first timer + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(110)) + ) + test.wait_for_events() + -- Only the second timer fires + test.mock_time.advance_time(detect_duration) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.motionSensor.motion.inactive())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WriteAttributeResponse with PREF_FREQUENCY_KEY updates detection frequency", + function() + mock_device:set_field(PREF_CHANGED_KEY, PREF_FREQUENCY_KEY) + mock_device:set_field(PREF_CHANGED_VALUE, PREF_FREQUENCY_VALUE_DEFAULT) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_write_attr_res(PRIVATE_CLUSTER_ID, 0x00) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + detectionFrequency.detectionFrequency(PREF_FREQUENCY_VALUE_DEFAULT, {visibility = {displayed = false}}))) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua index c77d9d623d..4ae03f0354 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -40,9 +30,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -57,7 +45,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -73,7 +72,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -89,7 +99,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -105,7 +126,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -142,7 +174,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -184,7 +219,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua index 3f0c6ad49a..1bb1e359bb 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -36,9 +26,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_coroutine_test( @@ -62,7 +50,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua index e4f05af13d..fd1222b0cc 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -57,9 +47,7 @@ local mock_device_old_firmware = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - test.mock_device.add_test_device(mock_device_old_firmware) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device_old_firmware)end test.set_test_init_function(test_init) test.register_coroutine_test( @@ -81,7 +69,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -104,7 +95,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device_old_firmware:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua index a5264683e5..ec85954568 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -39,9 +29,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_coroutine_test( @@ -87,7 +75,10 @@ test.register_coroutine_test( TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 300, 100):to_endpoint(0x03) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -108,7 +99,10 @@ test.register_coroutine_test( mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua new file mode 100644 index 0000000000..a7b3828958 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua @@ -0,0 +1,291 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local base64 = require "base64" +local test = require "integration_test" +local t_utils = require "integration_test.utils" + +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local IASZone = zcl_clusters.IASZone +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local OccupancySensing = zcl_clusters.OccupancySensing +local PowerConfiguration = zcl_clusters.PowerConfiguration + +local capabilities = require "st.capabilities" + +local OCCUPANCY_ENDPOINT = 0x22 +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local IASZONE_ENDPOINT = 0x23 + +local DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY = 240 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY = 0 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD = 0 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-motion-battery.yml"), + zigbee_endpoints = { + [0x22] = { + id = 0x22, + manufacturer = "frient A/S", + model = "MOSZB-141", + server_clusters = { 0x0000, 0x0003, 0x0406 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x000f, 0x0020, 0x0500 } + }, + [0x28] = { + id = 0x28, + server_clusters = { 0x0000, 0x0003, 0x0406 } + }, + [0x29] = { + id = 0x29, + server_clusters = { 0x0000, 0x0003, 0x0406 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end +test.set_test_init_function(test_init) + +test.register_message_test( + "Battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 24) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should read all necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT)}) + test.socket.zigbee:__expect_send({ mock_device.id, OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT)}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + ) + + test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 1 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + OccupancySensing.ID, + OCCUPANCY_ENDPOINT + ):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.Occupancy:configure_reporting( + mock_device, + 0, + 3600 + ):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(mock_device, DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(mock_device, DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(mock_device, DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + -- Advance time to trigger the do_refresh call scheduled in do_configure + test.wait_for_events() + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Occupancy attribute handler emits motion active", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OccupancySensing.attributes.Occupancy:build_test_attr_report(mock_device, 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Occupancy attribute handler emits motion inactive", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OccupancySensing.attributes.Occupancy:build_test_attr_report(mock_device, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: Motion Turn-Off Delay, Motion Turn-On Delay, Movement Threshold in Turn-On Delay", + function() + local updates = { + preferences = { + occupiedToUnoccupiedD = 200, + unoccupiedToOccupiedD = 1, + unoccupiedToOccupiedT = 2 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(mock_device, 1):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(mock_device, 2):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(mock_device, 200):to_endpoint(OCCUPANCY_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua new file mode 100644 index 0000000000..4dc67338d5 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua @@ -0,0 +1,409 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local base64 = require "base64" +local test = require "integration_test" +local t_utils = require "integration_test.utils" + +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local IASZone = zcl_clusters.IASZone +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local IlluminanceMeasurement = zcl_clusters.IlluminanceMeasurement +local OccupancySensing = zcl_clusters.OccupancySensing +local PowerConfiguration = zcl_clusters.PowerConfiguration +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement + +local capabilities = require "st.capabilities" + +local IASZONE_ENDPOINT = 0x23 +local ILLUMINANCE_ENDPOINT = 0x27 +local OCCUPANCY_ENDPOINT = 0x22 +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local TEMPERATURE_MEASUREMENT_ENDPOINT = 0x26 + +local DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY = 240 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY = 0 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD = 0 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-motion-temp-illuminance-battery.yml"), + zigbee_endpoints = { + [0x22] = { + id = 0x22, + manufacturer = "frient A/S", + model = "MOSZB-153", + server_clusters = { 0x0000, 0x0003, 0x0406 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x000f, 0x0020, 0x0500 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0000, 0x0003, 0x0402 } + }, + [0x27] = { + id = 0x27, + server_clusters = { 0x0000, 0x0003, 0x0400 } + }, + [0x28] = { + id = 0x28, + server_clusters = { 0x0000, 0x0003, 0x0406 } + }, + [0x29] = { + id = 0x29, + server_clusters = { 0x0000, 0x0003, 0x0406 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Motion inactive clear states when the device is added", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 24) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Illuminance report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + IlluminanceMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 21370) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) + } + }, + { + min_api_version = 17 + } +) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.wait_for_events() +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(ILLUMINANCE_ENDPOINT) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +test.register_coroutine_test( + "Refresh should read all necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }}) + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT)}) + test.socket.zigbee:__expect_send({mock_device.id, OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT)}) + test.socket.zigbee:__expect_send({mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT)}) + test.socket.zigbee:__expect_send({mock_device.id, IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(ILLUMINANCE_ENDPOINT)}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + ) + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 1 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + OccupancySensing.ID, + OCCUPANCY_ENDPOINT + ):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.Occupancy:configure_reporting( + mock_device, + 0, + 3600 + ):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_MEASUREMENT_ENDPOINT + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 3600, + 10 + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IlluminanceMeasurement.ID, + ILLUMINANCE_ENDPOINT + ):to_endpoint(ILLUMINANCE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IlluminanceMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 10, + 3600, + 0x2711 + ):to_endpoint(ILLUMINANCE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(mock_device, DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(mock_device, DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(mock_device, DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: Temperature Sensitivity, Motion Turn-Off Delay, Motion Turn-On Delay, Movement Threshold in Turn-On Delay", + function() + local updates = { + preferences = { + temperatureSensitivity = 0.9, + occupiedToUnoccupiedD = 200, + unoccupiedToOccupiedD = 1, + unoccupiedToOccupiedT = 2 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(mock_device, 1):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(mock_device, 2):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(mock_device, 200):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + local temperatureSensitivity = math.floor(0.9 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 3600, + temperatureSensitivity + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua new file mode 100644 index 0000000000..fe633e089a --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua @@ -0,0 +1,457 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local base64 = require "base64" +local test = require "integration_test" +local t_utils = require "integration_test.utils" + +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local IASZone = zcl_clusters.IASZone +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local IlluminanceMeasurement = zcl_clusters.IlluminanceMeasurement +local OccupancySensing = zcl_clusters.OccupancySensing +local PowerConfiguration = zcl_clusters.PowerConfiguration +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement + +local capabilities = require "st.capabilities" + +local IASZONE_ENDPOINT = 0x23 +local ILLUMINANCE_ENDPOINT = 0x27 +local OCCUPANCY_ENDPOINT = 0x22 +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local TAMPER_ENDPOINT = 0x23 +local TEMPERATURE_MEASUREMENT_ENDPOINT = 0x26 + +local DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY = 240 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY = 0 +local DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD = 0 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-motion-temp-illuminance-tamper-battery.yml"), + zigbee_endpoints = { + [0x22] = { + id = 0x22, + manufacturer = "frient A/S", + model = "MOSZB-140", + server_clusters = { 0x0000, 0x0003, 0x0406 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x000f, 0x0020, 0x0500 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0000, 0x0003, 0x0402 } + }, + [0x27] = { + id = 0x27, + server_clusters = { 0x0000, 0x0003, 0x0400 } + }, + [0x28] = { + id = 0x28, + server_clusters = { 0x0000, 0x0003, 0x0406 } + }, + [0x29] = { + id = 0x29, + server_clusters = { 0x0000, 0x0003, 0x0406 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Motion inactive and tamper clear states when the device is added", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 24) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: tamper detected", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit2: Tamper set to 1 + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0004, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: tamper clear", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit2: Tamper set to 0 + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Illuminance report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + IlluminanceMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 21370) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should read all necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }}) + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT)}) + test.socket.zigbee:__expect_send({mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device):to_endpoint(TAMPER_ENDPOINT)}) + test.socket.zigbee:__expect_send({mock_device.id, OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT)}) + test.socket.zigbee:__expect_send({mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT)}) + test.socket.zigbee:__expect_send({mock_device.id, IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(ILLUMINANCE_ENDPOINT)}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + ) + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 1 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + OccupancySensing.ID, + OCCUPANCY_ENDPOINT + ):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.Occupancy:configure_reporting( + mock_device, + 0, + 3600 + ):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_MEASUREMENT_ENDPOINT + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 3600, + 10 + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IlluminanceMeasurement.ID, + ILLUMINANCE_ENDPOINT + ):to_endpoint(ILLUMINANCE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IlluminanceMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 10, + 3600, + 0x2711 + ):to_endpoint(ILLUMINANCE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(mock_device, DEFAULT_OCCUPIED_TO_UNOCCUPIED_DELAY) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(mock_device, DEFAULT_UNOCCUPIED_TO_OCCUPIED_DELAY) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(mock_device, DEFAULT_UNOCCUPIED_TO_OCCUPIED_THRESHOLD) + :to_endpoint(OCCUPANCY_ENDPOINT) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: Temperature Sensitivity, Motion Turn-Off Delay, Motion Turn-On Delay, Movement Threshold in Turn-On Delay", + function() + local updates = { + preferences = { + temperatureSensitivity = 0.9, + occupiedToUnoccupiedD = 200, + unoccupiedToOccupiedD = 1, + unoccupiedToOccupiedT = 2 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedDelay:write(mock_device, 1):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold:write(mock_device, 2):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(mock_device, 200):to_endpoint(OCCUPANCY_ENDPOINT) + }) + + local temperatureSensitivity = math.floor(0.9 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 3600, + temperatureSensitivity + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "IASZone attribute status handler: tamper detected", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit2: Tamper set to 1 + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "IASZone attribute status handler: tamper clear", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit2: Tamper set to 0 + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_sensor.lua deleted file mode 100644 index c2eb704ccc..0000000000 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_sensor.lua +++ /dev/null @@ -1,175 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Mock out globals -local test = require "integration_test" -local clusters = require "st.zigbee.zcl.clusters" -local IASZone = clusters.IASZone -local PowerConfiguration = clusters.PowerConfiguration -local TemperatureMeasurement = clusters.TemperatureMeasurement -local OccupancyAttribute = clusters.OccupancySensing.attributes.Occupancy -local capabilities = require "st.capabilities" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" -local t_utils = require "integration_test.utils" - -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("motion-temp-battery.yml"), - fingerprinted_endpoint_id = 0x01, - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "frient A/S", - model = "MOSZB-140", - server_clusters = {0x0001, 0x0020, 0x0402, 0x0500} - } - } - } -) - -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "Configure should configure all necessary attributes", - function() - test.wait_for_events() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 600, 100) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, TemperatureMeasurement.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.attributes.IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 30, 300, 0) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASZone.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 300, 100):to_endpoint(0x26) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1):to_endpoint(0x23) - }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end -) - -test.register_coroutine_test( - "Added should refresh all necessary attributes", - function() - test.wait_for_events() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:read(mock_device) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.attributes.ZoneStatus:read(mock_device) - }) - end -) - -test.register_coroutine_test( - "Battery Voltage test cases", - function() - local battery_test_map = { - ["frient A/S"] = { - [32] = 100, - [31] = 100, - [29] = 86, - [26] = 43, - [24] = 14, - [23] = 0, - [15] = 0 - } - } - - for voltage, batt_perc in pairs(battery_test_map[mock_device:get_manufacturer()]) do - test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, voltage) }) - test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) - test.wait_for_events() - end - end -) - -test.register_message_test( - "Reported motion should be handled: active", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, OccupancyAttribute:build_test_attr_report(mock_device, 0x01) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - } - } -) - -test.register_message_test( - "Reported motion should be handled: inactive", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, OccupancyAttribute:build_test_attr_report(mock_device, 0x00) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua index 35bf6d1fe7..ad129ddc2b 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -38,9 +28,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_coroutine_test( @@ -56,7 +44,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.mock_time.advance_time(120) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -72,7 +63,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.presenceSensor.presence.present())) test.mock_time.advance_time(60) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.presenceSensor.presence.not_present())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -85,7 +79,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -98,7 +95,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -111,7 +111,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,7 +127,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -137,7 +143,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(10))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -152,7 +161,29 @@ test.register_coroutine_test( mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) test.wait_for_events() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZoneStatusChangeNotification with alarm1 triggers motion active and inactive", + function() + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.mock_time.advance_time(120) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua index 4d15d831db..f922439c22 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -44,9 +34,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -70,6 +58,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -109,7 +100,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -129,7 +123,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(180) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -150,7 +147,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -209,7 +209,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -232,7 +235,38 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Second OnWithTimedOff cancels existing timer and resets motion", + function() + local frm_ctrl = FrameCtrl(0x01) + -- Pre-register two timers: first will be cancelled, second will fire + test.timer.__create_and_queue_test_time_advance_timer(0x0708/10, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(0x0708/10, "oneshot") + -- First motion event + local first_cmd = OnOff.server.commands.OnWithTimedOff.build_test_rx(mock_device, 0x00, 0x0708, 0x0000) + first_cmd.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({mock_device.id, first_cmd}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.wait_for_events() + -- Second motion event before first timer fires - cancels first timer + local second_cmd = OnOff.server.commands.OnWithTimedOff.build_test_rx(mock_device, 0x00, 0x0708, 0x0000) + second_cmd.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({mock_device.id, second_cmd}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.wait_for_events() + -- Only the second timer fires + test.mock_time.advance_time(180) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_multiir_motion_pir.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_multiir_motion_pir.lua new file mode 100644 index 0000000000..357190c730 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_multiir_motion_pir.lua @@ -0,0 +1,224 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" + +--If this line is removed, an error will occur. +test.add_package_capability("sensitivityAdjustment.yaml") + +local sensitivityAdjustment = capabilities["stse.sensitivityAdjustment"] +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration + +local PREF_SENSITIVITY_VALUE_HIGH = 3 +local PREF_SENSITIVITY_VALUE_MEDIUM = 2 +local PREF_SENSITIVITY_VALUE_LOW = 1 +local IASZone_PRIVATE_COMMAND_ID = 0xF4 + +-- Needed for building iaszone_private_cmd msg +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" + +local zcl_messages = require "st.zigbee.zcl" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "MultIR", + model = "MIR-IR100", + server_clusters = { PowerConfiguration.ID ,IASZone.ID} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.motionSensor.motion.inactive())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.battery.battery(100))) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device)}) + end, + { + min_api_version = 19 + } +) + +local function build_iaszone_private_cmd(device, priv_cmd, data) + local frame_ctrl = FrameCtrl(0x00) + frame_ctrl:set_cluster_specific() + + local zclh = zcl_messages.ZclHeader({ + frame_ctrl = frame_ctrl, + cmd = data_types.ZCLCommandId(priv_cmd) + }) + + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = data_types.Uint16(data) + }) + + local addr_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(IASZone.ID), + zb_const.HA_PROFILE_ID, + IASZone.ID + ) + + local msg = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + + return msg +end + +test.register_coroutine_test( + "Handle detectionFrequency preference in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({preferences = {detectionfrequency = 63}})) + test.socket.zigbee:__expect_send( + { + mock_device.id, + build_iaszone_private_cmd(mock_device,IASZone_PRIVATE_COMMAND_ID, 63) + } + ) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported CurrentZoneSensitivityLevel 1 should be Low", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.CurrentZoneSensitivityLevel:build_test_attr_report(mock_device, + 1) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported CurrentZoneSensitivityLevel 2 should be Medium", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.CurrentZoneSensitivityLevel:build_test_attr_report(mock_device, + 2) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Medium()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported CurrentZoneSensitivityLevel 3 should be High", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.CurrentZoneSensitivityLevel:build_test_attr_report(mock_device, + 3) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.High()) + } + }, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability sensitivityAdjustment High should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.sensitivityAdjustment", component = "main", command = "setSensitivityAdjustment", args = {"High"} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write(mock_device, PREF_SENSITIVITY_VALUE_HIGH) }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability sensitivityAdjustment Medium should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.sensitivityAdjustment", component = "main", command = "setSensitivityAdjustment", args = {"Medium"} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write(mock_device, PREF_SENSITIVITY_VALUE_MEDIUM) }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability sensitivityAdjustment Low should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.sensitivityAdjustment", component = "main", command = "setSensitivityAdjustment", args = {"Low"} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write(mock_device, PREF_SENSITIVITY_VALUE_LOW) }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua index a645c46c75..95c24f5f8e 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -40,9 +30,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -105,7 +93,10 @@ test.register_coroutine_test( -- zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASZone.ID) -- }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -127,7 +118,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua index 1b9676c9b6..4ca87d6358 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +9,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local IASCIEAddress = IASZone.attributes.IASCIEAddress local EnrollResponseCode = IASZone.types.EnrollResponseCode @@ -53,13 +44,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 0xFFFF, 0x0000, 0) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -94,7 +96,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua index 64a5b01dfe..b8b8fc4c62 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -33,7 +23,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "SmartThings", model = "PGC314", - profileId = 0xFC01, + profile_id = 0xFC01, server_clusters = {} } } @@ -77,7 +67,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -91,7 +84,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -105,7 +101,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -119,7 +118,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -132,7 +134,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -145,7 +150,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -159,7 +167,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -169,7 +180,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(0))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({value = -100, unit = 'dBm'}))) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua index 16d2c89a56..83c8f3c36d 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -36,9 +26,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_coroutine_test( @@ -65,7 +53,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua index a7d49b337f..cae6559187 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -55,9 +45,7 @@ local mock_device2 = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device1) - test.mock_device.add_test_device(mock_device2) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device2)end test.set_test_init_function(test_init) test.register_coroutine_test( @@ -80,7 +68,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device1:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -103,7 +94,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device1:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -126,7 +120,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device2:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -149,7 +146,32 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device2:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle added lifecycle - reads ApplicationVersion", + { + { + channel = "device_lifecycle", + direction = "receive", + message = {mock_device1.id, "added"} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device1.id, + Basic.attributes.ApplicationVersion:read(mock_device1) + } + } + }, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua index 922bc663c9..7808c5184a 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -39,9 +29,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -66,7 +54,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -77,6 +68,22 @@ test.register_message_test( direction = "receive", message = {mock_device.id, "added"} }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, { channel = "capability", direction = "receive", @@ -135,7 +142,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua index 5ea2a9f54e..9dca781210 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -38,9 +28,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -56,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -72,6 +63,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -98,7 +92,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua index 759c21d06e..c9172d8b8f 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -38,9 +28,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -56,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -72,6 +63,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -88,6 +82,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -104,6 +101,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -128,7 +128,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -152,7 +155,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua index 4f063159be..3bc0f85f0c 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua @@ -1,22 +1,13 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" --- @type st.zigbee.zcl.clusters.OccupancySensing local OccupancySensing = clusters.OccupancySensing +local TemperatureMeasurement = clusters.TemperatureMeasurement local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" @@ -40,9 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -58,6 +47,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -74,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -86,6 +81,22 @@ test.register_message_test( direction = "receive", message = {mock_device.id, "added"} }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, { channel = "capability", direction = "receive", @@ -102,6 +113,9 @@ test.register_message_test( OccupancySensing.attributes.Occupancy:read(mock_device) } }, + }, + { + min_api_version = 17 } ) @@ -123,7 +137,10 @@ test.register_coroutine_test( ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/can_handle.lua new file mode 100644 index 0000000000..4f087c6e5d --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_third_reality_motion_sensor = function(opts, driver, device) + local FINGERPRINTS = require("thirdreality.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("thirdreality") + end + end + return false +end + +return is_third_reality_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/fingerprints.lua new file mode 100644 index 0000000000..a5e92ef0b9 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_MOTION_SENSOR_FINGERPRINTS = { + { mfr = "Third Reality, Inc", model = "3RMS16BZ"}, + { mfr = "THIRDREALITY", model = "3RMS16BZ"} +} + +return ZIGBEE_MOTION_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/init.lua index c93919f16b..37c6e5b8d6 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/thirdreality/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -20,19 +10,7 @@ local utils = require "st.utils" local APPLICATION_VERSION = "application_version" -local ZIGBEE_MOTION_SENSOR_FINGERPRINTS = { - { mfr = "Third Reality, Inc", model = "3RMS16BZ"}, - { mfr = "THIRDREALITY", model = "3RMS16BZ"} -} -local is_third_reality_motion_sensor = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_MOTION_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local device_added = function(self, device) device:set_field(APPLICATION_VERSION, 0) @@ -73,7 +51,7 @@ local third_reality_motion_sensor = { lifecycle_handlers = { added = device_added, }, - can_handle = is_third_reality_motion_sensor + can_handle = require("thirdreality.can_handle"), } return third_reality_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/can_handle.lua new file mode 100644 index 0000000000..9b926e0845 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_plugin_motion_sensor = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-plugin-motion-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return true, require("zigbee-plugin-motion-sensor") + end + end + return false +end + +return is_zigbee_plugin_motion_sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/fingerprints.lua new file mode 100644 index 0000000000..459c3e7702 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_PLUGIN_MOTION_SENSOR_FINGERPRINTS = { + { model = "E280-KR0A0Z0-HA" } +} + +return ZIGBEE_PLUGIN_MOTION_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/init.lua index eb96b32a94..b387a35fbb 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/zigbee-plugin-motion-sensor/init.lua @@ -1,34 +1,13 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local device_management = require "st.zigbee.device_management" local OccupancySensing = zcl_clusters.OccupancySensing -local ZIGBEE_PLUGIN_MOTION_SENSOR_FINGERPRINTS = { - { model = "E280-KR0A0Z0-HA" } -} -local is_zigbee_plugin_motion_sensor = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_PLUGIN_MOTION_SENSOR_FINGERPRINTS) do - if device:get_model() == fingerprint.model then - return true - end - end - return false -end local function occupancy_attr_handler(driver, device, occupancy, zb_rx) device:emit_event(occupancy.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) @@ -59,7 +38,7 @@ local zigbee_plugin_motion_sensor = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, - can_handle = is_zigbee_plugin_motion_sensor + can_handle = require("zigbee-plugin-motion-sensor.can_handle"), } return zigbee_plugin_motion_sensor diff --git a/drivers/SmartThings/zigbee-power-meter/fingerprints.yml b/drivers/SmartThings/zigbee-power-meter/fingerprints.yml index 37cf9df678..2948c0a381 100644 --- a/drivers/SmartThings/zigbee-power-meter/fingerprints.yml +++ b/drivers/SmartThings/zigbee-power-meter/fingerprints.yml @@ -7,12 +7,27 @@ zigbeeManufacturer: deviceLabel: frient Energy Monitor manufacturer: Develco model: "ZHEMI101" - deviceProfileName: power-meter + deviceProfileName: frient-power-energy-consumption-report - id: "Develco/EMIZB-132" deviceLabel: frient Energy Monitor manufacturer: Develco Products A/S model: "EMIZB-132" - deviceProfileName: power-meter + deviceProfileName: frient-power-meter-consumption-report + - id: "frient A/S/EMIZB-132" + deviceLabel: frient Energy Monitor + manufacturer: frient A/S + model: "EMIZB-132" + deviceProfileName: frient-power-meter-consumption-report + - id: "frient A/S/EMIZB-141" + deviceLabel: "frient EMI 2 LED" + manufacturer: frient A/S + model: "EMIZB-141" + deviceProfileName: frient-power-energy-battery-consumption-report + - id: "frient A/S/EMIZB-151" + deviceLabel: "frient EMI 2 P1" + manufacturer: frient A/S + model: "EMIZB-151" + deviceProfileName: frient-power-energy-current-voltage - id: "ShinaSystem/PMM-300Z1" deviceLabel: SiHAS Energy Monitor manufacturer: ShinaSystem @@ -28,6 +43,71 @@ zigbeeManufacturer: manufacturer: ShinaSystem model: "PMM-300Z3" deviceProfileName: power-meter-consumption-report-sihas + - id: "BITUO TECHNIK/SPM01-E0" + deviceLabel: Energy Monitor 1PN + manufacturer: BITUO TECHNIK + model: "SPM01-E0" + deviceProfileName: power-meter-1p + - id: "BITUO TECHNIK/SPM01X" + deviceLabel: Energy Monitor 1PN + manufacturer: BITUO TECHNIK + model: "SPM01X" + deviceProfileName: power-meter-1p + - id: "BITUO TECHNIK/SDM02-E0" + deviceLabel: Energy Monitor 2PN + manufacturer: BITUO TECHNIK + model: "SDM02-E0" + deviceProfileName: power-meter-2p + - id: "BITUO TECHNIK/SDM02X" + deviceLabel: Energy Monitor 2PN + manufacturer: BITUO TECHNIK + model: "SDM02X" + deviceProfileName: power-meter-2p + - id: "BITUO TECHNIK/SPM02-E0" + deviceLabel: Energy Monitor 3PN + manufacturer: BITUO TECHNIK + model: "SPM02-E0" + deviceProfileName: power-meter-3p + - id: "BITUO TECHNIK/SPM02X" + deviceLabel: Energy Monitor 3PN + manufacturer: BITUO TECHNIK + model: "SPM02X" + deviceProfileName: power-meter-3p + - id: "BITUO TECHNIK/SDM01W" + deviceLabel: Energy Monitor 3PN + manufacturer: BITUO TECHNIK + model: "SDM01W" + deviceProfileName: power-meter-3p + - id: "BITUO TECHNIK/SDM01B" + deviceLabel: Energy Monitor 1PN + manufacturer: BITUO TECHNIK + model: "SDM01B" + deviceProfileName: power-meter-1p + - id: "Zemismart/SPM01-1Z2" + deviceLabel: Energy Monitor 1PN + manufacturer: Zemismart + model: "SPM01-1Z2" + deviceProfileName: power-meter-1p + - id: "Zemismart/SDM02-2Z1" + deviceLabel: Energy Monitor 2PN + manufacturer: Zemismart + model: "SDM02-2Z1" + deviceProfileName: power-meter-2p + - id: "Zemismart/SPM02-3Z3" + deviceLabel: Energy Monitor 3PN + manufacturer: Zemismart + model: "SPM02-3Z3" + deviceProfileName: power-meter-3p + - id: "Zemismart/SDM01-3Z1" + deviceLabel: Energy Monitor 3PN + manufacturer: Zemismart + model: "SDM01-3Z1" + deviceProfileName: power-meter-3p + - id: "Zemismart/SDM01-1Z1" + deviceLabel: Energy Monitor 1PN + manufacturer: Zemismart + model: "SDM01-1Z1" + deviceProfileName: power-meter-1p zigbeeGeneric: - id: "genericMeter" deviceLabel: Zigbee Meter diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-battery-consumption-report.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-battery-consumption-report.yml new file mode 100644 index 0000000000..03278796a9 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-battery-consumption-report.yml @@ -0,0 +1,37 @@ +name: frient-power-energy-battery-consumption-report +components: + - id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - title: "Pulse Configuration" + name: pulseConfiguration + description: "Number of pulses the meter outputs per unit" + required: false + preferenceType: integer + definition: + minimum: 50 + maximum: 10000 + default: 1000 + - title: "Initial Energy Consumption" + name: currentSummation + description: "Offset (scaled value) for current summation delivered" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 268435455 + default: 0 diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-consumption-report.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-consumption-report.yml new file mode 100644 index 0000000000..c4ac75361b --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-consumption-report.yml @@ -0,0 +1,35 @@ +name: frient-power-energy-consumption-report +components: + - id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - title: "Pulse Configuration" + name: pulseConfiguration + description: "Number of pulses the meter outputs per unit" + required: false + preferenceType: integer + definition: + minimum: 50 + maximum: 10000 + default: 1000 + - title: "Initial Energy Consumption" + name: currentSummation + description: "Offset (scaled value) for current summation delivered" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 268435455 + default: 0 diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-current-voltage.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-current-voltage.yml new file mode 100644 index 0000000000..b4464d4ada --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-current-voltage.yml @@ -0,0 +1,48 @@ +name: frient-power-energy-current-voltage +components: + - id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor + - id: production + label: Production + capabilities: + - id: energyMeter + version: 1 + - id: phaseA + label: "Phase A" + capabilities: + - id: powerMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: phaseB + label: "Phase B" + capabilities: + - id: powerMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: phaseC + label: "Phase C" + capabilities: + - id: powerMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-meter-consumption-report.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-meter-consumption-report.yml new file mode 100644 index 0000000000..f65fce5d42 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-meter-consumption-report.yml @@ -0,0 +1,16 @@ +name: frient-power-meter-consumption-report +components: +- id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-1p.yml b/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-1p.yml new file mode 100644 index 0000000000..7290794ffe --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-1p.yml @@ -0,0 +1,31 @@ +name: power-meter-1p +components: +- id: main + label: Total Forward Energy + capabilities: + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: TotalReverseEnergy + label: Total Reverse Energy + capabilities: + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: PhaseA + label: Phase A + capabilities: + - id: powerMeter + version: 1 + - id: currentMeasurement + version: 1 + - id: voltageMeasurement + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-2p.yml b/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-2p.yml new file mode 100644 index 0000000000..11f2dd412b --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-2p.yml @@ -0,0 +1,42 @@ +name: power-meter-2p +components: +- id: main + label: Total Forward Energy + capabilities: + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: TotalReverseEnergy + label: Total Reverse Energy + capabilities: + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: PhaseA + label: Phase A + capabilities: + - id: powerMeter + version: 1 + - id: currentMeasurement + version: 1 + - id: voltageMeasurement + version: 1 + categories: + - name: CurbPowerMeter +- id: PhaseB + label: Phase B + capabilities: + - id: powerMeter + version: 1 + - id: currentMeasurement + version: 1 + - id: voltageMeasurement + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-3p.yml b/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-3p.yml new file mode 100644 index 0000000000..20c824d092 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/power-meter-3p.yml @@ -0,0 +1,53 @@ +name: power-meter-3p +components: +- id: main + label: Total Forward Energy + capabilities: + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: TotalReverseEnergy + label: Total Reverse Energy + capabilities: + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: PhaseA + label: Phase A + capabilities: + - id: powerMeter + version: 1 + - id: currentMeasurement + version: 1 + - id: voltageMeasurement + version: 1 + categories: + - name: CurbPowerMeter +- id: PhaseB + label: Phase B + capabilities: + - id: powerMeter + version: 1 + - id: currentMeasurement + version: 1 + - id: voltageMeasurement + version: 1 + categories: + - name: CurbPowerMeter +- id: PhaseC + label: Phase C + capabilities: + - id: powerMeter + version: 1 + - id: currentMeasurement + version: 1 + - id: voltageMeasurement + version: 1 + categories: + - name: CurbPowerMeter diff --git a/drivers/SmartThings/zigbee-power-meter/src/bituo/can_handle.lua b/drivers/SmartThings/zigbee-power-meter/src/bituo/can_handle.lua new file mode 100644 index 0000000000..a2124afdd6 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/bituo/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_bituo_power_meter = function(opts, driver, device) + local FINGERPRINTS = require("bituo.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return true, require("bituo") + end + end + + return false +end + +return is_bituo_power_meter diff --git a/drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua new file mode 100644 index 0000000000..4f4a659189 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_POWER_METER_FINGERPRINTS = { + { mfr = "BITUO TECHNIK", model = "SPM01-E0" }, + { mfr = "BITUO TECHNIK", model = "SPM01X" }, + { mfr = "BITUO TECHNIK", model = "SDM02-E0" }, + { mfr = "BITUO TECHNIK", model = "SDM02X" }, + { mfr = "BITUO TECHNIK", model = "SPM02-E0" }, + { mfr = "BITUO TECHNIK", model = "SPM02X" }, + { mfr = "BITUO TECHNIK", model = "SDM01W" }, + { mfr = "BITUO TECHNIK", model = "SDM01B" }, + { mfr = "Zemismart", model = "SPM01-1Z2" }, + { mfr = "Zemismart", model = "SPM02-3Z3" }, + { mfr = "Zemismart", model = "SDM01-3Z1" }, + { mfr = "Zemismart", model = "SDM01-1Z1" }, + { mfr = "Zemismart", model = "SDM02-2Z1" } +} + +return ZIGBEE_POWER_METER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua b/drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua new file mode 100644 index 0000000000..93788ddfb6 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua @@ -0,0 +1,231 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local constants = require "st.zigbee.constants" +local clusters = require "st.zigbee.zcl.clusters" +local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local configurations = require "configurations" + + +local PHASE_A_CONFIGURATION = { + { + cluster = SimpleMetering.ID, + attribute = SimpleMetering.attributes.CurrentSummationDelivered.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = SimpleMetering.attributes.CurrentSummationDelivered.base_type, + reportable_change = 0 + }, + { + cluster = SimpleMetering.ID, + attribute = 0x0001, + minimum_interval = 30, + maximum_interval = 120, + data_type = SimpleMetering.attributes.CurrentSummationDelivered.base_type, + reportable_change = 0 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePower.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.ActivePower.base_type, + reportable_change = 0 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltage.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.RMSVoltage.base_type, + reportable_change = 0 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrent.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.RMSCurrent.base_type, + reportable_change = 0 + } +} +local PHASE_B_CONFIGURATION = { + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePowerPhB.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.ActivePowerPhB.base_type, + reportable_change = 0 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltagePhB.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.RMSVoltagePhB.base_type, + reportable_change = 0 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrentPhB.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.RMSCurrentPhB.base_type, + reportable_change = 0 + }, +} +local PHASE_C_CONFIGURATION = { + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePowerPhC.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.ActivePowerPhC.base_type, + reportable_change = 0 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltagePhC.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.RMSVoltagePhC.base_type, + reportable_change = 0 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrentPhC.ID, + minimum_interval = 30, + maximum_interval = 120, + data_type = ElectricalMeasurement.attributes.RMSCurrentPhC.base_type, + reportable_change = 0 + } +} + +local function energy_handler(driver, device, value, zb_rx) + local multiplier = 1 + local divisor = 100 + local raw_value = value.value + local raw_value_kilowatts = raw_value * multiplier/divisor + + local offset = device:get_field(constants.ENERGY_METER_OFFSET) or 0 + if raw_value_kilowatts < offset then + --- somehow our value has gone below the offset, so we'll reset the offset, since the device seems to have + offset = 0 + device:set_field(constants.ENERGY_METER_OFFSET, offset, {persist = true}) + end + raw_value_kilowatts = raw_value_kilowatts - offset + + local raw_value_watts = raw_value_kilowatts*1000 + local delta_tick + local last_save_ticks = device:get_field("LAST_SAVE_TICK") + + if last_save_ticks == nil then last_save_ticks = 0 end + delta_tick = os.time() - last_save_ticks + + -- wwst energy certification : powerConsumptionReport capability values should be updated every 15 minutes. + -- Check if 15 minutes have passed since the reporting time of current_power_consumption. + if delta_tick >= 15*60 then + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value_watts - current_power_consumption.energy, 0.0) + end + device:emit_event(capabilities.powerConsumptionReport.powerConsumption({energy = raw_value_watts, deltaEnergy = delta_energy })) -- the unit of these values should be 'Wh' + + local curr_save_tick = last_save_ticks + 15*60 -- Set the time to a regular interval by adding 15 minutes to the existing last_save_ticks. + -- If the time 15 minutes from now is less than the current time, set the current time as the last time. + if curr_save_tick + 15*60 < os.time() then + curr_save_tick = os.time() + end + device:set_field("LAST_SAVE_TICK", curr_save_tick, {persist = false}) + end + device:emit_event(capabilities.energyMeter.energy({value = raw_value_kilowatts, unit = "kWh"})) +end + +local function generic_handler_factory(component_name, capability, multiplier, divisor, unit) + return function(driver, device, value, zb_rx) + local component = device.profile.components[component_name] + if component ~= nil then + local raw_value = value.value * multiplier / divisor + device:emit_component_event(component, capability({value = raw_value, unit = unit})) + end + end +end + +local refresh = function(driver, device, cmd) + device:refresh() +end + +local function resetEnergyMeter(self, device) + device:send(clusters.OnOff.server.commands.On(device)) + -- Reset Power consumption + device:set_field(constants.ENERGY_METER_OFFSET, 0, {persist = true}) + device:set_field("LAST_SAVE_TICK", os.time(), {persist = false}) +end +local function do_configure(driver, device) + device:configure() + --device:send(device_management.build_bind_request(device, clusters.SimpleMetering.ID, driver.environment_info.hub_zigbee_eui)) + --device:send(device_management.build_bind_request(device, clusters.ElectricalMeasurement.ID, driver.environment_info.hub_zigbee_eui)) + device:refresh() +end + +local device_init = function(self, device) + for _, attribute in ipairs(PHASE_A_CONFIGURATION) do + device:add_configured_attribute(attribute) + device:add_monitored_attribute(attribute) + end + if string.find(device:get_model(), "SDM02") or string.find(device:get_model(), "SPM02") or string.find(device:get_model(), "SDM01W") or string.find(device:get_model(), "SDM01-3Z1", 1, true) then + for _, attribute in ipairs(PHASE_B_CONFIGURATION) do + device:add_configured_attribute(attribute) + device:add_monitored_attribute(attribute) + end + end + if string.find(device:get_model(), "SPM02") or string.find(device:get_model(), "SDM01W") or string.find(device:get_model(), "SDM01-3Z1", 1, true) then + for _, attribute in ipairs(PHASE_C_CONFIGURATION) do + device:add_configured_attribute(attribute) + device:add_monitored_attribute(attribute) + end + end +end + +local bituo_power_meter_handler = { + NAME = "bituo power meter handler", + lifecycle_handlers = { + init = configurations.power_reconfig_wrapper(device_init), + doConfigure = do_configure, + }, + zigbee_handlers = { + attr = { + [clusters.SimpleMetering.ID] = { + [clusters.SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_handler, + [0x0001] = generic_handler_factory("TotalReverseEnergy", capabilities.energyMeter.energy, 1, 100, "kWh"), + }, + [clusters.ElectricalMeasurement.ID] = { + [ElectricalMeasurement.attributes.ActivePower.ID] = generic_handler_factory("PhaseA", capabilities.powerMeter.power, 1, 1, "W"), + [ElectricalMeasurement.attributes.ActivePowerPhB.ID] = generic_handler_factory("PhaseB", capabilities.powerMeter.power, 1, 1, "W"), + [ElectricalMeasurement.attributes.ActivePowerPhC.ID] = generic_handler_factory("PhaseC", capabilities.powerMeter.power, 1, 1, "W"), + [ElectricalMeasurement.attributes.RMSVoltage.ID] = generic_handler_factory("PhaseA", capabilities.voltageMeasurement.voltage, 1, 100, "V"), + [ElectricalMeasurement.attributes.RMSVoltagePhB.ID] = generic_handler_factory("PhaseB", capabilities.voltageMeasurement.voltage, 1, 100, "V"), + [ElectricalMeasurement.attributes.RMSVoltagePhC.ID] = generic_handler_factory("PhaseC", capabilities.voltageMeasurement.voltage, 1, 100, "V"), + [ElectricalMeasurement.attributes.RMSCurrent.ID] = generic_handler_factory("PhaseA", capabilities.currentMeasurement.current, 1, 100, "A"), + [ElectricalMeasurement.attributes.RMSCurrentPhB.ID] = generic_handler_factory("PhaseB", capabilities.currentMeasurement.current, 1, 100, "A"), + [ElectricalMeasurement.attributes.RMSCurrentPhC.ID] = generic_handler_factory("PhaseC", capabilities.currentMeasurement.current, 1, 100, "A") + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh + }, + [capabilities.energyMeter.ID] = { + [capabilities.energyMeter.commands.resetEnergyMeter.NAME] = resetEnergyMeter, + }, + }, + can_handle = require("bituo.can_handle"), +} + +return bituo_power_meter_handler diff --git a/drivers/SmartThings/zigbee-power-meter/src/configurations.lua b/drivers/SmartThings/zigbee-power-meter/src/configurations.lua new file mode 100644 index 0000000000..557e790f76 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/configurations.lua @@ -0,0 +1,148 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local device_def = require "st.device" +local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local device_management = require "st.zigbee.device_management" + + +local Status = require "st.zigbee.generated.types.ZclStatus" + +local CONFIGURATION_VERSION_KEY = "_configuration_version" +local CONFIGURATION_ATTEMPTED = "_reconfiguration_attempted" + + +local configurations = {} + +local active_power_configuration = { + cluster = clusters.ElectricalMeasurement.ID, + attribute = clusters.ElectricalMeasurement.attributes.ActivePower.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = clusters.ElectricalMeasurement.attributes.ActivePower.base_type, + reportable_change = 5 +} + +local instantaneous_demand_configuration = { + cluster = clusters.SimpleMetering.ID, + attribute = clusters.SimpleMetering.attributes.InstantaneousDemand.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = clusters.SimpleMetering.attributes.InstantaneousDemand.base_type, + reportable_change = 5 +} + +configurations.find_cluster_config = function(device, cluster, attribute) + -- This is an internal field, but this is the easiest way to allow the custom configuraitons without + -- larger driver changes + local configured_attrs = device:get_field("__configured_attributes") or {} + for clus, attrs in pairs(configured_attrs) do + if cluster == clus then + for _, attr_config in pairs(attrs) do + if attr_config.attribute == attribute then + local u = require "st.utils" + print(u.stringify_table(attr_config)) + return attr_config + end + end + end + end + return nil +end + + +configurations.check_and_reconfig_devices = function(driver) + for device_id, device in pairs(driver.device_cache) do + local config_version = device:get_field(CONFIGURATION_VERSION_KEY) + if config_version == nil or config_version < driver.current_config_version then + if device:supports_capability(capabilities.powerMeter) then + if device:supports_server_cluster(clusters.ElectricalMeasurement.ID) then + -- Allow for custom configurations as long as the minimum reporting interval is at least 5 + local config = configurations.find_cluster_config(device, clusters.ElectricalMeasurement.ID, ElectricalMeasurement.attributes.ActivePower.ID) + if config == nil or config.minimum_interval < 5 then + config = active_power_configuration + end + device:send(device_management.attr_config(device, config)) + device:add_configured_attribute(config) + end + if device:supports_server_cluster(clusters.SimpleMetering.ID) then + -- Allow for custom configurations as long as the minimum reporting interval is at least 5 + local config = configurations.find_cluster_config(device, clusters.SimpleMetering.ID, SimpleMetering.attributes.InstantaneousDemand.ID) + if config == nil or config.minimum_interval < 5 then + config = instantaneous_demand_configuration + end + device:send(device_management.attr_config(device, config)) + device:add_configured_attribute(config) + + -- perform reconfiguration of summation attribute if it's configured + config = configurations.find_cluster_config(device, clusters.SimpleMetering.ID, SimpleMetering.attributes.CurrentSummationDelivered.ID) + if config ~= nil then + device:send(device_management.attr_config(device, config)) + end + end + end + device:set_field(CONFIGURATION_ATTEMPTED, true, {persist = true}) + end + end + driver._reconfig_timer = nil +end + +configurations.handle_reporting_config_response = function(driver, device, zb_mess) + local dev = device + local find_child_fn = device:get_field(device_def.FIND_CHILD_KEY) + if find_child_fn ~= nil then + local child = find_child_fn(device, zb_mess.address_header.src_endpoint.value) + if child ~= nil then + dev = child + end + end + if dev:get_field(CONFIGURATION_ATTEMPTED) == true then + if zb_mess.body.zcl_body.global_status ~= nil and zb_mess.body.zcl_body.global_status.value == Status.SUCCESS then + dev:set_field(CONFIGURATION_VERSION_KEY, driver.current_config_version, {persist = true}) + elseif zb_mess.body.zcl_body.config_records ~= nil then + local config_records = zb_mess.body.zcl_body.config_records + for _, record in ipairs(config_records) do + if zb_mess.address_header.cluster.value == clusters.SimpleMetering.ID then + if record.attr_id.value == clusters.SimpleMetering.attributes.InstantaneousDemand.ID + and record.status.value == Status.SUCCESS then + dev:set_field(CONFIGURATION_VERSION_KEY, driver.current_config_version, {persist = true}) + end + elseif zb_mess.address_header.cluster.value == clusters.ElectricalMeasurement.ID then + if record.attr_id.value == clusters.ElectricalMeasurement.attributes.ActivePower.ID + and record.status.value == Status.SUCCESS then + dev:set_field(CONFIGURATION_VERSION_KEY, driver.current_config_version, {persist = true}) + end + end + end + end + end +end + +configurations.power_reconfig_wrapper = function(orig_function) + local new_init = function(driver, device) + local config_version = device:get_field(CONFIGURATION_VERSION_KEY) + if config_version == nil or config_version < driver.current_config_version then + if driver._reconfig_timer == nil then + driver._reconfig_timer = driver:call_with_delay(5*60, configurations.check_and_reconfig_devices, "reconfig_power_devices") + end + end + orig_function(driver, device) + end + return new_init +end + +return configurations \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/ezex/can_handle.lua b/drivers/SmartThings/zigbee-power-meter/src/ezex/can_handle.lua new file mode 100644 index 0000000000..91569fd4b4 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/ezex/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_ezex_power_meter = function(opts, driver, device) + local FINGERPRINTS = require("ezex.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return true, require("ezex") + end + end + + return false +end + +return is_ezex_power_meter diff --git a/drivers/SmartThings/zigbee-power-meter/src/ezex/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/ezex/fingerprints.lua new file mode 100644 index 0000000000..1495f82c56 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/ezex/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_POWER_METER_FINGERPRINTS = { + { model = "E240-KR080Z0-HA" } +} + +return ZIGBEE_POWER_METER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-power-meter/src/ezex/init.lua b/drivers/SmartThings/zigbee-power-meter/src/ezex/init.lua index 0792abec16..15ca345f31 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/ezex/init.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/ezex/init.lua @@ -1,42 +1,23 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local constants = require "st.zigbee.constants" local clusters = require "st.zigbee.zcl.clusters" local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local energy_meter_defaults = require "st.zigbee.defaults.energyMeter_defaults" +local configurations = require "configurations" -local ZIGBEE_POWER_METER_FINGERPRINTS = { - { model = "E240-KR080Z0-HA" } -} -local is_ezex_power_meter = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_POWER_METER_FINGERPRINTS) do - if device:get_model() == fingerprint.model then - return true - end - end - - return false -end local instantaneous_demand_configuration = { - cluster = clusters.SimpleMetering.ID, - attribute = clusters.SimpleMetering.attributes.InstantaneousDemand.ID, - minimum_interval = 1, + cluster = SimpleMetering.ID, + attribute = SimpleMetering.attributes.InstantaneousDemand.ID, + minimum_interval = 5, maximum_interval = 3600, - data_type = clusters.SimpleMetering.attributes.InstantaneousDemand.base_type, + data_type = SimpleMetering.attributes.InstantaneousDemand.base_type, reportable_change = 500 } @@ -48,11 +29,14 @@ end local device_init = function(self, device) device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000000, {persist = true}) device:set_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10, {persist = true}) - - device:add_monitored_attribute(instantaneous_demand_configuration) + device:remove_configured_attribute(ElectricalMeasurement.ID, ElectricalMeasurement.attributes.ActivePower.ID) + device:remove_configured_attribute(ElectricalMeasurement.ID, ElectricalMeasurement.attributes.ACPowerDivisor.ID) + device:remove_configured_attribute(ElectricalMeasurement.ID, ElectricalMeasurement.attributes.ACPowerMultiplier.ID) device:add_configured_attribute(instantaneous_demand_configuration) end +local function noop_active_power(driver, device, value, zb_rx) end + local function energy_meter_handler(driver, device, value, zb_rx) local raw_value_miliwatts = value.value local raw_value_watts = raw_value_miliwatts / 1000 @@ -63,10 +47,7 @@ local function energy_meter_handler(driver, device, value, zb_rx) end device:emit_event(capabilities.powerConsumptionReport.powerConsumption({energy = raw_value_watts, deltaEnergy = delta_energy })) -- the unit of these values should be 'Wh' - local multiplier = device:get_field(constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 - local divisor = device:get_field(constants.SIMPLE_METERING_DIVISOR_KEY) or 1000000 - local converted_value = raw_value_miliwatts * multiplier/divisor -- unit: kWh - device:emit_event(capabilities.energyMeter.energy({value = converted_value, unit = "kWh"})) + energy_meter_defaults.energy_meter_handler(driver, device, value, zb_rx) end local ezex_power_meter_handler = { @@ -75,14 +56,17 @@ local ezex_power_meter_handler = { attr = { [SimpleMetering.ID] = { [SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_meter_handler + }, + [ElectricalMeasurement.ID] = { + [ElectricalMeasurement.attributes.ActivePower.ID] = noop_active_power } } }, lifecycle_handlers = { - init = device_init, + init = configurations.power_reconfig_wrapper(device_init), doConfigure = do_configure, }, - can_handle = is_ezex_power_meter + can_handle = require("ezex.can_handle"), } return ezex_power_meter_handler diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/can_handle.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/can_handle.lua new file mode 100644 index 0000000000..85ee8c086f --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_frient_power_meter = function(opts, driver, device, zb_rx) + local FINGERPRINTS = require("frient.EMIZB-151.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return true, require("frient.EMIZB-151") + end + end + + return false +end + +return is_frient_power_meter diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/fingerprints.lua new file mode 100644 index 0000000000..e6af73d53e --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_POWER_METER_FINGERPRINTS = { + { model = "EMIZB-151"} +} + +return ZIGBEE_POWER_METER_FINGERPRINTS \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/init.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/init.lua new file mode 100644 index 0000000000..e7b2bae204 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/init.lua @@ -0,0 +1,258 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zigbee_constants = require "st.zigbee.constants" +local capabilities = require "st.capabilities" + +local clusters = require "st.zigbee.zcl.clusters" +local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local utils = require "frient.utils" + +local data_types = require "st.zigbee.data_types" +local LAST_REPORT_TIME = "LAST_REPORT_TIME" +local SIMPLE_METERING_DEFAULT_DIVISOR = 1000 + +local AC_VOLTAGE_MULTIPLIER_KEY = "_electrical_measurement_ac_voltage_multiplier" +local AC_CURRENT_MULTIPLIER_KEY = "_electrical_measurement_ac_current_multiplier" +local AC_VOLTAGE_DIVISOR_KEY = "_electrical_measurement_ac_voltage_divisor" +local AC_CURRENT_DIVISOR_KEY = "_electrical_measurement_ac_current_divisor" + +local CurrentSummationReceived = 0x0001 + +local ATTRIBUTES = { + { + cluster = SimpleMetering.ID, + attribute = CurrentSummationReceived, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint48, + reportable_change = 1 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePowerPhB.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Int16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePowerPhC.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Int16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltage.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltagePhB.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltagePhC.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrent.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrentPhB.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrentPhC.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + } +} + +local device_init = function(self, device) + for _, attribute in ipairs(ATTRIBUTES) do + device:add_configured_attribute(attribute) + end + + if device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil then + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, SIMPLE_METERING_DEFAULT_DIVISOR, { persist = true }) + end +end + +local do_configure = function(self, device) + device:refresh() + device:configure() + + -- Divisor and multipler for PowerMeter + device:send(SimpleMetering.attributes.Divisor:read(device)) + device:send(SimpleMetering.attributes.Multiplier:read(device)) +end + +local instantaneous_demand_handler = function(driver, device, value, zb_rx) + local raw_value = value.value + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + + raw_value = raw_value * multiplier / divisor * 1000 + + -- The result is already in watts, no need to multiply by 1000 + device:emit_event(capabilities.powerMeter.power({ value = raw_value, unit = "W" })) +end + +local current_summation_delivered_handler = function(driver, device, value, zb_rx) + local raw_value = value.value + + -- Handle potential overflow values + if raw_value < 0 or raw_value >= 0xFFFFFFFFFFFF then + return + end + + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + + raw_value = raw_value * multiplier / divisor * 1000 + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + + local current_time = os.time() + local last_report_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_report_time = last_report_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_report_time then + return + end + + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + capabilities.powerConsumptionReport.powerConsumption({ + start = utils.epoch_to_iso8601(last_report_time), + ["end"] = utils.epoch_to_iso8601(current_time - 1), + deltaEnergy = delta_energy, + energy = raw_value + }) + ) + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) +end + +local current_summation_received_handler = function(driver, device, value, zb_rx) + local raw_value = value.value + + -- Handle potential overflow values + if raw_value < 0 or raw_value >= 0xFFFFFFFFFFFF then + return + end + + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or 1000 + + raw_value = raw_value * multiplier / divisor * 1000 + device:emit_component_event(device.profile.components['production'], capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) +end + +local electrical_measurement_ac_voltage_multiplier_handler = function(driver, device, multiplier, zb_rx) + local raw_value = multiplier.value + device:set_field(AC_VOLTAGE_MULTIPLIER_KEY, raw_value, { persist = true }) +end + +local electrical_measurement_ac_voltage_divisor_handler = function(driver, device, divisor, zb_rx) + local raw_value = divisor.value + if raw_value == 0 then + return + end + device:set_field(AC_VOLTAGE_DIVISOR_KEY, raw_value, { persist = true }) +end + +local electrical_measurement_ac_current_multiplier_handler = function(driver, device, multiplier, zb_rx) + local raw_value = multiplier.value + device:set_field(AC_CURRENT_MULTIPLIER_KEY, raw_value, { persist = true }) +end + +local electrical_measurement_ac_current_divisor_handler = function(driver, device, divisor, zb_rx) + local raw_value = divisor.value + if raw_value == 0 then + return + end + device:set_field(AC_CURRENT_DIVISOR_KEY, raw_value, { persist = true }) +end + +local measurement_handler = function(component, multiplier_key, divisor_key, emit_fn, unit) + local handler = function(driver, device, value, zb_rx) + local raw_value = value.value + -- By default emit raw value + local multiplier = device:get_field(multiplier_key) or 1 + local divisor = device:get_field(divisor_key) or 1 + + raw_value = raw_value * multiplier / divisor + + device:emit_component_event(device.profile.components[component], emit_fn({ value = raw_value, unit = unit })) + end + + return handler +end + +local frient_emi = { + NAME = "EMIZB-151", + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure + }, + zigbee_handlers = { + cluster = { + }, + attr = { + [SimpleMetering.ID] = { + [CurrentSummationReceived] = current_summation_received_handler, + [SimpleMetering.attributes.CurrentSummationDelivered.ID] = current_summation_delivered_handler, + [SimpleMetering.attributes.InstantaneousDemand.ID] = instantaneous_demand_handler, + }, + [ElectricalMeasurement.ID] = { + [ElectricalMeasurement.attributes.ACVoltageDivisor.ID] = electrical_measurement_ac_voltage_divisor_handler, + [ElectricalMeasurement.attributes.ACVoltageMultiplier.ID] = electrical_measurement_ac_voltage_multiplier_handler, + [ElectricalMeasurement.attributes.ACCurrentDivisor.ID] = electrical_measurement_ac_current_divisor_handler, + [ElectricalMeasurement.attributes.ACCurrentMultiplier.ID] = electrical_measurement_ac_current_multiplier_handler, + [ElectricalMeasurement.attributes.ActivePower.ID] = measurement_handler("phaseA", zigbee_constants.ELECTRICAL_MEASUREMENT_MULTIPLIER_KEY, zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, capabilities.powerMeter.power, "W"), + [ElectricalMeasurement.attributes.RMSVoltage.ID] = measurement_handler("phaseA", AC_VOLTAGE_MULTIPLIER_KEY, AC_VOLTAGE_DIVISOR_KEY, capabilities.voltageMeasurement.voltage, "V"), + [ElectricalMeasurement.attributes.RMSCurrent.ID] = measurement_handler("phaseA", AC_CURRENT_MULTIPLIER_KEY, AC_CURRENT_DIVISOR_KEY, capabilities.currentMeasurement.current, "A"), + [ElectricalMeasurement.attributes.ActivePowerPhB.ID] = measurement_handler("phaseB", zigbee_constants.ELECTRICAL_MEASUREMENT_MULTIPLIER_KEY, zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, capabilities.powerMeter.power, "W"), + [ElectricalMeasurement.attributes.RMSVoltagePhB.ID] = measurement_handler("phaseB", AC_VOLTAGE_MULTIPLIER_KEY, AC_VOLTAGE_DIVISOR_KEY, capabilities.voltageMeasurement.voltage, "V"), + [ElectricalMeasurement.attributes.RMSCurrentPhB.ID] = measurement_handler("phaseB", AC_CURRENT_MULTIPLIER_KEY, AC_CURRENT_DIVISOR_KEY, capabilities.currentMeasurement.current, "A"), + [ElectricalMeasurement.attributes.ActivePowerPhC.ID] = measurement_handler("phaseC", zigbee_constants.ELECTRICAL_MEASUREMENT_MULTIPLIER_KEY, zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, capabilities.powerMeter.power, "W"), + [ElectricalMeasurement.attributes.RMSVoltagePhC.ID] = measurement_handler("phaseC", AC_VOLTAGE_MULTIPLIER_KEY, AC_VOLTAGE_DIVISOR_KEY, capabilities.voltageMeasurement.voltage, "V"), + [ElectricalMeasurement.attributes.RMSCurrentPhC.ID] = measurement_handler("phaseC", AC_CURRENT_MULTIPLIER_KEY, AC_CURRENT_DIVISOR_KEY, capabilities.currentMeasurement.current, "A") + } + } + }, + can_handle = require("frient.EMIZB-151.can_handle") +} + +return frient_emi \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/can_handle.lua new file mode 100644 index 0000000000..3cf7077ddd --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_frient_power_meter = function(opts, driver, device) + local FINGERPRINTS = require("frient.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return true, require("frient") + end + end + + return false +end + +return is_frient_power_meter diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua new file mode 100644 index 0000000000..1e00c4434e --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_POWER_METER_FINGERPRINTS = { + { model = "ZHEMI101", }, + { model = "EMIZB-132", }, + { model = "EMIZB-141", MIN_BAT = 2.3 , MAX_BAT = 3.0 }, + { model = "EMIZB-151", } +} + +return ZIGBEE_POWER_METER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua index 1c9028f853..e87e3f909d 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua @@ -1,42 +1,158 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local constants = require "st.zigbee.constants" - -local ZIGBEE_POWER_METER_FINGERPRINTS = { - { model = "ZHEMI101" }, - { model = "EMIZB-132" }, -} +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zigbee_constants = require "st.zigbee.constants" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" + +local clusters = require "st.zigbee.zcl.clusters" +local SimpleMetering = clusters.SimpleMetering +local PowerConfiguration = clusters.PowerConfiguration +local utils = require "frient.utils" + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local data_types = require "st.zigbee.data_types" + +local log = require "log" +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local SIMPLE_METERING_DEFAULT_DIVISOR = 1000 -local is_frient_power_meter = function(opts, driver, device) +local ZIGBEE_POWER_METER_FINGERPRINTS = require("frient.fingerprints") + +local device_init = function(self, device) for _, fingerprint in ipairs(ZIGBEE_POWER_METER_FINGERPRINTS) do - if device:get_model() == fingerprint.model then - return true + if device:get_model() == fingerprint.model and fingerprint.MIN_BAT then + battery_defaults.build_linear_voltage_init(fingerprint.MIN_BAT, fingerprint.MAX_BAT)(self, device) end end +end - return false +local do_refresh = function(self, device) + device:refresh() + if device:supports_capability(capabilities.battery) then + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + end end local do_configure = function(self, device) - device:refresh() device:configure() + for _, fingerprint in ipairs(ZIGBEE_POWER_METER_FINGERPRINTS) do + if device:get_model() == fingerprint.model and device.preferences then + -- Only write manufacturer-specific attributes when preferences exist for this device. + if device.preferences.pulseConfiguration ~= nil then + local pulseConfiguration = tonumber(device.preferences.pulseConfiguration) or 1000 + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, pulseConfiguration):to_endpoint(0x02)) + end + + if device.preferences.currentSummation ~= nil then + local currentSummation = tonumber(device.preferences.currentSummation) or 0 + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, currentSummation):to_endpoint(0x02)) + end + end + end + + -- Divisor and multipler for PowerMeter + device:send(SimpleMetering.attributes.Divisor:read(device)) + device:send(SimpleMetering.attributes.Multiplier:read(device)) + + device.thread:call_with_delay(5, function() + do_refresh(self, device) + end) end -local device_init = function(self, device) - device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) - device:set_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10000, {persist = true}) +local function info_changed(driver, device, event, args) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "pulseConfiguration") then + local pulseConfiguration = tonumber(device.preferences.pulseConfiguration) + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, pulseConfiguration):to_endpoint(0x02)) + end + if (name == "currentSummation") then + local currentSummation = tonumber(device.preferences.currentSummation) + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, currentSummation):to_endpoint(0x02)) + end + end + end + device.thread:call_with_delay(5, function() + do_refresh(driver, device) + end) +end + +local function simple_metering_divisor_handler(driver, device, divisor, zb_rx) + local new_divisor = SIMPLE_METERING_DEFAULT_DIVISOR + local header = zb_rx.body and zb_rx.body.zcl_header + if header and header.frame_ctrl:is_mfg_specific_set() then + log.debug_with({ hub_logs = true }, string.format("Ignoring manufacturer-specific divisor report: %s", tostring(divisor.value))) + elseif (divisor.value and divisor.value == 0) then + log.warn_with({ hub_logs = true }, "Simple metering divisor reported as 0; forcing divisor to 1000") + elseif (divisor.value and divisor.value > 0) then + new_divisor = divisor.value + end + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, new_divisor, { persist = true }) +end + +local function instantaneous_demand_handler(driver, device, value, zb_rx) + local raw_value = value.value + --- demand = demand received * Multipler/Divisor + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + if raw_value < -8388607 or raw_value >= 8388607 then + raw_value = 0 + end + + raw_value = raw_value * multiplier / divisor * 1000 + + local raw_value_watts = raw_value + device:emit_event(capabilities.powerMeter.power({ value = raw_value_watts, unit = "W" })) +end + +local function energy_meter_handler(driver, device, value, zb_rx) + local raw_value = value.value + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + + if raw_value < 0 or raw_value >= 0xFFFFFFFFFFFF then + return + end + + raw_value = (raw_value * multiplier) / divisor + + local offset = device:get_field(zigbee_constants.ENERGY_METER_OFFSET) or 0 + if raw_value < offset then + --- somehow our value has gone below the offset, so we'll reset the offset, since the device seems to have + offset = 0 + device:set_field(zigbee_constants.ENERGY_METER_OFFSET, offset, { persist = true }) + end + raw_value = raw_value - offset + raw_value = raw_value * 1000 -- the unit of these values should be 'Wh' + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + + local current_time = os.time() + local last_report_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_report_time = last_report_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_report_time then + return + end + + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + capabilities.powerConsumptionReport.powerConsumption({ + start = utils.epoch_to_iso8601(last_report_time), + ["end"] = utils.epoch_to_iso8601(current_time - 1), + deltaEnergy = delta_energy, + energy = raw_value + }) + ) + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) end local frient_power_meter_handler = { @@ -44,8 +160,26 @@ local frient_power_meter_handler = { lifecycle_handlers = { init = device_init, doConfigure = do_configure, + infoChanged = info_changed + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zigbee_handlers = { + cluster = { + }, + attr = { + [SimpleMetering.ID] = { + [SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_meter_handler, + [SimpleMetering.attributes.InstantaneousDemand.ID] = instantaneous_demand_handler, + [SimpleMetering.attributes.Divisor.ID] = simple_metering_divisor_handler + } + } }, - can_handle = is_frient_power_meter + sub_drivers = require("frient.sub_drivers"), + can_handle = require("frient.can_handle"), } return frient_power_meter_handler \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/sub_drivers.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/sub_drivers.lua new file mode 100644 index 0000000000..3698af85d7 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient.EMIZB-151") +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/utils.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/utils.lua new file mode 100644 index 0000000000..70e7684855 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/utils.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local utils = {} + +utils.epoch_to_iso8601 = function(time) + return os.date("!%Y-%m-%dT%H:%M:%SZ", time) +end + +return utils \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/init.lua b/drivers/SmartThings/zigbee-power-meter/src/init.lua index 4b24f04d82..4f69f0e72e 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/init.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/init.lua @@ -1,24 +1,16 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" local zigbee_constants = require "st.zigbee.constants" local clusters = require "st.zigbee.zcl.clusters" -local ElectricalMeasurement = clusters.ElectricalMeasurement local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local configurations = require "configurations" local do_configure = function(self, device) device:refresh() @@ -27,9 +19,6 @@ local do_configure = function(self, device) -- Additional one time configuration if device:supports_capability(capabilities.energyMeter) or device:supports_capability(capabilities.powerMeter) then -- Divisor and multipler for EnergyMeter - device:send(ElectricalMeasurement.attributes.ACPowerDivisor:read(device)) - device:send(ElectricalMeasurement.attributes.ACPowerMultiplier:read(device)) - -- Divisor and multipler for PowerMeter device:send(SimpleMetering.attributes.Divisor:read(device)) device:send(SimpleMetering.attributes.Multiplier:read(device)) end @@ -52,16 +41,26 @@ local zigbee_power_meter_driver_template = { capabilities.powerMeter, capabilities.energyMeter, capabilities.powerConsumptionReport, + capabilities.battery, }, - sub_drivers = { - require("ezex"), - require("frient"), - require("shinasystems"), + zigbee_handlers = { + global = { + [SimpleMetering.ID] = { + [zcl_global_commands.CONFIGURE_REPORTING_RESPONSE_ID] = configurations.handle_reporting_config_response + }, + [ElectricalMeasurement.ID] = { + [zcl_global_commands.CONFIGURE_REPORTING_RESPONSE_ID] = configurations.handle_reporting_config_response + } + } }, + current_config_version = 1, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { - init = device_init, + init = configurations.power_reconfig_wrapper(device_init), doConfigure = do_configure, - } + }, + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_power_meter_driver_template, zigbee_power_meter_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-power-meter/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-power-meter/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-power-meter/src/shinasystems/can_handle.lua b/drivers/SmartThings/zigbee-power-meter/src/shinasystems/can_handle.lua new file mode 100644 index 0000000000..94808a8486 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/shinasystems/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_shinasystems_power_meter = function(opts, driver, device) + local FINGERPRINTS = require("shinasystems.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return true, require("shinasystems") + end + end + + return false +end + +return is_shinasystems_power_meter diff --git a/drivers/SmartThings/zigbee-power-meter/src/shinasystems/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/shinasystems/fingerprints.lua new file mode 100644 index 0000000000..5364835514 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/shinasystems/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_POWER_METER_FINGERPRINTS = { + { model = "PMM-300Z1" }, + { model = "PMM-300Z2" }, + { model = "PMM-300Z3" } +} + +return ZIGBEE_POWER_METER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-power-meter/src/shinasystems/init.lua b/drivers/SmartThings/zigbee-power-meter/src/shinasystems/init.lua index 4ba1c6467e..54866e2f82 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/shinasystems/init.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/shinasystems/init.lua @@ -1,35 +1,21 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local constants = require "st.zigbee.constants" local clusters = require "st.zigbee.zcl.clusters" local SimpleMetering = clusters.SimpleMetering local ElectricalMeasurement = clusters.ElectricalMeasurement +local configurations = require "configurations" -local ZIGBEE_POWER_METER_FINGERPRINTS = { - { model = "PMM-300Z1" }, - { model = "PMM-300Z2" }, - { model = "PMM-300Z3" } -} local POWERMETER_CONFIGURATION_V2 = { { cluster = SimpleMetering.ID, attribute = SimpleMetering.attributes.CurrentSummationDelivered.ID, minimum_interval = 5, - maximum_interval = 300, + maximum_interval = 450, -- Since the 15 minute report below depends on this report we make this 7.5 minutes data_type = SimpleMetering.attributes.CurrentSummationDelivered.base_type, reportable_change = 1 }, @@ -37,29 +23,20 @@ local POWERMETER_CONFIGURATION_V2 = { cluster = SimpleMetering.ID, attribute = SimpleMetering.attributes.InstantaneousDemand.ID, minimum_interval = 5, - maximum_interval = 300, + maximum_interval = 3600, data_type = SimpleMetering.attributes.InstantaneousDemand.base_type, - reportable_change = 1 + reportable_change = 5 }, { -- reporting : no cluster = ElectricalMeasurement.ID, attribute = ElectricalMeasurement.attributes.ActivePower.ID, - minimum_interval = 0, + minimum_interval = 5, maximum_interval = 65535, data_type = ElectricalMeasurement.attributes.ActivePower.base_type, - reportable_change = 1 + reportable_change = 5 } } -local is_shinasystems_power_meter = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_POWER_METER_FINGERPRINTS) do - if device:get_model() == fingerprint.model then - return true - end - end - - return false -end local function energy_meter_handler(driver, device, value, zb_rx) local multiplier = device:get_field(constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 @@ -111,7 +88,6 @@ local device_init = function(self, device) device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) for _, attribute in ipairs(POWERMETER_CONFIGURATION_V2) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -125,10 +101,10 @@ local shinasystems_power_meter_handler = { } }, lifecycle_handlers = { - init = device_init, + init = configurations.power_reconfig_wrapper(device_init), doConfigure = do_configure, }, - can_handle = is_shinasystems_power_meter + can_handle = require("shinasystems.can_handle"), } return shinasystems_power_meter_handler diff --git a/drivers/SmartThings/zigbee-power-meter/src/sub_drivers.lua b/drivers/SmartThings/zigbee-power-meter/src/sub_drivers.lua new file mode 100644 index 0000000000..51b24aca32 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("ezex"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("shinasystems"), + lazy_load_if_possible("bituo"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_battery_consumption_report.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_battery_consumption_report.lua new file mode 100644 index 0000000000..6a312c1906 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_battery_consumption_report.lua @@ -0,0 +1,299 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-power-energy-battery-consumption-report.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + model = "EMIZB-141", + server_clusters = { ElectricalMeasurement.ID, PowerConfiguration.ID, SimpleMetering.ID } + } + } + } +) + + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "InstantaneousDemand Report should be handled.", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }, + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 2700) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2700.0, unit = "W" })) + } + } +) + +test.register_coroutine_test( + "lifecycle configure event should configure the device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 1000):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, 0):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting( + mock_device, 5, 3600, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, 30, 21600, 1 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }} + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: pulseConfiguration, currentSummation", + function() + local updates = { + preferences = { + pulseConfiguration = 400, + currentSummation = 500 + } + } + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0300, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint16, + 400 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0301, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint48, + 500 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered Report should be handled.", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered report should be handled without powerConsumptionReport because 15 min didn't pass since last report", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_consumption_report.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_consumption_report.lua new file mode 100644 index 0000000000..2679329fd3 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_consumption_report.lua @@ -0,0 +1,272 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-power-energy-consumption-report.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + model = "ZHEMI101", + server_clusters = { ElectricalMeasurement.ID, PowerConfiguration.ID, SimpleMetering.ID } + } + } + } +) + + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "InstantaneousDemand Report should be handled.", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }, + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 2700) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2700.0, unit = "W" })) + } + } +) + +test.register_coroutine_test( + "lifecycle configure event should configure the device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 1000):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, 0):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting( + mock_device, 5, 3600, 1 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }} + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: pulseConfiguration, currentSummation", + function() + local updates = { + preferences = { + pulseConfiguration = 400, + currentSummation = 500 + } + } + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0300, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint16, + 400 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0301, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint48, + 500 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered Report should be handled.", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered report should be handled without powerConsumptionReport because 15 min didn't pass since last report", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_current_voltage.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_current_voltage.lua new file mode 100644 index 0000000000..75f9da6961 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_current_voltage.lua @@ -0,0 +1,361 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local CurrentSummationReceived = 0x0001 +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local zigbee_constants = require "st.zigbee.constants" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_VOLTAGE_MULTIPLIER_KEY = "_electrical_measurement_ac_voltage_multiplier" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_CURRENT_MULTIPLIER_KEY = "_electrical_measurement_ac_current_multiplier" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_VOLTAGE_DIVISOR_KEY = "_electrical_measurement_ac_voltage_divisor" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_CURRENT_DIVISOR_KEY = "_electrical_measurement_ac_current_divisor" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-power-energy-current-voltage.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + model = "EMIZB-151", + server_clusters = { ElectricalMeasurement.ID, SimpleMetering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +local function expected_refresh_commands() + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute( + mock_device, + data_types.ClusterId(SimpleMetering.ID), + CurrentSummationReceived + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:read(mock_device) + }) +end + + + + +test.register_coroutine_test( + "Refresh should read all necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", command = "refresh", args = {} } }) + + expected_refresh_commands() + end +) + +test.register_coroutine_test( + "ALl reports (for all phases) should be handled properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACVoltageMultiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACVoltageDivisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACCurrentMultiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACCurrentDivisor:build_test_attr_report(mock_device, 1000) }) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 30) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 30.0, unit = "Wh"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 40) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 40.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, 50) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseA", capabilities.powerMeter.power({ value = 50.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, 50) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseA", capabilities.voltageMeasurement.voltage({ value = 0.05, unit = "V"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, 60) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseA", capabilities.currentMeasurement.current({ value = 0.06, unit = "A"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, 70) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseB", capabilities.powerMeter.power({ value = 70.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, 80) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseB", capabilities.voltageMeasurement.voltage({ value = 0.08, unit = "V"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, 90) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseB", capabilities.currentMeasurement.current({ value = 0.09, unit = "A"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhC:build_test_attr_report(mock_device, 100) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseC", capabilities.powerMeter.power({ value = 100.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhC:build_test_attr_report(mock_device, 110) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseC", capabilities.voltageMeasurement.voltage({ value = 0.11, unit = "V"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhC:build_test_attr_report(mock_device, 120) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseC", capabilities.currentMeasurement.current({ value = 0.12, unit = "A"})) + ) + + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered Report should be handled.", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered report should be handled without powerConsumptionReport because 15 min didn't pass since last report", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.register_coroutine_test( + "lifecycle configure event should configure the device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + expected_refresh_commands() + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting( + mock_device, 5, 3600, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting( + mock_device, + data_types.ClusterId(SimpleMetering.ID), + data_types.AttributeId(CurrentSummationReceived), + data_types.ZigbeeDataType(data_types.Uint48.ID), + 5, + 3600, + 1 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua index 8577910291..571764d8f2 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua @@ -20,6 +20,12 @@ local SimpleMetering = clusters.SimpleMetering local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local messages = require "st.zigbee.messages" +local config_reporting_response = require "st.zigbee.zcl.global_commands.configure_reporting_response" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local data_types = require "st.zigbee.data_types" +local Status = require "st.zigbee.generated.types.ZclStatus" local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("power-meter.yml") } @@ -27,12 +33,147 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() end test.set_test_init_function(test_init) +local function build_config_response_msg(device, cluster, global_status, attribute, attr_status) + local addr_header = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local config_response_body + if global_status ~= nil then + config_response_body = config_reporting_response.ConfigureReportingResponse({}, global_status) + else + local individual_record = config_reporting_response.ConfigureReportingResponseRecord(attr_status, 0x01, attribute) + config_response_body = config_reporting_response.ConfigureReportingResponse({individual_record}, nil) + end + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(config_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = config_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +test.register_coroutine_test( + "configuration version below 1", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1)}) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, Status.SUCCESS)}) + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "configuration version below 1 config response not success", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1)}) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, Status.UNSUPPORTED_ATTRIBUTE)}) + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, Status.UNSUPPORTED_ATTRIBUTE)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == nil) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "configuration version below 1 individual config response records ElectricalMeasurement", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1)}) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, nil, ElectricalMeasurement.attributes.ActivePower.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "configuration version below 1 individual config response records SimpleMetering", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1)}) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, nil, SimpleMetering.attributes.InstantaneousDemand.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) + test.register_message_test( "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", { @@ -52,6 +193,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2.7, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -73,6 +217,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.027, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -101,7 +248,7 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 1, 3600, 5) + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -115,7 +262,7 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 1, 3600, 5) + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -125,6 +272,14 @@ test.register_coroutine_test( mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.Multiplier:read(mock_device) @@ -134,7 +289,10 @@ test.register_coroutine_test( SimpleMetering.attributes.Divisor:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua new file mode 100644 index 0000000000..f82864cc06 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua @@ -0,0 +1,355 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local constants = require "st.zigbee.constants" + + +-- Mock out globals +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-1p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "BITUO TECHNIK", + model = "SPM01X", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "resetEnergyMeter command should send OnOff On to reset device", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, clusters.OnOff.server.commands.On(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "refresh capability command should read device attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "energy handler resets offset when reading is below stored offset", + function() + -- Set an offset larger than the incoming value (100 raw / 100 = 1.0 kWh, offset = 5.0) + mock_device:set_field(constants.ENERGY_METER_OFFSET, 5.0, {persist = true}) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 100) + }) + -- Offset resets to 0; raw_value_kilowatts = 1.0 - 0 = 1.0; no powerConsumptionReport (delta_tick < 15 min) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.0, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "energy handler resets save tick when timer has slipped beyond 30 minutes", + function() + -- Advance time > 30 min so that curr_save_tick + 15*60 < os.time() is true + test.timer.__create_and_queue_test_time_advance_timer(40*60, "oneshot") + test.mock_time.advance_time(40*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 100) + }) + -- raw_value = 100, divisor = 100, kWh = 1.0, watts = 1000.0; first report: deltaEnergy = 0.0 + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1000.0, deltaEnergy = 0.0})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.0, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p_ZM.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p_ZM.lua new file mode 100644 index 0000000000..ef82322125 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p_ZM.lua @@ -0,0 +1,211 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + + +-- Mock out globals +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-1p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Zemismart", + model = "SPM01-1Z2", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua new file mode 100644 index 0000000000..37edda6bd9 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua @@ -0,0 +1,309 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +-- 使用两相电能表配置文件 +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-2p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "BITUO TECHNIK", + model = "SDM02X", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p_ZM.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p_ZM.lua new file mode 100644 index 0000000000..5b959e7c61 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p_ZM.lua @@ -0,0 +1,284 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +-- 使用两相电能表配置文件 +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-2p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Zemismart", + model = "SDM02-2Z1", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua new file mode 100644 index 0000000000..ed606d096e --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua @@ -0,0 +1,392 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-3p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "BITUO TECHNIK", + model = "SDM01W", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report for PhaseB should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report for PhaseC should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhC:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseC should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhC:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseC should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhC:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p_ZM.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p_ZM.lua new file mode 100644 index 0000000000..c4d24f35ba --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p_ZM.lua @@ -0,0 +1,358 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-3p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Zemismart", + model = "SDM01-3Z1", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_message_test( + "ActivePower Report for PhaseB should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_message_test( + "ActivePower Report for PhaseC should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhC:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseC should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhC:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseC should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhC:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua index 74350fd188..ece6587f2f 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua @@ -20,6 +20,14 @@ local SimpleMetering = clusters.SimpleMetering local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local messages = require "st.zigbee.messages" +local config_reporting_response = require "st.zigbee.zcl.global_commands.configure_reporting_response" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local data_types = require "st.zigbee.data_types" +local Status = require "st.zigbee.generated.types.ZclStatus" +local constants = require "st.zigbee.constants" + local mock_device = test.mock_device.build_test_zigbee_device( { @@ -36,8 +44,37 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() +end + +local function build_config_response_msg(device, cluster, global_status, attribute, attr_status) + local addr_header = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local config_response_body + if global_status ~= nil then + config_response_body = config_reporting_response.ConfigureReportingResponse({}, global_status) + else + local individual_record = config_reporting_response.ConfigureReportingResponseRecord(attr_status, 0x01, attribute) + config_response_body = config_reporting_response.ConfigureReportingResponse({individual_record}, nil) + end + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(config_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = config_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) end test.set_test_init_function(test_init) @@ -61,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -103,7 +143,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -123,6 +166,14 @@ test.register_coroutine_test( mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, @@ -131,11 +182,11 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 300, 1) + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) }) test.socket.zigbee:__expect_send({ mock_device.id, - SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 300, 1) + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 450, 1) }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -145,10 +196,90 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 0, 65535, 1) + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 65535, 5) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) +test.register_coroutine_test( + "configuration version below 1 use override configs", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 65535, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 450, 1)}) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, Status.SUCCESS)}) + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) +test.register_coroutine_test( + "energy handler resets shinasystems offset when reading is below stored offset", + function() + -- divisor=1000; raw_value=100 -> 0.1 kWh; offset=0.5 -> 0.1 < 0.5 triggers reset + mock_device:set_field(constants.ENERGY_METER_OFFSET, 0.5, {persist = true}) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 100) + }) + -- offset resets to 0; raw_value_kilowatts = 0.1; no powerConsumptionReport (delta_tick < 15 min) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 0.1, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "shinasystems energy handler resets save tick when timer has slipped beyond 30 minutes", + function() + -- Advance time > 30 min so that curr_save_tick + 15*60 < os.time() is true + test.timer.__create_and_queue_test_time_advance_timer(40*60, "oneshot") + test.mock_time.advance_time(40*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 1500) + }) + -- raw_value=1500, divisor=1000, kWh=1.5, watts=1500.0; first report: deltaEnergy=0.0 + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_ezex.lua similarity index 57% rename from drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report.lua rename to drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_ezex.lua index 59441e7789..afb5adda69 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_ezex.lua @@ -20,6 +20,12 @@ local SimpleMetering = clusters.SimpleMetering local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local messages = require "st.zigbee.messages" +local config_reporting_response = require "st.zigbee.zcl.global_commands.configure_reporting_response" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local data_types = require "st.zigbee.data_types" +local Status = require "st.zigbee.generated.types.ZclStatus" local mock_device = test.mock_device.build_test_zigbee_device( { @@ -36,14 +42,43 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() end test.set_test_init_function(test_init) +local function build_config_response_msg(device, cluster, global_status, attribute, attr_status) + local addr_header = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local config_response_body + if global_status ~= nil then + config_response_body = config_reporting_response.ConfigureReportingResponse({}, global_status) + else + local individual_record = config_reporting_response.ConfigureReportingResponseRecord(attr_status, 0x01, attribute) + config_response_body = config_reporting_response.ConfigureReportingResponse({individual_record}, nil) + end + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(config_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = config_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + test.register_message_test( - "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + "ActivePower Report should not generate an event", { { channel = "zigbee", @@ -55,12 +90,10 @@ test.register_message_test( direction = "receive", message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, 27) }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2.7, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -97,6 +130,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 0.0015, unit = "kWh"})) } + }, + { + min_api_version = 17 } ) @@ -114,35 +150,52 @@ test.register_coroutine_test( SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ mock_device.id, - ElectricalMeasurement.attributes.ActivePower:read(mock_device) + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 500) }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - SimpleMetering.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 1, 3600, 500) - }) - test.socket.zigbee:__expect_send({ + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - ElectricalMeasurement.ID) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 1, 3600, 5) - }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) +test.register_coroutine_test( + "configuration version below 1 use override configs", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 500)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1)}) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, Status.SUCCESS)}) + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) + + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_frient.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_frient.lua new file mode 100644 index 0000000000..f6cf172d7f --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_frient.lua @@ -0,0 +1,200 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local constants = require "st.zigbee.constants" + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("frient-power-meter-consumption-report.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Develco Products A/S", + model = "EMIZB-132", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "frient device_init sets divisor fields", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + end +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }} + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_coroutine_test( + "frient instantaneous demand report emits power", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 40) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 40.0, unit = "W" })) + ) + end +) + +test.register_coroutine_test( + "frient current summation delivered emits energy and consumption report", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "frient current summation delivered skips consumption report when interval is short", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.register_coroutine_test( + "frient divisor report updates divisor field", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 0) }) + test.wait_for_events() + assert(mock_device:get_field(constants.SIMPLE_METERING_DIVISOR_KEY) == 1000, + "SIMPLE_METERING_DIVISOR_KEY should be 1000") + end +) + +test.register_coroutine_test( + "frient lifecycle configure event should configure device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..33a0bde838 --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device, ...) + local FINGERPRINTS = { mfr = "aqara", model = "lumi.motion.ac01" } + + if device:get_manufacturer() == FINGERPRINTS.mfr and device:get_model() == FINGERPRINTS.model then + return true, require("aqara") + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua index a37c23abf7..1fd79e3875 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" @@ -19,17 +22,12 @@ local SENSITIVITY = "stse.sensitivity" local RESET_PRESENCE = "stse.resetPresence" local APP_DISTANCE = "stse.approachDistance" -local FINGERPRINTS = { mfr = "aqara", model = "lumi.motion.ac01" } - -local is_aqara_products = function(opts, driver, device, ...) - return device:get_manufacturer() == FINGERPRINTS.mfr and device:get_model() == FINGERPRINTS.model -end - local function device_init(driver, device) -- no action end -local function device_added(driver, device) +local function do_configure(self, device) + device:configure() -- private protocol enable device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1)) @@ -100,10 +98,10 @@ local aqara_fp1_handler = { }, lifecycle_handlers = { init = device_init, - added = device_added, + doConfigure = do_configure, infoChanged = device_info_changed }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_fp1_handler diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/can_handle.lua b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/can_handle.lua new file mode 100644 index 0000000000..d848d46caa --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function arrival_sensor_v1_can_handle(opts, driver, device, ...) + -- excluding Aqara device and tagv4 + if device:get_manufacturer() ~= "aqara" and device:get_model() ~= "tagv4" then + return true, require("arrival-sensor-v1") + end + return false +end + +return arrival_sensor_v1_can_handle diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua index dd0566a3a0..81d9461a38 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Zigbee Spec Utils local zcl_messages = require "st.zigbee.zcl" @@ -41,10 +31,6 @@ local presence_utils = require "presence_utils" local CHECKIN_INTERVAL = 20 -- seconds -local function arrival_sensor_v1_can_handle(opts, driver, device, ...) - -- excluding Aqara device and tagv4 - return device:get_manufacturer() ~= "aqara" and device:get_model() ~= "tagv4" -end local function legacy_battery_handler(self, device, zb_rx) local battery_value = string.byte(zb_rx.body.zcl_body.body_bytes) @@ -100,8 +86,14 @@ local function beep_handler(self, device, command) end end +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + local function added_handler(self, device) - device:emit_event(PresenceSensor.presence("present")) + emit_event_if_latest_state_missing(device, "main", PresenceSensor, PresenceSensor.presence.NAME, PresenceSensor.presence("present")) end local function init_handler(self, device, event, args) @@ -138,7 +130,7 @@ local arrival_sensor_v1 = { added = added_handler, init = init_handler }, - can_handle = arrival_sensor_v1_can_handle + can_handle = require("arrival-sensor-v1.can_handle"), } return arrival_sensor_v1 diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/init.lua b/drivers/SmartThings/zigbee-presence-sensor/src/init.lua index 0b4ecf5c75..d797a09b62 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -111,7 +101,6 @@ end local function init_handler(self, device, event, args) device:set_field(battery_defaults.DEVICE_VOLTAGE_TABLE_KEY, battery_table) device:add_configured_attribute(battery_voltage_attr_configuration) - device:add_monitored_attribute(battery_voltage_attr_configuration) device:remove_monitored_attribute(PowerConfiguration.ID, PowerConfiguration.attributes.BatteryPercentageRemaining.ID) device:remove_configured_attribute(PowerConfiguration.ID, PowerConfiguration.attributes.BatteryPercentageRemaining.ID) @@ -139,8 +128,14 @@ local function beep_handler(self, device, command) device:send(IdentifyCluster.server.commands.Identify(device, BEEP_IDENTIFY_TIME)) end +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + local function added_handler(self, device) - device:emit_event(PresenceSensor.presence("present")) + emit_event_if_latest_state_missing(device, "main", PresenceSensor, PresenceSensor.presence.NAME, PresenceSensor.presence("present")) device:set_field(IS_PRESENCE_BASED_ON_BATTERY_REPORTS, false, {persist = true}) device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) end @@ -199,10 +194,9 @@ local zigbee_presence_driver = { }, -- Custom handler for every Zigbee message zigbee_message_handler = all_zigbee_message_handler, - sub_drivers = { - require("aqara"), - require("arrival-sensor-v1") - } + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_presence_driver, zigbee_presence_driver.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-presence-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua b/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua index a68de0f95e..968d75ea54 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local presence_utils = {} diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-presence-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..1340826663 --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("arrival-sensor-v1"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua index 4b8159997d..557519996e 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local cluster_base = require "st.zigbee.cluster_base" local t_utils = require "integration_test.utils" @@ -40,11 +43,11 @@ end test.set_test_init_function(test_init) test.register_coroutine_test( - "lifecycle - added test", + "lifecycle - doConfigure test", function() test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.capability:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) -- private protocol enable test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, @@ -56,7 +59,12 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESET_MODE, MFG_CODE, data_types.Uint8, 1) }) - end + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -81,7 +89,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x010C, MFG_CODE, data_types.Uint8, updates.preferences["stse.sensitivity"]) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -106,7 +117,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESET_MODE, MFG_CODE, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -131,7 +145,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0146, MFG_CODE, data_types.Uint8, updates.preferences["stse.approachDistance"]) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -146,7 +163,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", PresenceSensor.presence("present"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -161,7 +181,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", PresenceSensor.presence("not present"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -181,7 +204,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -201,7 +227,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -221,7 +250,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -241,7 +273,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua index 513b40966d..9124534739 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -68,6 +57,7 @@ end zigbee_test_utils.prepare_zigbee_env_info() local add_device = function() + -- The initial presenceSensor event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added"}) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present") @@ -75,11 +65,15 @@ local add_device = function() test.wait_for_events() end -local function test_init() - test.mock_device.add_test_device(mock_simple_device) - zigbee_test_utils.init_noop_health_check_timer() +local add_device_after_switch_over = function() + -- Avoid sending the initial presenceSensor event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added"}) + test.wait_for_events() end +local function test_init() + test.mock_device.add_test_device(mock_simple_device)end + test.set_test_init_function(test_init) test.register_coroutine_test( @@ -102,7 +96,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(7) end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -120,7 +117,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -128,7 +126,11 @@ test.register_coroutine_test( "Added lifecycle should be handlded", function () add_device() - end + add_device_after_switch_over() + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -140,7 +142,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -153,7 +158,10 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.battery.battery(100))) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +174,10 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.battery.battery(75))) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -179,7 +190,10 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.battery.battery(0))) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -193,9 +207,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) end, { - test_init = function() - zigbee_test_utils.init_noop_health_check_timer() - end + test_init = function() end, min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua index 0749599bb7..53843e0014 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -21,6 +10,7 @@ local PowerConfiguration = clusters.PowerConfiguration local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local presence_utils = require "presence_utils" -- Needed for building ConfigureReportingResponse msg local messages = require "st.zigbee.messages" @@ -46,9 +36,7 @@ local mock_simple_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_simple_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_simple_device)end local function build_config_response_msg(cluster, status) local addr_header = messages.AddressHeader( @@ -90,7 +78,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -107,10 +96,14 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, IdentifyCluster.server.commands.Identify(mock_simple_device, 0x05) } } + }, + { + min_api_version = 17 } ) local add_device = function() + -- The initial presenceSensor event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added"}) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present") @@ -122,6 +115,16 @@ local add_device = function() test.wait_for_events() end +local add_device_after_switch_over = function() + -- Avoid sending the initial presenceSensor event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added"}) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_simple_device) + }) + test.wait_for_events() +end + test.register_coroutine_test( "Battery Voltage test cases when polling from hub", function() @@ -147,7 +150,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence.present())) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -180,14 +186,21 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Added lifecycle should be handlded", function () add_device() - end + add_device_after_switch_over() + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -201,9 +214,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) end, { - test_init = function() - zigbee_test_utils.init_noop_health_check_timer() - end + test_init = function() end, min_api_version = 17 } ) @@ -232,7 +243,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(121) test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -264,7 +278,10 @@ test.register_coroutine_test( test.mock_time.advance_time(305) test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -294,7 +311,110 @@ test.register_coroutine_test( PowerConfiguration.ID ) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "battery_config_response_handler cancels pre-existing recurring poll timer", + function() + -- Place a live timer in the field so the nil-check branch is taken. + local pre_timer = test.timer.__create_test_time_advance_timer(60, "interval") + mock_simple_device:set_field(presence_utils.RECURRING_POLL_TIMER, pre_timer) + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + build_config_response_msg(PowerConfiguration.ID, 0x00) + }) + -- poke() emits "present" for every inbound zigbee message + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present")) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "info_changed with changed check_interval cancels existing recurring poll timer", + function() + local pre_timer = test.timer.__create_test_time_advance_timer(60, "interval") + mock_simple_device:set_field(presence_utils.RECURRING_POLL_TIMER, pre_timer) + test.socket.device_lifecycle():__queue_receive( + mock_simple_device:generate_info_changed({ preferences = { check_interval = 100 } }) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +-- Build two additional mock devices (module-level) for checkInterval type variants. +-- The profile default sets checkInterval = 120 (number); we override after building. +local mock_device_str_interval = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("smartthings-arrival-sensor.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SmartThings", + model = "tagv4", + server_clusters = {0x0000, 0x0001, 0x0003} + } + } + } +) +mock_device_str_interval.preferences.checkInterval = "120" -- string → triggers elseif branch + +local mock_device_nil_interval = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("smartthings-arrival-sensor.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SmartThings", + model = "tagv4", + server_clusters = {0x0000, 0x0001, 0x0003} + } + } + } +) +mock_device_nil_interval.preferences.checkInterval = nil -- nil → triggers default-return branch + +test.register_coroutine_test( + "init with string checkInterval uses parsed value for presence timeout", + function() + test.mock_device.add_test_device(mock_device_str_interval) + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_str_interval.id, "init" }) + test.wait_for_events() + test.mock_time.advance_time(121) + test.socket.capability:__expect_send( + mock_device_str_interval:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + ) + end, + { test_init = function() end, min_api_version = 17 + } +) + +test.register_coroutine_test( + "init with nil checkInterval uses default presence timeout", + function() + test.mock_device.add_test_device(mock_device_nil_interval) + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_nil_interval.id, "init" }) + test.wait_for_events() + test.mock_time.advance_time(121) + test.socket.capability:__expect_send( + mock_device_nil_interval:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + ) + end, + { test_init = function() end, min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-range-extender/fingerprints.yml b/drivers/SmartThings/zigbee-range-extender/fingerprints.yml index e889fc1839..d8d4bcd5cf 100644 --- a/drivers/SmartThings/zigbee-range-extender/fingerprints.yml +++ b/drivers/SmartThings/zigbee-range-extender/fingerprints.yml @@ -29,3 +29,8 @@ zigbeeManufacturer: manufacturer: Insta GmbH model: NEXENTRO Pushbutton Interface deviceProfileName: range-extender + - id: "frientA/S/111" + deviceLabel: frient Zigbee Range Extender + manufacturer: frient A/S + model: REXZB-111 + deviceProfileName: range-extender-battery-source \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-range-extender/profiles/range-extender-battery-source.yml b/drivers/SmartThings/zigbee-range-extender/profiles/range-extender-battery-source.yml new file mode 100644 index 0000000000..f8958a3901 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/profiles/range-extender-battery-source.yml @@ -0,0 +1,20 @@ +name: range-extender-battery-source +components: + - id: main + capabilities: + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + - id: battery + version: 1 + - id: powerSource + version: 1 + config: + values: + - key: "powerSource.value" + enabledValues: + - battery + - mains + categories: + - name: Networking diff --git a/drivers/SmartThings/zigbee-range-extender/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-range-extender/src/frient/can_handle.lua new file mode 100644 index 0000000000..9967c0edf8 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and (device:get_model() == "REXZB-111") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-range-extender/src/frient/init.lua b/drivers/SmartThings/zigbee-range-extender/src/frient/init.lua new file mode 100644 index 0000000000..fef5d8470a --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/frient/init.lua @@ -0,0 +1,66 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration + +local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) + device:emit_event_for_endpoint( + zigbee_message.address_header.src_endpoint.value, + zone_status:is_ac_mains_fault_set() and capabilities.powerSource.powerSource.battery() or capabilities.powerSource.powerSource.mains() + ) +end + +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +local function device_added(driver, device) + device:emit_event(capabilities.powerSource.powerSource.mains()) +end + +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(3.3, 4.1)(driver, device) +end + +local function do_refresh(driver, device) + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + device:send(IASZone.attributes.ZoneStatus:read(device)) +end + +local frient_range_extender = { + NAME = "frient Range Extender", + lifecycle_handlers = { + added = device_added, + init = device_init + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + zigbee_handlers = { + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + } + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + } + }, + can_handle = require("frient.can_handle"), +} + +return frient_range_extender diff --git a/drivers/SmartThings/zigbee-range-extender/src/init.lua b/drivers/SmartThings/zigbee-range-extender/src/init.lua index b41180e2d4..f06988f5cf 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/init.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/init.lua @@ -1,19 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" +local capabilities = require "st.capabilities" +local log = require "log" +local defaults = require "st.zigbee.defaults" local Basic = (require "st.zigbee.zcl.clusters").Basic local ZigbeeDriver = require "st.zigbee" @@ -23,24 +14,36 @@ end local zigbee_range_driver_template = { supported_capabilities = { - capabilities.refresh + capabilities.refresh, + capabilities.battery }, capability_handlers = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, + health_check = false, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } +defaults.register_for_default_handlers(zigbee_range_driver_template, zigbee_range_driver_template.supported_capabilities) + local zigbee_range_extender_driver = ZigbeeDriver("zigbee-range-extender", zigbee_range_driver_template) function zigbee_range_extender_driver:device_health_check() local device_list = self.device_api.get_device_list() for _, device_id in ipairs(device_list) do - local device = self:get_device_info(device_id, false) - device:send(Basic.attributes.ZCLVersion:read(device)) + local device, err = self:get_device_info(device_id, false) + if device == nil then + log.warn_with({ hub_logs = true }, + string.format("device_health_check failed to get device info for device_id %s: %s", device_id, err)) + else + device:send(Basic.attributes.ZCLVersion:read(device)) + end end end + zigbee_range_extender_driver.device_health_timer = zigbee_range_extender_driver.call_on_schedule(zigbee_range_extender_driver, 300, zigbee_range_extender_driver.device_health_check) zigbee_range_extender_driver:run() diff --git a/drivers/SmartThings/zigbee-range-extender/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-range-extender/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua b/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua new file mode 100644 index 0000000000..2f30e461ee --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua b/drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua new file mode 100644 index 0000000000..30a6270b8a --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua @@ -0,0 +1,236 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("range-extender-battery-source.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "REXZB-111", + server_clusters = {IASZone.ID, PowerConfiguration.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "lifecycles - init and doConfigure test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read( mock_device ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:read( mock_device ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Power source / mains should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Power source / battery should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0081) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 33) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Medium battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 37) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 41) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification - mains should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Device added lifecycle should emit mains powerSource", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_device.id, "added" } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua b/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua index 321d96b609..59156dd798 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua @@ -1,20 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" local Basic = clusters.Basic @@ -24,9 +12,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() - test.timer.__create_and_queue_test_time_advance_timer(600, "interval", "health_check") + test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(600, "interval", "health_check") end test.set_test_init_function(test_init) @@ -42,20 +28,23 @@ test.register_coroutine_test( Basic.attributes.ZCLVersion:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.wait_for_events() - - test.mock_time.advance_time(50000) - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.ZCLVersion:read(mock_device) }) - test.wait_for_events() - end -) +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.ZCLVersion:read(mock_device) }) +-- test.wait_for_events() +-- end +-- ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-sensor/config.yml b/drivers/SmartThings/zigbee-sensor/config.yml new file mode 100644 index 0000000000..7bfe5e3e7c --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/config.yml @@ -0,0 +1,6 @@ +name: 'Zigbee Sensor' +packageKey: 'zigbee-sensor' +permissions: + zigbee: {} +description: "SmartThings driver for Zigbee sensor devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/zigbee-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-sensor/fingerprints.yml new file mode 100644 index 0000000000..014ef79844 --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/fingerprints.yml @@ -0,0 +1,16 @@ +zigbeeGeneric: + - id: "generic-sensor" + deviceLabel: "Zigbee Generic Sensor" + clusters: + server: + - 0x0500 + deviceProfileName: generic-sensor + - id: "generic-battery-sensor" + deviceLabel: "Zigbee Generic Sensor" + deviceIdentifiers: + - 0x0402 + clusters: + server: + - 0x0500 + - 0x0001 + deviceProfileName: generic-sensor diff --git a/drivers/SmartThings/zigbee-sensor/profiles/generic-contact-sensor.yml b/drivers/SmartThings/zigbee-sensor/profiles/generic-contact-sensor.yml new file mode 100644 index 0000000000..8d07bcc70e --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/profiles/generic-contact-sensor.yml @@ -0,0 +1,14 @@ +name: generic-contact-sensor +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/zigbee-sensor/profiles/generic-motion-illuminance.yml b/drivers/SmartThings/zigbee-sensor/profiles/generic-motion-illuminance.yml new file mode 100755 index 0000000000..af4dfab95a --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/profiles/generic-motion-illuminance.yml @@ -0,0 +1,20 @@ +name: generic-motion-illuminance +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + config: + values: + - key: "illuminance.value" + range: [0, 32000] + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-sensor/profiles/generic-motion-sensor.yml b/drivers/SmartThings/zigbee-sensor/profiles/generic-motion-sensor.yml new file mode 100644 index 0000000000..c126485be9 --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/profiles/generic-motion-sensor.yml @@ -0,0 +1,14 @@ +name: generic-motion-sensor +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/SmartThings/zigbee-sensor/profiles/generic-sensor.yml b/drivers/SmartThings/zigbee-sensor/profiles/generic-sensor.yml new file mode 100644 index 0000000000..c91fdca717 --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/profiles/generic-sensor.yml @@ -0,0 +1,10 @@ +name: generic-sensor +components: +- id: main + capabilities: + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-sensor/profiles/generic-waterleak-sensor.yml b/drivers/SmartThings/zigbee-sensor/profiles/generic-waterleak-sensor.yml new file mode 100644 index 0000000000..45b0ed2a9d --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/profiles/generic-waterleak-sensor.yml @@ -0,0 +1,14 @@ +name: generic-waterleak-sensor +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/zigbee-sensor/src/contact/init.lua b/drivers/SmartThings/zigbee-sensor/src/contact/init.lua new file mode 100755 index 0000000000..55970e2b37 --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/src/contact/init.lua @@ -0,0 +1,32 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local defaults = require "st.zigbee.defaults" +local capabilities = require "st.capabilities" + +local is_contact_sensor = function(opts, driver, device) + if device:supports_capability(capabilities.contactSensor) then + return true + end +end + +local generic_contact_sensor = { + NAME = "Generic Contact Sensor", + supported_capabilities = { + capabilities.contactSensor + }, + can_handle = is_contact_sensor, +} +defaults.register_for_default_handlers(generic_contact_sensor, generic_contact_sensor.supported_capabilities) +return generic_contact_sensor \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-sensor/src/init.lua b/drivers/SmartThings/zigbee-sensor/src/init.lua new file mode 100644 index 0000000000..348d186613 --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/src/init.lua @@ -0,0 +1,111 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local constants = require "st.zigbee.constants" +local IasZoneType = require "st.zigbee.generated.types.IasZoneType" +local device_management = require "st.zigbee.device_management" +local PowerConfiguration = clusters.PowerConfiguration + +local CONTACT_SWITCH = IasZoneType.CONTACT_SWITCH +local MOTION_SENSOR = IasZoneType.MOTION_SENSOR +local WATER_SENSOR = IasZoneType.WATER_SENSOR + +local ZIGBEE_GENERIC_SENSOR_PROFILE = "generic-sensor" +local ZIGBEE_GENERIC_CONTACT_SENSOR_PROFILE = "generic-contact-sensor" +local ZIGBEE_GENERIC_MOTION_SENSOR_PROFILE = "generic-motion-sensor" +local ZIGBEE_GENERIC_WATERLEAK_SENSOR_PROFILE = "generic-waterleak-sensor" +local ZIGBEE_GENERIC_MOTION_ILLUMINANCE_PROFILE = "generic-motion-illuminance" + +local ZONETYPE = "ZoneType" +local IASZone = clusters.IASZone + +-- ask device to upload its zone type +local device_added = function(self, device) + device:send(IASZone.attributes.ZoneType:read(device)) +end + +-- configure reporting for IASZone cluster +local do_configure = function(self, device) + device:configure() + device:send(device_management.build_bind_request(device, IASZone.ID, self.environment_info.hub_zigbee_eui)) + device:send(IASZone.attributes.ZoneStatus:configure_reporting(device, 30, 300, 1)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) +end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + device:send(IASZone.attributes.ZoneStatus:read(device)) + end +end + +-- update profile with different zone type +local function update_profile(device, zone_type) + local profile = ZIGBEE_GENERIC_SENSOR_PROFILE + if zone_type == CONTACT_SWITCH then + profile = ZIGBEE_GENERIC_CONTACT_SENSOR_PROFILE + elseif zone_type == WATER_SENSOR then + profile = ZIGBEE_GENERIC_WATERLEAK_SENSOR_PROFILE + elseif zone_type == MOTION_SENSOR then + profile = ZIGBEE_GENERIC_MOTION_SENSOR_PROFILE + for _, ep in ipairs(device.zigbee_endpoints) do + if device:supports_server_cluster(clusters.IlluminanceMeasurement.ID, ep.id) then + profile = ZIGBEE_GENERIC_MOTION_ILLUMINANCE_PROFILE + end + end + end + device:try_update_metadata({profile = profile}) +end + +-- read zone type and update profile +local ias_zone_type_attr_handler = function (driver, device, attr_val) + device:set_field(ZONETYPE, attr_val.value) + update_profile(device, attr_val.value) +end + +local zigbee_generic_sensor_template = { + supported_capabilities = { + capabilities.battery, + capabilities.firmwareUpdate, + capabilities.refresh + }, + zigbee_handlers = { + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneType.ID] = ias_zone_type_attr_handler + } + } + }, + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure, + infoChanged = info_changed + }, + sub_drivers = { + require("contact"), + require("motion"), + require("waterleak"), + require("motion-illuminance") + }, + ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, + health_check = false, + shared_device_thread_enabled = true, +} + +defaults.register_for_default_handlers(zigbee_generic_sensor_template, zigbee_generic_sensor_template.supported_capabilities) +local zigbee_sensor = ZigbeeDriver("zigbee-sensor", zigbee_generic_sensor_template) +zigbee_sensor:run() diff --git a/drivers/SmartThings/zigbee-sensor/src/motion-illuminance/init.lua b/drivers/SmartThings/zigbee-sensor/src/motion-illuminance/init.lua new file mode 100755 index 0000000000..0e52ec660c --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/src/motion-illuminance/init.lua @@ -0,0 +1,33 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local defaults = require "st.zigbee.defaults" +local capabilities = require "st.capabilities" + +local is_motion_illuminance = function(opts, driver, device) + if device:supports_capability(capabilities.motionSensor) and device:supports_capability(capabilities.illuminanceMeasurement) then + return true + end +end + +local generic_motion_illuminance = { + NAME = "Generic Motion illuminance", + supported_capabilities = { + capabilities.illuminanceMeasurement, + capabilities.motionSensor + }, + can_handle = is_motion_illuminance +} +defaults.register_for_default_handlers(generic_motion_illuminance, generic_motion_illuminance.supported_capabilities) +return generic_motion_illuminance diff --git a/drivers/SmartThings/zigbee-sensor/src/motion/init.lua b/drivers/SmartThings/zigbee-sensor/src/motion/init.lua new file mode 100755 index 0000000000..e78054d645 --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/src/motion/init.lua @@ -0,0 +1,32 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local defaults = require "st.zigbee.defaults" +local capabilities = require "st.capabilities" + +local is_motion_sensor = function(opts, driver, device) + if device:supports_capability(capabilities.motionSensor) and not device:supports_capability(capabilities.illuminanceMeasurement) then + return true + end +end + +local generic_motion_sensor = { + NAME = "Generic Motion Sensor", + supported_capabilities = { + capabilities.motionSensor + }, + can_handle = is_motion_sensor +} +defaults.register_for_default_handlers(generic_motion_sensor, generic_motion_sensor.supported_capabilities) +return generic_motion_sensor diff --git a/drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua b/drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua new file mode 100644 index 0000000000..32d4762d4b --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua @@ -0,0 +1,1015 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local dkjson = require 'dkjson' +local utils = require "st.utils" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local IlluminanceMeasurement = clusters.IlluminanceMeasurement +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus +local ZoneTypeAttribute = IASZone.attributes.ZoneType + +local ZONETYPE = "ZoneType" +local Contact_Switch = 21 -- 0x0015 +local Motion_Sensor = 13 -- 0x000d +local Water_Sensor = 42 -- 0x002a +local ZIGBEE_GENERIC_SENSOR_PROFILE = "generic-sensor" +local ZIGBEE_GENERIC_CONTACT_SENSOR_PROFILE = "generic-contact-sensor" +local ZIGBEE_GENERIC_MOTION_SENSOR_PROFILE = "generic-motion-sensor" +local ZIGBEE_GENERIC_WATERLEAK_SENSOR_PROFILE = "generic-waterleak-sensor" +local ZIGBEE_GENERIC_MOTION_ILLUMINANCE_PROFILE = "generic-motion-illuminance" + +local mock_device_generic_sensor = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition(ZIGBEE_GENERIC_SENSOR_PROFILE .. ".yml"), + zigbee_endpoints = { + [1] = { + id = 1, + server_clusters = { 0x0500, 0x0001 } + } + } + } +) + +local mock_device_contact_sensor = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition(ZIGBEE_GENERIC_CONTACT_SENSOR_PROFILE .. ".yml"), + zigbee_endpoints = { + [1] = { + id = 1, + server_clusters = { 0x0500, 0x0001 } + } + } + } +) + +local mock_device_motion_sensor = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition(ZIGBEE_GENERIC_MOTION_SENSOR_PROFILE .. ".yml"), + zigbee_endpoints = { + [1] = { + id = 1, + server_clusters = { 0x0500, 0x0001 } + } + } + } +) + +local mock_device_waterleak_sensor = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition(ZIGBEE_GENERIC_WATERLEAK_SENSOR_PROFILE .. ".yml"), + zigbee_endpoints = { + [1] = { + id = 1, + server_clusters = { 0x0500, 0x0001 } + } + } + } +) + +local mock_device_motion_illuminance = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition(ZIGBEE_GENERIC_MOTION_ILLUMINANCE_PROFILE .. ".yml"), + zigbee_endpoints = { + [1] = { + id = 1, + server_clusters = { 0x0500, 0x0001 } + }, + [2] = { + id = 2, + server_clusters = { 0x0400 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + mock_device_contact_sensor:set_field(ZONETYPE, Contact_Switch, { persist = true }) + mock_device_motion_sensor:set_field(ZONETYPE, Motion_Sensor, { persist = true }) + mock_device_waterleak_sensor:set_field(ZONETYPE, Water_Sensor, { persist = true }) + test.mock_device.add_test_device(mock_device_contact_sensor) + test.mock_device.add_test_device(mock_device_motion_sensor) + test.mock_device.add_test_device(mock_device_waterleak_sensor) + test.mock_device.add_test_device(mock_device_generic_sensor) + test.mock_device.add_test_device(mock_device_motion_illuminance)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Profile should change to Contact Sensor", + function () + test.socket.zigbee:__queue_receive({mock_device_generic_sensor.id, ZoneTypeAttribute:build_test_attr_report(mock_device_generic_sensor, 0x0015)}) + mock_device_generic_sensor:expect_metadata_update({profile = ZIGBEE_GENERIC_CONTACT_SENSOR_PROFILE}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Profile should change to Motion Sensor", + + function () + test.socket.zigbee:__queue_receive({mock_device_generic_sensor.id, ZoneTypeAttribute:build_test_attr_report(mock_device_generic_sensor, 0x000d)}) + mock_device_generic_sensor:expect_metadata_update({profile = ZIGBEE_GENERIC_MOTION_SENSOR_PROFILE}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Profile should change to Motion Illuminance", + function () + test.socket.zigbee:__queue_receive({mock_device_motion_illuminance.id, ZoneTypeAttribute:build_test_attr_report(mock_device_motion_illuminance, 0x000d)}) + mock_device_motion_illuminance:expect_metadata_update({profile = ZIGBEE_GENERIC_MOTION_ILLUMINANCE_PROFILE}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Profile should change to Waterleak Sensor", + function () + test.socket.zigbee:__queue_receive({mock_device_generic_sensor.id, ZoneTypeAttribute:build_test_attr_report(mock_device_generic_sensor, 0x002a)}) + mock_device_generic_sensor:expect_metadata_update({profile = ZIGBEE_GENERIC_WATERLEAK_SENSOR_PROFILE}) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled (contact sensor)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_contact_sensor.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_contact_sensor, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_contact_sensor:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled (motion sensor)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_sensor.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_motion_sensor, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_sensor:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled (motion illuminance)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_illuminance.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_motion_illuminance, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_illuminance:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled (water leak sensor)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_waterleak_sensor.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_waterleak_sensor, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported contact should be handled: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_contact_sensor.id, ZoneStatusAttribute:build_test_attr_report(mock_device_contact_sensor, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported contact should be handled: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_contact_sensor.id, ZoneStatusAttribute:build_test_attr_report(mock_device_contact_sensor, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from contact sensor should be handled: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_contact_sensor.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_contact_sensor, + 0x0001, + 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from contact sensor should be handled: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_contact_sensor.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_contact_sensor, + 0x0000, + 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported motion should be handled: active", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_sensor.id, ZoneStatusAttribute:build_test_attr_report(mock_device_motion_sensor, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported motion should be handled: inactive", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_sensor.id, ZoneStatusAttribute:build_test_attr_report(mock_device_motion_sensor, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from motion sensor should be handled: active", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_sensor.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_motion_sensor, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from motion sensor should be handled: inactive", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_sensor.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_motion_sensor, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported motion illuminance should be handled: active", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_illuminance.id, ZoneStatusAttribute:build_test_attr_report(mock_device_motion_illuminance, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported motion illuminance should be handled: inactive", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_illuminance.id, ZoneStatusAttribute:build_test_attr_report(mock_device_motion_illuminance, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Illuminance report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device_motion_illuminance.id, + IlluminanceMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device_motion_illuminance, 21370) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_illuminance:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from motion illuminance should be handled: active", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_illuminance.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_motion_illuminance, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from motion illuminance should be handled: inactive", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_motion_illuminance.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_motion_illuminance, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported water should be handled: wet", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_waterleak_sensor.id, ZoneStatusAttribute:build_test_attr_report(mock_device_waterleak_sensor, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported water should be handled: dry", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_waterleak_sensor.id, ZoneStatusAttribute:build_test_attr_report(mock_device_waterleak_sensor, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from waterleak sensor should be handled: wet", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_waterleak_sensor.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_waterleak_sensor, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification from waterleak sensor should be handled: dry", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_waterleak_sensor.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device_waterleak_sensor, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) + } + }, + { + min_api_version = 17 + } +) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes(contact)", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device_contact_sensor.id, "added" }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_contact_sensor.id, +-- ZoneTypeAttribute:read(mock_device_contact_sensor) +-- }) +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device_contact_sensor.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_contact_sensor) }) +-- test.wait_for_events() +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device_contact_sensor) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes(motion)", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device_motion_sensor.id, "added" }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_motion_sensor.id, +-- ZoneTypeAttribute:read(mock_device_motion_sensor) +-- }) +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device_motion_sensor.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_motion_sensor) }) +-- test.wait_for_events() +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device_motion_sensor) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes(motion illuminance)", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device_motion_illuminance.id, "added" }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_motion_illuminance.id, +-- ZoneTypeAttribute:read(mock_device_motion_illuminance) +-- }) +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device_motion_illuminance.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_motion_illuminance) }) +-- test.wait_for_events() +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device_motion_illuminance) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes(waterleak)", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device_waterleak_sensor.id, "added" }) +-- test.socket.zigbee:__expect_send({ +-- mock_device_waterleak_sensor.id, +-- ZoneTypeAttribute:read(mock_device_waterleak_sensor) +-- }) +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device_waterleak_sensor.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_waterleak_sensor) }) +-- test.wait_for_events() +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device_waterleak_sensor) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +test.register_coroutine_test( + "Refresh necessary attributes(contact)", + function() + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_contact_sensor.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send( + { + mock_device_contact_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_contact_sensor) + } + ) + test.socket.zigbee:__expect_send({ mock_device_contact_sensor.id, ZoneStatusAttribute:read(mock_device_contact_sensor) }) + + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes(motion)", + function() + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_motion_sensor.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send( + { + mock_device_motion_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_motion_sensor) + } + ) + test.socket.zigbee:__expect_send({ mock_device_motion_sensor.id, ZoneStatusAttribute:read(mock_device_motion_sensor) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes(motion illuminance)", + function() + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_motion_illuminance.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send( + { + mock_device_motion_illuminance.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_motion_illuminance) + } + ) + test.socket.zigbee:__expect_send({ mock_device_motion_illuminance.id, ZoneStatusAttribute:read(mock_device_motion_illuminance) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes(waterleak)", + function() + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_waterleak_sensor.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send( + { + mock_device_waterleak_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_waterleak_sensor) + } + ) + test.socket.zigbee:__expect_send({ mock_device_waterleak_sensor.id, ZoneStatusAttribute:read(mock_device_waterleak_sensor) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes(contact)", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_contact_sensor.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device_contact_sensor.id, + ZoneTypeAttribute:read(mock_device_contact_sensor) + }) + local device_info_copy = utils.deep_copy(mock_device_contact_sensor.raw_st_data) + device_info_copy.profile.id = "generic-contact-sensor" + local device_info_json = dkjson.encode(device_info_copy) + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_contact_sensor.id, "doConfigure" }) + test.socket.zigbee:__expect_send( + { + mock_device_contact_sensor.id, + zigbee_test_utils.build_bind_request(mock_device_contact_sensor, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_contact_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device_contact_sensor, + 30, + 21600, + 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_contact_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_contact_sensor) + }) + test.socket.zigbee:__expect_send({ + mock_device_contact_sensor.id, + IASZone.attributes.IASCIEAddress:write(mock_device_contact_sensor, zigbee_test_utils.mock_hub_eui) + }) + test.socket.zigbee:__expect_send({ + mock_device_contact_sensor.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device_contact_sensor, IasEnrollResponseCode.SUCCESS, 0x00) + }) + + + test.socket.zigbee:__expect_send( + { + mock_device_contact_sensor.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device_contact_sensor, + 30, + 300, + 0) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_contact_sensor.id, + zigbee_test_utils.build_bind_request(mock_device_contact_sensor, + zigbee_test_utils.mock_hub_eui, + IASZone.ID) + } + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_contact_sensor.id, "infoChanged", device_info_json }) + test.socket.zigbee:__expect_send({ mock_device_contact_sensor.id, IASZone.attributes.ZoneStatus:read(mock_device_contact_sensor) }) + + mock_device_contact_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes(motion)", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_motion_sensor.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device_motion_sensor.id, + ZoneTypeAttribute:read(mock_device_motion_sensor) + }) + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_motion_sensor.id, "doConfigure" }) + test.socket.zigbee:__expect_send( + { + mock_device_motion_sensor.id, + zigbee_test_utils.build_bind_request(mock_device_motion_sensor, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_motion_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device_motion_sensor, + 30, + 21600, + 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_motion_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_motion_sensor) + }) + test.socket.zigbee:__expect_send({ + mock_device_motion_sensor.id, + IASZone.attributes.IASCIEAddress:write(mock_device_motion_sensor, zigbee_test_utils.mock_hub_eui) + }) + test.socket.zigbee:__expect_send({ + mock_device_motion_sensor.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device_motion_sensor, IasEnrollResponseCode.SUCCESS, 0x00) + }) + test.socket.zigbee:__expect_send( + { + mock_device_motion_sensor.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device_motion_sensor, + 30, + 300, + 0) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_motion_sensor.id, + zigbee_test_utils.build_bind_request(mock_device_motion_sensor, + zigbee_test_utils.mock_hub_eui, + IASZone.ID) + } + ) + mock_device_motion_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes(motion illuminance)", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_motion_illuminance.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device_motion_illuminance.id, + ZoneTypeAttribute:read(mock_device_motion_illuminance) + }) + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_motion_illuminance.id, "doConfigure" }) + test.socket.zigbee:__expect_send( + { + mock_device_motion_illuminance.id, + zigbee_test_utils.build_bind_request(mock_device_motion_illuminance, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_motion_illuminance.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device_motion_illuminance, + 30, + 21600, + 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_motion_illuminance.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_motion_illuminance) + }) + test.socket.zigbee:__expect_send({ + mock_device_motion_illuminance.id, + IASZone.attributes.IASCIEAddress:write(mock_device_motion_illuminance, zigbee_test_utils.mock_hub_eui) + }) + test.socket.zigbee:__expect_send({ + mock_device_motion_illuminance.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device_motion_illuminance, IasEnrollResponseCode.SUCCESS, 0x00) + }) + test.socket.zigbee:__expect_send( + { + mock_device_motion_illuminance.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device_motion_illuminance, + 30, + 300, + 0) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_motion_illuminance.id, + zigbee_test_utils.build_bind_request(mock_device_motion_illuminance, + zigbee_test_utils.mock_hub_eui, + IASZone.ID) + } + ) + mock_device_motion_illuminance:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes(waterleak)", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_waterleak_sensor.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device_waterleak_sensor.id, + ZoneTypeAttribute:read(mock_device_waterleak_sensor) + }) + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_waterleak_sensor.id, "doConfigure" }) + test.socket.zigbee:__expect_send( + { + mock_device_waterleak_sensor.id, + zigbee_test_utils.build_bind_request(mock_device_waterleak_sensor, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_waterleak_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device_waterleak_sensor, + 30, + 21600, + 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_waterleak_sensor.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_waterleak_sensor) + } + ) + test.socket.zigbee:__expect_send({ + mock_device_waterleak_sensor.id, + IASZone.attributes.IASCIEAddress:write(mock_device_waterleak_sensor, zigbee_test_utils.mock_hub_eui) + }) + test.socket.zigbee:__expect_send({ + mock_device_waterleak_sensor.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device_waterleak_sensor, IasEnrollResponseCode.SUCCESS, 0x00) + }) + test.socket.zigbee:__expect_send( + { + mock_device_waterleak_sensor.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device_waterleak_sensor, + 30, + 300, + 0) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device_waterleak_sensor.id, + zigbee_test_utils.build_bind_request(mock_device_waterleak_sensor, + zigbee_test_utils.mock_hub_eui, + IASZone.ID) + } + ) + mock_device_waterleak_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-sensor/src/waterleak/init.lua b/drivers/SmartThings/zigbee-sensor/src/waterleak/init.lua new file mode 100755 index 0000000000..0f35cbf3b7 --- /dev/null +++ b/drivers/SmartThings/zigbee-sensor/src/waterleak/init.lua @@ -0,0 +1,32 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local defaults = require "st.zigbee.defaults" +local capabilities = require "st.capabilities" + +local is_water_sensor = function(opts, driver, device) + if device:supports_capability(capabilities.waterSensor) then + return true + end +end + +local generic_water_sensor = { + NAME = "Generic Water Sensor", + supported_capabilities = { + capabilities.waterSensor + }, + can_handle = is_water_sensor +} +defaults.register_for_default_handlers(generic_water_sensor, generic_water_sensor.supported_capabilities) +return generic_water_sensor diff --git a/drivers/SmartThings/zigbee-siren/fingerprints.yml b/drivers/SmartThings/zigbee-siren/fingerprints.yml index d40b7efb9a..3361b8b717 100644 --- a/drivers/SmartThings/zigbee-siren/fingerprints.yml +++ b/drivers/SmartThings/zigbee-siren/fingerprints.yml @@ -4,18 +4,33 @@ zigbeeManufacturer : manufacturer: ClimaxTechnology model: SRAC_00.00.00.16TC deviceProfileName: switch-alarm-generic-siren-7 + - id: "frient/SIRZB-110" + deviceLabel: frient Smart Siren + manufacturer: frient A/S + model: SIRZB-110 + deviceProfileName: frient-siren-battery-source-tamper + - id: "frient/SIRZB-111" + deviceLabel: frient Smart Siren + manufacturer: frient A/S + model: SIRZB-111 + deviceProfileName: frient-siren-battery-source + - id: "frient/SIRZB-112" + deviceLabel: frient Smart Siren + manufacturer: frient A/S + model: SIRZB-112 + deviceProfileName: frient-siren-battery-source-tamper - id : Heiman/WarningDevice deviceLabel : HEIMAN Siren manufacturer : Heiman model : WarningDevice deviceProfileName : switch-alarm-generic-siren-7 - - id : frient/SIRZB-110 - deviceLabel : frient Siren - manufacturer : frient A/S - model : SIRZB-110 - deviceProfileName : switch-alarm-generic-siren-7 - id: "Sercomm Corp./SZ-SRN12N" deviceLabel: SmartThings Siren manufacturer: Sercomm Corp. model: SZ-SRN12N deviceProfileName: basic-alarm + - id: "MultIR/MIR-SR100" + deviceLabel: MultiIR Siren MIR-SR100 + manufacturer: MultIR + model: MIR-SR100 + deviceProfileName: switch-alarm-tamper-warningduration-volume-no-fw-update diff --git a/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source-tamper.yml b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source-tamper.yml new file mode 100644 index 0000000000..6c7e991e45 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source-tamper.yml @@ -0,0 +1,53 @@ +name: frient-siren-battery-source-tamper +components: + - id: main + capabilities: + - id: alarm + version: 1 + - id: tone + version: 1 + - id: battery + version: 1 + - id: powerSource + version: 1 + - id: tamperAlert + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren + - id: SirenVolume + capabilities: + - id: mode + version: 1 + - id: SirenVoice + capabilities: + - id: mode + version: 1 + - id: SquawkVolume + capabilities: + - id: mode + version: 1 + - id: SquawkVoice + capabilities: + - id: mode + version: 1 + - id: WarningDuration + capabilities: + - id: mode + version: 1 +preferences: + - title: "Max alarm duration (s)" + name: maxWarningDuration + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 +metadata: + mnmn: SmartThings + vid: SmartThings-smartthings-frient_Siren_Tamper \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source.yml b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source.yml new file mode 100644 index 0000000000..5248db2b54 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source.yml @@ -0,0 +1,51 @@ +name: frient-siren-battery-source +components: + - id: main + capabilities: + - id: alarm + version: 1 + - id: tone + version: 1 + - id: battery + version: 1 + - id: powerSource + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren + - id: SirenVolume + capabilities: + - id: mode + version: 1 + - id: SirenVoice + capabilities: + - id: mode + version: 1 + - id: SquawkVolume + capabilities: + - id: mode + version: 1 + - id: SquawkVoice + capabilities: + - id: mode + version: 1 + - id: WarningDuration + capabilities: + - id: mode + version: 1 +preferences: + - title: "Max alarm duration (s)" + name: maxWarningDuration + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 +metadata: + mnmn: SmartThings + vid: SmartThings-smartthings-frient_Siren \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-siren/profiles/switch-alarm-tamper-warningduration-volume-no-fw-update.yml b/drivers/SmartThings/zigbee-siren/profiles/switch-alarm-tamper-warningduration-volume-no-fw-update.yml new file mode 100644 index 0000000000..5192f236dc --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/profiles/switch-alarm-tamper-warningduration-volume-no-fw-update.yml @@ -0,0 +1,36 @@ +name: switch-alarm-tamper-warningduration-volume-no-fw-update +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: alarm + version: 1 + - id: tamperAlert + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren +preferences: + - title: "警报时长/秒(warning duration/sec)" + name: warningDuration + description: "警报持续时间 单位:秒(warning duration unit:seconds)" + required: false + preferenceType: integer + definition: + minimum: 30 + maximum: 1800 + default: 1800 + - title: "警报音量(siren volume)" + name: sirenVolume + description: "警报音量大小(siren volume)" + required: false + preferenceType: enumeration + definition: + options: + 0: "低(low)" + 1: "中(medium)" + 2: "高(high)" + 3: "最大(max)" + default: 3 diff --git a/drivers/SmartThings/zigbee-siren/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-siren/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-siren/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-siren/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..ef955eecc6 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-SR100" } +} diff --git a/drivers/SmartThings/zigbee-siren/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-siren/src/MultiIR/init.lua new file mode 100644 index 0000000000..c9934936af --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/MultiIR/init.lua @@ -0,0 +1,119 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local data_types = require "st.zigbee.data_types" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local IASWD = zcl_clusters.IASWD +local IASZone = zcl_clusters.IASZone +local IaswdLevel = IASWD.types.IaswdLevel +local SirenConfiguration = IASWD.types.SirenConfiguration +local WarningMode = IASWD.types.WarningMode +local Strobe = IASWD.types.Strobe +local capabilities = require "st.capabilities" +local ALARM_COMMAND = "alarmCommand" +local DEFAULT_MAX_WARNING_DURATION = 1800 +local ALARM_STROBE_DUTY_CYCLE = 40 + +local alarm_command = { + OFF = 0, + SIREN = 1, + STROBE = 2, + BOTH = 3 +} + +local function device_added (driver, device) + device:emit_event(capabilities.switch.switch.off()) + device:emit_event(capabilities.alarm.alarm.off()) + if(device:supports_capability(capabilities.tamperAlert)) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end + device:send(IASWD.attributes.MaxDuration:read(device)) +end + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + if device:supports_capability(capabilities.tamperAlert) then + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function send_siren_command(device, warning_mode, warning_siren_level, warning_duration, strobe_active, strobe_level) + local siren_configuration + + siren_configuration = SirenConfiguration(0x00) + siren_configuration:set_warning_mode(warning_mode) + siren_configuration:set_siren_level(warning_siren_level) + siren_configuration:set_strobe(strobe_active) + + device:send( + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(ALARM_STROBE_DUTY_CYCLE), + data_types.Enum8(strobe_level) + ) + ) +end + +local function siren_switch_off_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.OFF, {persist = true}) + send_siren_command(device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) +end + +local function siren_alarm_siren_handler(alarm_cmd, WarningMode, Strobe, strobe_level) + return function(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_cmd, {persist = true}) + + local sirenVolume_msg = tonumber(device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + + send_siren_command(device, WarningMode, sirenVolume_msg, warning_duration, Strobe, strobe_level) + + device.thread:call_with_delay(warning_duration, function() -- Send command to switch from siren to off in the app when the siren is done + if(device:get_field(ALARM_COMMAND) ~= alarm_command.OFF) then + siren_switch_off_handler(driver, device, alarm_cmd) + end + end) + end +end + +local MultiIR_siren_driver = { + NAME = "MultiIR Zigbee siren", + lifecycle_handlers = { + added = device_added, + }, + capability_handlers = { + [capabilities.alarm.ID] = { + [capabilities.alarm.commands.off.NAME] = siren_switch_off_handler, + [capabilities.alarm.commands.siren.NAME] = siren_alarm_siren_handler(alarm_command.SIREN, WarningMode.BURGLAR, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL), + [capabilities.alarm.commands.both.NAME] = siren_alarm_siren_handler(alarm_command.BOTH, WarningMode.BURGLAR, Strobe.USE_STROBE , IaswdLevel.VERY_HIGH_LEVEL), + [capabilities.alarm.commands.strobe.NAME] = siren_alarm_siren_handler(alarm_command.STROBE, WarningMode.STOP, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = siren_alarm_siren_handler(alarm_command.BOTH, WarningMode.BURGLAR, Strobe.USE_STROBE , IaswdLevel.VERY_HIGH_LEVEL), + [capabilities.switch.commands.off.NAME] = siren_switch_off_handler + } + }, + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status + } + } + }, + can_handle = require("MultiIR.can_handle"), +} + +return MultiIR_siren_driver diff --git a/drivers/SmartThings/zigbee-siren/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-siren/src/frient/can_handle.lua new file mode 100644 index 0000000000..ed6acf5b42 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/frient/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and + (device:get_model() == "SIRZB-110" or + device:get_model() == "SIRZB-111" or + device:get_model() == "SIRZB-112") + then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-siren/src/frient/init.lua b/drivers/SmartThings/zigbee-siren/src/frient/init.lua index 085b8ecd79..d408579696 100644 --- a/drivers/SmartThings/zigbee-siren/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-siren/src/frient/init.lua @@ -1,97 +1,416 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" --ZCL local zcl_clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local Basic = zcl_clusters.Basic local IASWD = zcl_clusters.IASWD -local SirenConfiguration = IASWD.types.SirenConfiguration +local IASZone = zcl_clusters.IASZone local IaswdLevel = IASWD.types.IaswdLevel +local SirenConfiguration = IASWD.types.SirenConfiguration +local SquawkConfiguration = IASWD.types.SquawkConfiguration +local SquawkMode = IASWD.types.SquawkMode +local WarningMode = IASWD.types.WarningMode +local PowerConfiguration = zcl_clusters.PowerConfiguration --capability local capabilities = require "st.capabilities" local alarm = capabilities.alarm -local switch = capabilities.switch local ALARM_COMMAND = "alarmCommand" -local ALARM_LAST_DURATION = "lastDuration" -local ALARM_MAX_DURATION = "maxDuration" +local ALARM_DURATION = "warningDuration" +local SIREN_FIXED_ENDIAN_SW_VERSION = "010903" -local ALARM_DEFAULT_MAX_DURATION = 0x00B4 -local ALARM_STROBE_DUTY_CYCLE = 00 +local DEFAULT_MAX_WARNING_DURATION = 0x00F0 +local PRIMARY_SW_VERSION = "primary_sw_version" +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local IASZONE_ENDPOINT = 0x2B local alarm_command = { OFF = 0, SIREN = 1, - STROBE = 2, - BOTH = 3 } -local send_siren_command = function(device) - local max_duration = device:get_field(ALARM_MAX_DURATION) - local warning_duration = max_duration and max_duration or ALARM_DEFAULT_MAX_DURATION - local duty_cycle = ALARM_STROBE_DUTY_CYCLE +local IASZone_configuration = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 0, + maximum_interval = 6*60*60, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1 + } +} + +local SQUAWK_VOICE_MAP = { + ["Armed"] = 0, + ["Disarmed"] = 1 +} + +local VOLUME_MAP = { + ["Low"] = 0, + ["Medium"] = 1, + ["High"] = 2, + ["Very High"] = 3 +} +local SIREN_VOICE_MAP = { + ["Burglar"] = 1, + ["Fire"] = 2, + ["Emergency"] = 3, + ["Panic"] = 4, + ["Panic Fire"] = 5, + ["Panic Emergency"] = 6 +} - device:set_field(ALARM_LAST_DURATION, warning_duration, {persist = true}) +local WARNING_DURATION_MAP = { + ["5 seconds"] = 5, + ["10 seconds"] = 10, + ["15 seconds"] = 15, + ["20 seconds"] = 20, + ["25 seconds"] = 25, + ["30 seconds"] = 30, + ["40 seconds"] = 40, + ["50 seconds"] = 50, + ["1 minute"] = 60, + ["2 minutes"] = 120, + ["3 minutes"] = 180, + ["4 minutes"] = 240, + ["5 minutes"] = 300, + ["10 minutes"] = 600 +} - local siren_configuration = SirenConfiguration(0xC1) +local MODEL_DEVICE_PROFILE_MAP = { + ["SIRZB-110"] = "frient-siren-battery-source-tamper", + ["SIRZB-111"] = "frient-siren-battery-source" +} - device:send( - IASWD.server.commands.StartWarning( - device, - siren_configuration, - data_types.Uint16(warning_duration), - data_types.Uint8(duty_cycle), - data_types.Enum8(IaswdLevel.LOW_LEVEL) +local BATTERY_CONFIG_APPLIED_KEY = "_frient_battery_config_applied" + +local function get_current_max_warning_duration(device) + return device.preferences.maxWarningDuration == nil and DEFAULT_MAX_WARNING_DURATION or device.preferences.maxWarningDuration +end + +local function get_warning_duration(device) + -- User may select one of predefine modes on the UI or input duration in preferences + -- every time ALARM_DURATION is updated + local selected_duration = device:get_field(ALARM_DURATION) + local current_max_warning_duration = get_current_max_warning_duration(device) + + local warning_duration + if selected_duration == nil or selected_duration > current_max_warning_duration then + warning_duration = current_max_warning_duration + else + warning_duration = selected_duration + end + return warning_duration +end + +local function configure_battery_handling_based_on_fw(driver, device) + local sw_version = device:get_field(PRIMARY_SW_VERSION) + local applied_state = device:get_field(BATTERY_CONFIG_APPLIED_KEY) + + if sw_version and sw_version < SIREN_FIXED_ENDIAN_SW_VERSION then + if applied_state ~= "voltage" then + -- Old firmware - does not support BatteryPercentageRemaining attribute, use battery defaults (voltage-based) + battery_defaults.build_linear_voltage_init(3.3, 4.1)(driver, device) + device:set_field(BATTERY_CONFIG_APPLIED_KEY, "voltage", { persist = true }) + return true + end + else + if applied_state ~= "percentage" then + -- New firmware - supports BatteryPercentageRemaining, remove voltage monitoring + device:remove_configured_attribute(PowerConfiguration.ID, PowerConfiguration.attributes.BatteryVoltage.ID) + device:remove_monitored_attribute(PowerConfiguration.ID, PowerConfiguration.attributes.BatteryVoltage.ID) + device:set_field(BATTERY_CONFIG_APPLIED_KEY, "percentage", { persist = true }) + return true + end + end + + return false +end + +local function device_init(driver, device) + for _, attribute in ipairs(IASZone_configuration) do + device:add_configured_attribute(attribute) + device:add_monitored_attribute(attribute) + end +end + +local function device_added (driver, device) + for comp_name, comp in pairs(device.profile.components) do + if comp_name ~= "main" then + if comp_name == "SirenVoice" then + device:emit_component_event(comp, capabilities.mode.supportedModes({"Burglar", "Fire", "Emergency", "Panic","Panic Fire","Panic Emergency" }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({"Burglar", "Fire", "Emergency", "Panic","Panic Fire","Panic Emergency" }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("Burglar")) + elseif comp_name == "SquawkVoice" then + device:emit_component_event(comp, capabilities.mode.supportedModes({"Armed", "Disarmed"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({"Armed", "Disarmed"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("Armed")) + elseif comp_name == "WarningDuration" then + device:emit_component_event(comp, capabilities.mode.supportedModes({ + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({ + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("4 minutes")) + else + device:emit_component_event(comp, capabilities.mode.supportedModes({"Low", "Medium", "High", "Very High"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({"Low", "Medium", "High", "Very High"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("Very High")) + end + end + end + + device:emit_event(capabilities.alarm.alarm.off()) + + if(device:supports_capability(capabilities.tamperAlert)) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function do_refresh(driver, device) + device:refresh() + device:send(IASZone.attributes.ZoneStatus:read(device):to_endpoint(IASZONE_ENDPOINT)) + + -- Check if we have the software version + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + end +end + +local function do_configure(driver, device) + local maxWarningDuration = get_current_max_warning_duration(device) + device:set_field(ALARM_DURATION, maxWarningDuration , { persist = true}) + device:send(IASWD.attributes.MaxDuration:write(device, maxWarningDuration):to_endpoint(0x2B)) + + -- Check if we have the software version + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + else + configure_battery_handling_based_on_fw(driver, device) + end + + -- handle frient sirens that were already connected and using old device profile + if (not device:supports_capability_by_id(capabilities.mode.ID)) then + device:try_update_metadata({profile = MODEL_DEVICE_PROFILE_MAP[device:get_model()]}) + end + + device.thread:call_with_delay(5, function() + do_refresh(driver, device) + end) + device:configure() +end + +local function primary_sw_version_attr_handler(driver, device, value, zb_rx) + local primary_sw_version = value.value:gsub('.', function (c) return string.format('%02x', string.byte(c)) end) + device:set_field(PRIMARY_SW_VERSION, primary_sw_version, {persist = true}) + local config_changed = configure_battery_handling_based_on_fw(driver, device) + if config_changed then + device.thread:call_with_delay(1, function() + device:configure() + end) + end + device.thread:call_with_delay(config_changed and 2 or 1, function() + do_refresh(driver, device) + end) +end + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + if device:supports_capability(capabilities.tamperAlert) then + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear() ) + end + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + zone_status:is_ac_mains_fault_set() and capabilities.powerSource.powerSource.battery() or capabilities.powerSource.powerSource.mains() + ) +end + +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function send_siren_command(device, warning_mode, warning_siren_level) + -- Check if we have the software version first + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + end + + local warning_duration = get_warning_duration(device) + + local siren_configuration + + if (sw_version and sw_version < SIREN_FIXED_ENDIAN_SW_VERSION) then + -- Old frient firmware, the endian format is reversed + local siren_config_value = (warning_siren_level << 6) | warning_mode + siren_configuration = SirenConfiguration(siren_config_value) + else + siren_configuration = SirenConfiguration(0x00) + siren_configuration:set_warning_mode(warning_mode) + siren_configuration:set_siren_level(warning_siren_level) + end + + device:send( + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) ) end -local siren_switch_both_handler = function(driver, device, command) - device:set_field(ALARM_COMMAND, alarm_command.BOTH, {persist = true}) - send_siren_command(device) +local function siren_switch_off_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.OFF, {persist = true}) + send_siren_command(device, WarningMode.STOP, IaswdLevel.LOW_LEVEL) end -local siren_alarm_siren_handler = function(driver, device, command) +local function siren_alarm_siren_handler(driver, device, command) device:set_field(ALARM_COMMAND, alarm_command.SIREN, {persist = true}) - send_siren_command(device) + + -- delay is needed to allow st automations get updated fields when mode, volume, voice is set sequentially + device.thread:call_with_delay(1, function() + local sirenVoice_msg = device:get_latest_state("SirenVoice", capabilities.mode.ID, capabilities.mode.mode.NAME) + local sirenVolume_msg = device:get_latest_state("SirenVolume", capabilities.mode.ID, capabilities.mode.mode.NAME) + send_siren_command(device,sirenVoice_msg == nil and WarningMode.BURGLAR or SIREN_VOICE_MAP[sirenVoice_msg] , sirenVolume_msg == nil and IaswdLevel.VERY_HIGH_LEVEL or VOLUME_MAP[sirenVolume_msg]) + end) + + local warningDurationDelay = get_warning_duration(device) + + device.thread:call_with_delay(warningDurationDelay, function() -- Send command to switch from siren to off in the app when the siren is done + if(device:get_field(ALARM_COMMAND) == alarm_command.SIREN) then + siren_switch_off_handler(driver, device, command) + end + end) +end + +local function send_squawk_command(device, squawk_mode, squawk_siren_level) + -- Check if we have the software version first + local sw_version = device:get_field(PRIMARY_SW_VERSION) + + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + end + + local squawk_configuration + + if (sw_version and sw_version < SIREN_FIXED_ENDIAN_SW_VERSION) then + -- Old frient firmware, the endian format is reversed + local squawk_config_value = (squawk_siren_level << 6) | squawk_mode + squawk_configuration = SquawkConfiguration(squawk_config_value) + else + squawk_configuration = SquawkConfiguration(0x00) + squawk_configuration:set_squawk_mode(squawk_mode) + squawk_configuration:set_squawk_level(squawk_siren_level) + end + + device:send( + IASWD.server.commands.Squawk( + device, + squawk_configuration + ) + ) +end + +local function siren_tone_beep_handler(driver, device, command) + device.thread:call_with_delay(1, function () + local squawkVolume_msg = device:get_latest_state("SquawkVolume", capabilities.mode.ID, capabilities.mode.mode.NAME) + local squawkVoice_msg = device:get_latest_state("SquawkVoice", capabilities.mode.ID, capabilities.mode.mode.NAME) + + send_squawk_command(device, SQUAWK_VOICE_MAP[squawkVoice_msg] or SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED,VOLUME_MAP[squawkVolume_msg] or IaswdLevel.VERY_HIGH_LEVEL) + end ) end -local siren_alarm_strobe_handler = function(driver, device, command) - device:set_field(ALARM_COMMAND, alarm_command.STROBE, {persist = true}) - send_siren_command(device) +local function info_changed(driver, device, event, args) + for name, info in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + local input = device.preferences[name] + if (name == "maxWarningDuration") then + device:send(IASWD.attributes.MaxDuration:write(device, tonumber(input))) + end + end + end end -local siren_switch_on_handler = function(driver, device, command) - siren_switch_both_handler(driver, device, command) +local function siren_mode_handler(driver, device, command) + local mode_set = command.args.mode + local component = command.component + local compObj = device.profile.components[component] + + if compObj then + if component == "WarningDuration" then + local warning_duration = WARNING_DURATION_MAP[mode_set] or DEFAULT_MAX_WARNING_DURATION + device:set_field(ALARM_DURATION, warning_duration, {persist = true}) + end + end + + device.thread:call_with_delay(2,function() + device:emit_component_event( + compObj, + capabilities.mode.mode(mode_set)) + end) end local frient_siren_driver = { NAME = "frient A/S", + lifecycle_handlers = { + added = device_added, + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed, + }, capability_handlers = { [alarm.ID] = { - [alarm.commands.both.NAME] = siren_switch_both_handler, + [alarm.commands.off.NAME] = siren_switch_off_handler, [alarm.commands.siren.NAME] = siren_alarm_siren_handler, - [alarm.commands.strobe.NAME] = siren_alarm_strobe_handler + [alarm.commands.both.NAME] = siren_alarm_siren_handler + }, + [capabilities.tone.ID] = { + [capabilities.tone.commands.beep.NAME] = siren_tone_beep_handler }, - [switch.ID] = { - [switch.commands.on.NAME] = siren_switch_on_handler, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = siren_mode_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" - end + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, + [Basic.ID] = { + [DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR] = primary_sw_version_attr_handler + } + } + }, + can_handle = require("frient.can_handle"), } -return frient_siren_driver +return frient_siren_driver \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-siren/src/init.lua b/drivers/SmartThings/zigbee-siren/src/init.lua index 5d3fafac39..8e4812c3bd 100644 --- a/drivers/SmartThings/zigbee-siren/src/init.lua +++ b/drivers/SmartThings/zigbee-siren/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -32,7 +22,9 @@ local IaswdLevel = IASWD.types.IaswdLevel local capabilities = require "st.capabilities" local alarm = capabilities.alarm local switch = capabilities.switch - +local mode = capabilities.mode +local battery = capabilities.battery +local refresh = capabilities.refresh -- Constants local ALARM_COMMAND = "alarmCommand" local ALARM_LAST_DURATION = "lastDuration" @@ -81,13 +73,13 @@ local send_siren_command = function(device, warning_mode, warning_siren_level, s siren_configuration:set_siren_level(warning_siren_level) device:send( - IASWD.server.commands.StartWarning( - device, - siren_configuration, - data_types.Uint16(warning_duration), - data_types.Uint8(duty_cycle), - data_types.Enum8(strobe_level) - ) + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(duty_cycle), + data_types.Enum8(strobe_level) + ) ) end @@ -157,7 +149,10 @@ end local zigbee_siren_driver_template = { supported_capabilities = { alarm, - switch + switch, + mode, + battery, + refresh }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, zigbee_handlers = { @@ -189,7 +184,7 @@ local zigbee_siren_driver_template = { added = device_added, doConfigure = do_configure }, - sub_drivers = { require("ozom"), require("frient") }, + sub_drivers = require("sub_drivers"), cluster_configurations = { [alarm.ID] = { { @@ -200,7 +195,9 @@ local zigbee_siren_driver_template = { data_type = IASZone.attributes.ZoneStatus.base_type } } - } + }, + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_siren_driver_template, zigbee_siren_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-siren/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-siren/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-siren/src/ozom/can_handle.lua b/drivers/SmartThings/zigbee-siren/src/ozom/can_handle.lua new file mode 100644 index 0000000000..bec6069a7f --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/ozom/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function ozom_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "ClimaxTechnology" then + return true, require("ozom") + end + return false +end + +return ozom_can_handle diff --git a/drivers/SmartThings/zigbee-siren/src/ozom/init.lua b/drivers/SmartThings/zigbee-siren/src/ozom/init.lua index e4cecc28ab..c17a1c4fb7 100644 --- a/drivers/SmartThings/zigbee-siren/src/ozom/init.lua +++ b/drivers/SmartThings/zigbee-siren/src/ozom/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" --ZCL @@ -75,9 +65,7 @@ local ozom_siren_driver = { [switch.commands.on.NAME] = siren_switch_on_handler } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "ClimaxTechnology" - end + can_handle = require("ozom.can_handle"), } return ozom_siren_driver diff --git a/drivers/SmartThings/zigbee-siren/src/sub_drivers.lua b/drivers/SmartThings/zigbee-siren/src/sub_drivers.lua new file mode 100644 index 0000000000..5f5953b04e --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("frient"), + lazy_load_if_possible("ozom"), + lazy_load_if_possible("MultiIR"), +} diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua index 0323a57e79..4d0486c6fd 100644 --- a/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua +++ b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -15,112 +15,1176 @@ -- Mock out globals local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local OnOff = clusters.OnOff +local Scenes = clusters.Scenes +local Basic = clusters.Basic +local Identify = clusters.Identify +local PowerConfiguration = clusters.PowerConfiguration +local Groups = clusters.Groups +local IASZone = clusters.IASZone local IASWD = clusters.IASWD +local IaswdLevel = IASWD.types.IaswdLevel +local WarningMode = IASWD.types.WarningMode +local SquawkMode = IASWD.types.SquawkMode +local SirenConfiguration = IASWD.types.SirenConfiguration +local SquawkConfiguration = IASWD.types.SquawkConfiguration +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus + +local PRIMARY_SW_VERSION = "primary_sw_version" +local SIREN_ENDIAN = "siren_endian" +local ALARM_DURATION = "warningDuration" +local ALARM_DEFAULT_MAX_DURATION = 0x00F0 +local ALARM_DURATION_TEST_VALUE = 5 +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 + +local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local data_types = require "st.zigbee.data_types" -local SirenConfiguration = require "st.zigbee.generated.zcl_clusters.IASWD.types.SirenConfiguration" local t_utils = require "integration_test.utils" + local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("switch-alarm.yml"), - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "frient A/S", - model = "SIRZB-110", - server_clusters = {0x0502} + { + profile = t_utils.get_profile_definition("frient-siren-battery-source.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "SIRZB-111", + server_clusters = { Scenes.ID, OnOff.ID} + }, + [0x2B] = { + id = 0x2B, + server_clusters = { Basic.ID, Identify.ID, PowerConfiguration.ID, Groups.ID, IASZone.ID, IASWD.ID } + } + } } - } - } ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.mock_device.add_test_device(mock_device) end test.set_test_init_function(test_init) +local function set_new_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_older_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010901", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_no_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function get_siren_commands_new_fw( warningMode, sirenLevel, duration ) + local expectedSirenONConfiguration = SirenConfiguration(0x00) + expectedSirenONConfiguration:set_warning_mode(warningMode) --WarningMode.BURGLAR + expectedSirenONConfiguration:set_siren_level(sirenLevel) --IaswdLevel.VERY_HIGH_LEVEL + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_commands_old_fw(warningMode, sirenLevel) + local expectedSirenONConfiguration + local siren_config_value = (sirenLevel << 6) | warningMode + expectedSirenONConfiguration = SirenConfiguration(siren_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_OFF_commands(duration) + local expectedSirenOFFConfiguration = SirenConfiguration(0x00) + expectedSirenOFFConfiguration:set_warning_mode(WarningMode.STOP) + expectedSirenOFFConfiguration:set_siren_level(IaswdLevel.LOW_LEVEL) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenOFFConfiguration, + data_types.Uint16(duration and duration or ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_squawk_command_new_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration = SquawkConfiguration(0x00) + expected_squawk_configuration:set_squawk_mode(squawk_mode) + expected_squawk_configuration:set_squawk_level(squawk_siren_level) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +local function get_squawk_command_older_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration + local squawk_config_value = (squawk_siren_level << 6) | squawk_mode + expected_squawk_configuration = SquawkConfiguration(squawk_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +test.register_coroutine_test( + "lifecycles - init and doConfigure test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + ALARM_DEFAULT_MAX_DURATION + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting( + mock_device, + 30, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 0, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedModes({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedArguments({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.mode("Burglar") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.mode({value = "Very High"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedModes({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedArguments({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.mode("Armed") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.mode("Very High") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedModes( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedArguments( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.mode("4 minutes") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.alarm.alarm.off() + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE+1) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_older_firmware_and_defaults() + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_old_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Alarm OFF should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(5) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + + test.mock_time.advance_time(5) + -- stop the siren + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - when maxWarningDuration is shorted then selected mode", + function() + local expectedWarningDuration = 7 + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(expectedWarningDuration, "oneshot") + + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed( + { + preferences = { + maxWarningDuration = expectedWarningDuration + } + } + )) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + expectedWarningDuration + ) + }) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"50 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("50 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL, expectedWarningDuration) + + test.mock_time.advance_time(expectedWarningDuration) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands(expectedWarningDuration) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + set_older_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of OLD FW version was reported before", + function() + set_older_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should be handled - new FW", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should be handled - FW not known", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ) + } + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Power source / mains should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + test.register_message_test( - "Capability(switch) command(on) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "switch", command = "on", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Power source / battery and tamper clear should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0081) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability(alarm) command(both) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "alarm", component = "main", command = "both", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0xC8) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability(alarm) command(siren) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "alarm", component = "main", command = "siren", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Medium battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0x64) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability(alarm) command(strobe) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "alarm", component = "main", command = "strobe", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +local function build_sw_version_attr_report(device, fw_bytes) + local zcl_messages_mod = require "st.zigbee.zcl" + local messages_mod = require "st.zigbee.messages" + local zb_const_mod = require "st.zigbee.constants" + local report_attr = require "st.zigbee.zcl.global_commands.report_attribute" + local zcl_cmds_mod = require "st.zigbee.zcl.global_commands" + + local attr_record = report_attr.ReportAttributeAttributeRecord( + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + data_types.CharString.ID, + fw_bytes + ) + local report_body = report_attr.ReportAttribute({ attr_record }) + local zclh = zcl_messages_mod.ZclHeader({ + cmd = data_types.ZCLCommandId(zcl_cmds_mod.REPORT_ATTRIBUTE_ID) + }) + local addrh = messages_mod.AddressHeader( + device:get_short_address(), + device:get_endpoint(Basic.ID), + zb_const_mod.HUB.ADDR, + zb_const_mod.HUB.ENDPOINT, + zb_const_mod.HA_PROFILE_ID, + Basic.ID + ) + local message_body = zcl_messages_mod.ZclMessageBody({ zcl_header = zclh, zcl_body = report_body }) + return messages_mod.ZigbeeMessageRx({ address_header = addrh, body = message_body }) +end + +test.register_coroutine_test( + "SW version attr handler with new firmware should configure battery percentage", + function() + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field("_frient_battery_config_applied", nil, {persist = true}) + + -- Binary "\x01\x09\x03" -> hex "010903" (new firmware >= SIREN_FIXED_ENDIAN_SW_VERSION) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_sw_version_attr_report(mock_device, "\x01\x09\x03") + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SW version attr handler with old firmware should configure battery voltage", + function() + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field("_frient_battery_config_applied", nil, {persist = true}) + + -- Binary "\x01\x09\x01" -> hex "010901" (old firmware < SIREN_FIXED_ENDIAN_SW_VERSION) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_sw_version_attr_report(mock_device, "\x01\x09\x01") + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doConfigure with existing sw_version should call configure_battery_handling", + function() + mock_device:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device:set_field("_frient_battery_config_applied", nil, {persist = true}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, ALARM_DEFAULT_MAX_DURATION) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting( + mock_device, 30, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 0, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren_tamper.lua b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren_tamper.lua new file mode 100644 index 0000000000..70854ca7e7 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren_tamper.lua @@ -0,0 +1,1142 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local OnOff = clusters.OnOff +local Scenes = clusters.Scenes +local Basic = clusters.Basic +local Identify = clusters.Identify +local PowerConfiguration = clusters.PowerConfiguration +local Groups = clusters.Groups +local IASZone = clusters.IASZone +local IASWD = clusters.IASWD +local IaswdLevel = IASWD.types.IaswdLevel +local WarningMode = IASWD.types.WarningMode +local SquawkMode = IASWD.types.SquawkMode +local SirenConfiguration = IASWD.types.SirenConfiguration +local SquawkConfiguration = IASWD.types.SquawkConfiguration +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus + +local PRIMARY_SW_VERSION = "primary_sw_version" +local SIREN_ENDIAN = "siren_endian" +local ALARM_DURATION = "warningDuration" +local ALARM_DEFAULT_MAX_DURATION = 0x00F0 +local ALARM_DURATION_TEST_VALUE = 5 +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 + +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" + + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-siren-battery-source-tamper.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "SIRZB-111", + server_clusters = { Scenes.ID, OnOff.ID} + }, + [0x2B] = { + id = 0x2B, + server_clusters = { Basic.ID, Identify.ID, PowerConfiguration.ID, Groups.ID, IASZone.ID, IASWD.ID } + } + } + } +) + +local mock_device_112 = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-siren-battery-source-tamper.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "SIRZB-112", + server_clusters = { Scenes.ID, OnOff.ID} + }, + [0x2B] = { + id = 0x2B, + server_clusters = { Basic.ID, Identify.ID, PowerConfiguration.ID, Groups.ID, IASZone.ID, IASWD.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +local function set_new_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_older_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010901", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_no_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function get_siren_commands_new_fw(warningMode, sirenLevel, duration) + local expectedSirenONConfiguration = SirenConfiguration(0x00) + expectedSirenONConfiguration:set_warning_mode(warningMode) --WarningMode.BURGLAR + expectedSirenONConfiguration:set_siren_level(sirenLevel) --IaswdLevel.VERY_HIGH_LEVEL + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_commands_old_fw(warningMode, sirenLevel) + local expectedSirenONConfiguration + local siren_config_value = (sirenLevel << 6) | warningMode + expectedSirenONConfiguration = SirenConfiguration(siren_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_OFF_commands(duration) + local expectedSirenOFFConfiguration = SirenConfiguration(0x00) + expectedSirenOFFConfiguration:set_warning_mode(WarningMode.STOP) + expectedSirenOFFConfiguration:set_siren_level(IaswdLevel.LOW_LEVEL) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenOFFConfiguration, + data_types.Uint16(duration and duration or ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_squawk_command_new_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration = SquawkConfiguration(0x00) + expected_squawk_configuration:set_squawk_mode(squawk_mode) + expected_squawk_configuration:set_squawk_level(squawk_siren_level) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +local function get_squawk_command_older_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration + local squawk_config_value = (squawk_siren_level << 6) | squawk_mode + expected_squawk_configuration = SquawkConfiguration(squawk_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +test.register_coroutine_test( + "lifecycles - init and doConfigure test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + ALARM_DEFAULT_MAX_DURATION + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting( + mock_device, + 30, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 0, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedModes({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedArguments({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.mode("Burglar") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.mode({value = "Very High"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedModes({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedArguments({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.mode("Armed") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.mode("Very High") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedModes( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedArguments( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.mode("4 minutes") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.alarm.alarm.off() + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.tamperAlert.tamper.clear() + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_older_firmware_and_defaults() + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_old_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Alarm OFF should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(5) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + + test.mock_time.advance_time(5) + -- stop the siren + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - when maxWarningDuration is shorted then selected mode", + function() + local expectedWarningDuration = 7 + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(expectedWarningDuration, "oneshot") + + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed( + { + preferences = { + maxWarningDuration = expectedWarningDuration + } + } + )) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + expectedWarningDuration + ) + }) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"50 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("50 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL, expectedWarningDuration) + + test.mock_time.advance_time(expectedWarningDuration) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands(expectedWarningDuration) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + set_older_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of OLD FW version was reported before", + function() + set_older_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should be handled - new FW", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should be handled - FW not known", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ) + } + ) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Power source / mains should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Power source / battery should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0081) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0xC8) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Medium battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0x64) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 17 + } +) + +local function get_siren_OFF_commands_112(duration) + local expectedSirenOFFConfiguration = SirenConfiguration(0x00) + expectedSirenOFFConfiguration:set_warning_mode(WarningMode.STOP) + expectedSirenOFFConfiguration:set_siren_level(IaswdLevel.LOW_LEVEL) + + test.socket.zigbee:__expect_send({ + mock_device_112.id, + IASWD.server.commands.StartWarning( + mock_device_112, + expectedSirenOFFConfiguration, + data_types.Uint16(duration and duration or ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +test.register_coroutine_test( + "SIRZB-112 alarm off command should be handled via frient sub-driver", + function() + mock_device_112:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device_112:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) + + test.socket.capability:__queue_receive({ + mock_device_112.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + get_siren_OFF_commands_112() + test.wait_for_events() + end, + { test_init = function() + test.mock_device.add_test_device(mock_device_112) + end, + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_multiir_zigbee_siren_tamper.lua b/drivers/SmartThings/zigbee-siren/src/test/test_multiir_zigbee_siren_tamper.lua new file mode 100644 index 0000000000..24dc8766f3 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/test/test_multiir_zigbee_siren_tamper.lua @@ -0,0 +1,312 @@ +-- Copyright 2026 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local OnOff = clusters.OnOff +local IASZone = clusters.IASZone +local IASWD = clusters.IASWD +local SirenConfiguration = IASWD.types.SirenConfiguration +local DEFAULT_MAX_WARNING_DURATION = 1800 +local ALARM_STROBE_DUTY_CYCLE = 40 +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" + +local IaswdLevel = IASWD.types.IaswdLevel +local WarningMode = IASWD.types.WarningMode +local Strobe = IASWD.types.Strobe + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("switch-alarm-tamper-warningduration-volume-no-fw-update.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "MultIR", + model = "MIR-SR100", + server_clusters = { IASWD.ID, IASZone.ID,OnOff.ID} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.switch.switch.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tamperAlert.tamper.clear())) + test.socket.zigbee:__expect_send({ mock_device.id, + IASWD.attributes.MaxDuration:read(mock_device)}) + end, + { + min_api_version = 19 + } +) + +local function send_startWarning( device, WarningMode, sirenVolume_msg, warning_duration, strobe_active, strobe_level ) + local expectedSirenONConfiguration = SirenConfiguration(0x00) + expectedSirenONConfiguration:set_warning_mode(WarningMode) --WarningMode.BURGLAR + expectedSirenONConfiguration:set_siren_level(sirenVolume_msg) --IaswdLevel.VERY_HIGH_LEVEL + expectedSirenONConfiguration:set_strobe(strobe_active) + + test.socket.zigbee:__expect_send({ + device.id, + IASWD.server.commands.StartWarning( + device, + expectedSirenONConfiguration, + data_types.Uint16(warning_duration), + data_types.Uint8(ALARM_STROBE_DUTY_CYCLE), + data_types.Enum8(strobe_level) + ) + }) +end + +test.register_coroutine_test( + "Capability(switch) command(on) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.BURGLAR, sirenVolume_msg, warning_duration, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(switch) command(off) should be handled", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } + }) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(both) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "both", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.BURGLAR, sirenVolume_msg, warning_duration, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(off) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(siren) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.BURGLAR, sirenVolume_msg, warning_duration, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(strobe) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "strobe", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.STOP, sirenVolume_msg, warning_duration, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0004, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua b/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua index f992686439..f7b13d4895 100644 --- a/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua +++ b/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -38,9 +38,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -62,6 +60,9 @@ test.register_message_test( data_types.Enum8(0)) } } + }, + { + min_api_version = 17 } ) @@ -96,7 +97,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -120,7 +124,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua b/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua index e8de7eb958..3750412d19 100644 --- a/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua +++ b/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua @@ -19,6 +19,7 @@ local zcl_cmds = require "st.zigbee.zcl.global_commands" local IASZone = clusters.IASZone local IASWD = clusters.IASWD local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local data_types = require "st.zigbee.data_types" @@ -66,6 +67,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(3)) } } + }, + { + min_api_version = 17 } ) @@ -86,6 +90,9 @@ test.register_message_test( data_types.Uint8(0x28), data_types.Enum8(0)) } } + }, + { + min_api_version = 17 } ) @@ -106,6 +113,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(3)) } } + }, + { + min_api_version = 17 } ) @@ -126,6 +136,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(0)) } } + }, + { + min_api_version = 17 } ) @@ -146,6 +159,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(0)) } } + }, + { + min_api_version = 17 } ) @@ -166,6 +182,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(3)) } } + }, + { + min_api_version = 17 } ) @@ -212,9 +231,26 @@ test.register_coroutine_test( mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -235,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -265,9 +304,18 @@ test.register_message_test( OnOff.attributes.OnOff:read(mock_device) } }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -314,7 +362,76 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.siren())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 17 + } +) + +local function build_default_response_zigbee_msg() + local zcl_messages = require "st.zigbee.zcl" + local messages = require "st.zigbee.messages" + local zb_const = require "st.zigbee.constants" + local buf_lib = require "st.buf" + local buf_from_str = function(str) return buf_lib.Reader(str) end + local frame = "\x00\x00" + local default_response = zcl_cmds.DefaultResponse.deserialize(buf_from_str(frame)) + local zclh = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(zcl_cmds.DefaultResponse.ID) }) + local addrh = messages.AddressHeader( + mock_device:get_short_address(), + mock_device:get_endpoint(data_types.ClusterId(IASWD.ID)), + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, zb_const.HA_PROFILE_ID, IASWD.ID) + local message_body = zcl_messages.ZclMessageBody({ zcl_header = zclh, zcl_body = default_response }) + return messages.ZigbeeMessageRx({ address_header = addrh, body = message_body }) +end + +test.register_coroutine_test( + "Default response with OFF alarm command should emit off events", + function() + mock_device:set_field("alarmCommand", 0, {persist = true}) + test.socket.zigbee:__queue_receive({ mock_device.id, build_default_response_zigbee_msg() }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Default response with STROBE alarm command should emit strobe event and timer off", + function() + test.timer.__create_and_queue_test_time_advance_timer(180, "oneshot") + mock_device:set_field("alarmCommand", 2, {persist = true}) + test.socket.zigbee:__queue_receive({ mock_device.id, build_default_response_zigbee_msg() }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.strobe())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.mock_time.advance_time(180) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Default response with BOTH alarm command should emit both event", + function() + test.timer.__create_and_queue_test_time_advance_timer(180, "oneshot") + mock_device:set_field("alarmCommand", 3, {persist = true}) + test.socket.zigbee:__queue_receive({ mock_device.id, build_default_response_zigbee_msg() }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.both())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.mock_time.advance_time(180) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -333,7 +450,10 @@ test.register_coroutine_test( data_types.Uint16(0x0032), data_types.Uint8(40), data_types.Enum8(0)) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml index a8384b886c..6fce8d0d3a 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml +++ b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml @@ -33,7 +33,12 @@ zigbeeManufacturer: deviceLabel: frient Smoke Detector manufacturer: frient A/S model: SMSZB-120 - deviceProfileName: smoke-battery + deviceProfileName: smoke-temp-battery-alarm + - id: "frient/HESZB-120" + deviceLabel: frient Heat Detector + manufacturer: frient A/S + model: HESZB-120 + deviceProfileName: heat-temp-battery-alarm - id: "Heiman/Orvibo/Gas3" deviceLabel: Orvibo Gas Detector manufacturer: Heiman @@ -49,3 +54,13 @@ zigbeeManufacturer: manufacturer: HEIMAN model: GASSensor-N deviceProfileName: smoke-detector + - id: "MultIR/MIR-SM200" + deviceLabel: MultiIR Smoke Detector MIR-SM200 + manufacturer: MultIR + model: MIR-SM200 + deviceProfileName: smoke-battery-tamper-no-fw-update + - id: ShinaSystem/FAM-300Z + deviceLabel: SiHAS Smoke Detector + manufacturer: ShinaSystem + model: FAM-300Z + deviceProfileName: smoke-battery diff --git a/drivers/SmartThings/zigbee-smoke-detector/profiles/heat-temp-battery-alarm.yml b/drivers/SmartThings/zigbee-smoke-detector/profiles/heat-temp-battery-alarm.yml new file mode 100644 index 0000000000..c7abdd4e99 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/profiles/heat-temp-battery-alarm.yml @@ -0,0 +1,55 @@ +name: heat-temp-battery-alarm +components: +- id: main + capabilities: + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - heat + - cleared + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + - id: alarm + version: 1 + config: + values: + - key: "alarm.value" + enabledValues: + - off + - siren + - key: "{{enumCommands}}" + enabledValues: + - off + - siren + categories: + - name: TempSensor +preferences: + - preferenceId: tempOffset + explicit: true + - name: "tempSensitivity" + title: "Temperature Sensitivity (°C)" + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - name: "warningDuration" + title: "Alarm duration (s)" + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-battery-tamper-no-fw-update.yml b/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-battery-tamper-no-fw-update.yml new file mode 100644 index 0000000000..eb66ce92c4 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-battery-tamper-no-fw-update.yml @@ -0,0 +1,14 @@ +name: smoke-battery-tamper-no-fw-update +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: tamperAlert + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector diff --git a/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-temp-battery-alarm.yml b/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-temp-battery-alarm.yml new file mode 100644 index 0000000000..28b7c58357 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-temp-battery-alarm.yml @@ -0,0 +1,50 @@ +name: smoke-temp-battery-alarm +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + - id: alarm + version: 1 + config: + values: + - key: "alarm.value" + enabledValues: + - off + - siren + - key: "{{enumCommands}}" + enabledValues: + - off + - siren + categories: + - name: SmokeDetector +preferences: + - preferenceId: tempOffset + explicit: true + - name: "tempSensitivity" + title: "Temperature Sensitivity (°C)" + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - name: "warningDuration" + title: "Alarm duration (s)" + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 + \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..3d845bdf7e --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-SM200" } +} diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/init.lua new file mode 100644 index 0000000000..b382a56cec --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/init.lua @@ -0,0 +1,53 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local IASZone = zcl_clusters.IASZone + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + if zone_status:is_alarm1_set() then + device:emit_event(capabilities.smokeDetector.smoke.detected()) + elseif zone_status:is_alarm2_set() then + device:emit_event(capabilities.smokeDetector.smoke.tested()) + else + device:emit_event(capabilities.smokeDetector.smoke.clear()) + end + if device:supports_capability(capabilities.tamperAlert) then + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function added_handler(self, device) + device:emit_event(capabilities.battery.battery(100)) + device:emit_event(capabilities.smokeDetector.smoke.clear()) + device:emit_event(capabilities.tamperAlert.tamper.clear()) +end + +local MultiIR_smoke_detector_handler = { + NAME = "MultiIR Smoke Detector Handler", + lifecycle_handlers = { + added = added_handler + }, + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_smoke_detector_handler diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/can_handle.lua new file mode 100644 index 0000000000..41ba568b80 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara-gas.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara-gas") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/fingerprints.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/fingerprints.lua new file mode 100644 index 0000000000..61cc4d9730 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.sensor_gas.acn02" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua index 863385accc..e0474243ce 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" local cluster_base = require "st.zigbee.cluster_base" local capabilities = require "st.capabilities" @@ -31,9 +21,6 @@ local PRIVATE_LIFE_TIME_ATTRIBUTE_ID = 0x0128 local PRIVATE_GAS_ZONE_STATUS_ATTRIBUTE_ID = 0x013A -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.sensor_gas.acn02" } -} local CONFIGURATIONS = { @@ -125,20 +112,11 @@ local function self_check_attr_handler(self, device, zone_status, zb_rx) PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true)) end -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) if CONFIGURATIONS ~= nil then for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end @@ -187,8 +165,7 @@ local aqara_gas_detector_handler = { [startSelfCheckCommandName] = self_check_attr_handler }, }, - can_handle = is_aqara_products + can_handle = require("aqara-gas.can_handle"), } return aqara_gas_detector_handler - diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..d296aa8518 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.sensor_smoke.acn03" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua index c9813a2b10..1950d1f923 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -30,9 +20,6 @@ local PRIVATE_SMOKE_ZONE_STATUS_ATTRIBUTE_ID = 0x013A local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.sensor_smoke.acn03" } -} local CONFIGURATIONS = { @@ -98,21 +85,12 @@ local function self_check_attr_handler(self, device, zone_status, zb_rx) PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true)) end -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) if CONFIGURATIONS ~= nil then for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end @@ -155,8 +133,7 @@ local aqara_gas_detector_handler = { [startSelfCheckCommandName] = self_check_attr_handler }, }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_gas_detector_handler - diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/frient/can_handle.lua new file mode 100644 index 0000000000..5154ec5fe6 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and (device:get_model() == "SMSZB-120" or device:get_model() == "HESZB-120") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua index 0f1dee8e23..d3a3604c26 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua @@ -1,32 +1,265 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local Basic = zcl_clusters.Basic +local alarm = capabilities.alarm +local smokeDetector = capabilities.smokeDetector +local temperatureAlarm = capabilities.temperatureAlarm + +local IASWD = zcl_clusters.IASWD +local IASZone = zcl_clusters.IASZone +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement + +local ALARM_COMMAND = "alarmCommand" +local ALARM_LAST_DURATION = "Custom_Alarm_Duration" +local ALARM_DEFAULT_MAX_DURATION = 0x00F0 +local DEFAULT_WARNING_DURATION = 240 +local BATTERY_MIN_VOLTAGE = 2.3 +local BATTERY_MAX_VOLTAGE = 3.0 + +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 + +local PRIMARY_SW_VERSION = "primary_sw_version" +local SMOKE_ALARM_FIXED_ENDIAN_SW_VERSION = "040005" + +local TEMPERATURE_MEASUREMENT_ENDPOINT = 0x26 + +local alarm_command = { + OFF = 0, + SIREN = 1 +} + +local CONFIGURATIONS = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 30, + maximum_interval = 300, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1 + }, + { + cluster = TemperatureMeasurement.ID, + attribute = TemperatureMeasurement.attributes.MeasuredValue.ID, + minimum_interval = 60, + maximum_interval = 600, + data_type = TemperatureMeasurement.attributes.MeasuredValue.base_type, + reportable_change = 100 + } +} + +local function primary_sw_version_attr_handler(driver, device, value, zb_rx) + local primary_sw_version = value.value:gsub('.', function (c) return string.format('%02x', string.byte(c)) end) + device:set_field(PRIMARY_SW_VERSION, primary_sw_version, {persist = true}) +end + +local function device_added(driver, device) + device:emit_event(alarm.alarm.off()) + + if device:supports_capability(capabilities.temperatureAlarm) then + device:emit_event(temperatureAlarm.temperatureAlarm.cleared()) + end + + if device:supports_capability(capabilities.smokeDetector) then + device:emit_event(smokeDetector.smoke.clear()) + end + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) +end + +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(BATTERY_MIN_VOLTAGE, BATTERY_MAX_VOLTAGE)(driver, device) + if CONFIGURATIONS ~= nil then + for _, attribute in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(attribute) + end + end +end + +local function do_configure(self, device) + device:configure() + device:send(IASWD.attributes.MaxDuration:write(device, ALARM_DEFAULT_MAX_DURATION)) + + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + end +end + +local info_changed = function (driver, device, event, args) + for name, info in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + local input = device.preferences[name] + if (name == "tempSensitivity") then + local sensitivity = math.floor((device.preferences.tempSensitivity or 0.1)*100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 60, 600, sensitivity):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT)) + elseif (name == "warningDuration") then + device:set_field(ALARM_LAST_DURATION, input, {persist = true}) + device:send(IASWD.attributes.MaxDuration:write(device, tonumber(input))) + end + end + end +end + +local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) + if zone_status:is_test_set() then + if device:supports_capability(capabilities.temperatureAlarm) then + device:emit_event(temperatureAlarm.temperatureAlarm.heat()) + end + if device:supports_capability(capabilities.smokeDetector) then + device:emit_event(smokeDetector.smoke.tested()) + end + elseif zone_status:is_alarm1_set() then + if device:supports_capability(capabilities.temperatureAlarm) then + device:emit_event(temperatureAlarm.temperatureAlarm.heat()) + end + if device:supports_capability(capabilities.smokeDetector) then + device:emit_event(smokeDetector.smoke.detected()) + end + else + device.thread:call_with_delay(6, function () + if device:supports_capability(capabilities.temperatureAlarm) then + device:emit_event(temperatureAlarm.temperatureAlarm.cleared()) + end + if device:supports_capability(capabilities.smokeDetector) then + device:emit_event(smokeDetector.smoke.clear()) + end + end) + end +end + +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local emit_alarm_event = function(device, cmd) + if cmd == alarm_command.OFF then + device:emit_event(alarm.alarm.off()) + elseif cmd == alarm_command.SIREN then + device:emit_event(alarm.alarm.siren()) + end +end + +local default_response_handler = function(driver, device, zigbee_message) + local command = zigbee_message.body.zcl_body.cmd.value + local alarm_ev = device:get_field(ALARM_COMMAND) + if command == IASWD.server.commands.StartWarning.ID then + if alarm_ev ~= alarm_command.OFF then + emit_alarm_event(device, alarm_ev) + local lastDuration = device:get_field(ALARM_LAST_DURATION) or ALARM_DEFAULT_MAX_DURATION + device.thread:call_with_delay(lastDuration, function(d) + device:emit_event(alarm.alarm.off()) + end) + else + emit_alarm_event(device,alarm_command.OFF) + end + end +end + +local siren_alarm_siren_handler = function(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.SIREN) -local is_frient_smoke_detector = function(opts, driver, device) - if device:get_manufacturer() == "frient A/S" then - return true + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) end - return false + + local warning_duration = device:get_field(ALARM_LAST_DURATION) or DEFAULT_WARNING_DURATION + local sirenConfiguration + local warning_mode = 0x01 -- For siren on + + if (device:get_field(PRIMARY_SW_VERSION) < SMOKE_ALARM_FIXED_ENDIAN_SW_VERSION) then + -- Old frient firmware, the endian format is reversed + local siren_config_value = warning_mode + sirenConfiguration = IASWD.types.SirenConfiguration(siren_config_value) + else + sirenConfiguration = IASWD.types.SirenConfiguration(0x00) + sirenConfiguration:set_warning_mode(warning_mode) + end + + device:send( + IASWD.server.commands.StartWarning( + device, + sirenConfiguration, + data_types.Uint16(warning_duration), + data_types.Uint8(00), + data_types.Enum8(00) + ) + ) +end + + +local siren_switch_off_handler = function(driver, device, command) + local sirenConfiguration + local warning_mode = 0x00 -- For siren off + + if (device:get_field(PRIMARY_SW_VERSION) < SMOKE_ALARM_FIXED_ENDIAN_SW_VERSION) then + -- Old frient firmware, the endian format is reversed + sirenConfiguration = IASWD.types.SirenConfiguration(warning_mode) + else + sirenConfiguration = IASWD.types.SirenConfiguration(0x00) + sirenConfiguration:set_warning_mode(warning_mode) + end + + device:set_field(ALARM_COMMAND, alarm_command.OFF, {persist = true}) + + device:send( + IASWD.server.commands.StartWarning( + device, + sirenConfiguration, + data_types.Uint16(0x00), + data_types.Uint8(00), + data_types.Enum8(00) + ) + ) end -local frient_smoke_detector = { - NAME = "Freint Smoke Detector", +local frient_smoke_sensor = { + NAME = "frient smoke sensor", lifecycle_handlers = { - init = battery_defaults.build_linear_voltage_init(2.3, 3.0) + added = device_added, + doConfigure = do_configure, + init = device_init, + infoChanged = info_changed }, - can_handle = is_frient_smoke_detector + capability_handlers = { + [alarm.ID] = { + [alarm.commands.off.NAME] = siren_switch_off_handler, + [alarm.commands.siren.NAME] = siren_alarm_siren_handler, + }, + }, + zigbee_handlers = { + global = { + [IASWD.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = default_response_handler + }, + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, + [Basic.ID] = { + [DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR] = primary_sw_version_attr_handler, + } + } + }, + can_handle = require("frient.can_handle"), } - -return frient_smoke_detector +return frient_smoke_sensor diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/init.lua index 2d46a5dc8c..0f1ba0720b 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -20,17 +10,18 @@ local constants = require "st.zigbee.constants" local zigbee_smoke_driver_template = { supported_capabilities = { capabilities.smokeDetector, - capabilities.battery - }, - sub_drivers = { - require("frient"), - require("aqara-gas"), - require("aqara"), - require("frient") + capabilities.battery, + capabilities.alarm, + capabilities.temperatureMeasurement, + capabilities.temperatureAlarm }, + sub_drivers = require("sub_drivers"), ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, + health_check = false, + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(zigbee_smoke_driver_template, zigbee_smoke_driver_template.supported_capabilities) +defaults.register_for_default_handlers(zigbee_smoke_driver_template, + zigbee_smoke_driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) local zigbee_smoke_driver = ZigbeeDriver("zigbee-smoke-detector", zigbee_smoke_driver_template) zigbee_smoke_driver:run() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-smoke-detector/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/can_handle.lua new file mode 100644 index 0000000000..c13aebce6a --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function shinasystem_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "ShinaSystem" and (device:get_model() == "FAM-300Z") then + return true, require("shinasystem") + end + return false +end + +return shinasystem_can_handle diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/init.lua new file mode 100644 index 0000000000..19e59e4aaf --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/init.lua @@ -0,0 +1,34 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local IASZone = zcl_clusters.IASZone + +local CONFIGURATIONS = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 1, + maximum_interval = 1200, -- Zigbee poll interval is 600s. Added because the default reporting maximum_interval (180s) must be greater than 600s. + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1 + } +} + +local function device_init(driver, device) + if CONFIGURATIONS ~= nil then + for _, attribute in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(attribute) + end + end +end + +local shinasystem_smoke_sensor = { + NAME = "shinasystem smoke sensor", + lifecycle_handlers = { + init = device_init + }, + can_handle = require("shinasystem.can_handle"), +} +return shinasystem_smoke_sensor diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua b/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua new file mode 100644 index 0000000000..2f7a4b29ff --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient"), + lazy_load_if_possible("aqara-gas"), + lazy_load_if_possible("aqara"), + lazy_load_if_possible("MultiIR"), + lazy_load_if_possible("shinasystem"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua index 6c194351a6..c15b0e4d62 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -74,7 +63,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.High())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.idle())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", lifeTimeReport.lifeTimeState.normal())) - end + end, + { + min_api_version = 17 + } ) @@ -92,7 +84,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, config_attr_message}) test.socket.zigbee:__expect_send({mock_device.id, write_attr_messge}) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) @@ -108,7 +103,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.gasDetector.gas.detected())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -123,7 +121,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.gasDetector.gas.clear())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -138,7 +139,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.muted())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -153,7 +157,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.unmuted())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -164,7 +171,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_MUTE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -174,7 +184,10 @@ test.register_coroutine_test( { capability = "audioMute", component = "main", command = "unmute", args = {} } }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.muted())) - end + end, + { + min_api_version = 17 + } ) @@ -191,7 +204,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.selfCheckCompleted())) - end + end, + { + min_api_version = 17 + } ) @@ -205,7 +221,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) @@ -222,7 +241,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", lifeTimeReport.lifeTimeState.endOfLife())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -237,7 +259,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", lifeTimeReport.lifeTimeState.normal())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -252,9 +277,81 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low())) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "selfCheck report should be handled, idle", + function() + local attr_report_data = { + { PRIVATE_SELF_CHECK_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + selfCheck.selfCheckState.idle())) + end, + { + min_api_version = 17 + } ) +test.register_coroutine_test( + "sensitivityAdjustment report should be handled, High", + function() + local attr_report_data = { + { PRIVATE_SENSITIVITY_ADJUSTMENT_ATTRIBUTE_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability on command should be handled : setSensitivityAdjustment High", + function() + local attr_report_data = { + { PRIVATE_SENSITIVITY_ADJUSTMENT_ATTRIBUTE_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.capability:__queue_receive({ mock_device.id, + { capability = sensitivityAdjustmentId, component = "main", command = "setSensitivityAdjustment", args = {"High"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_SENSITIVITY_ADJUSTMENT_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x02) + }) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High()) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = sensitivityAdjustmentId, component = "main", command = "setSensitivityAdjustment", args = {"High"}} + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High()) + ) + end, + { + min_api_version = 17 + } +) test.register_coroutine_test( @@ -285,7 +382,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low()) ) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua index bd0fae534c..eaee68d41e 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2024 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -18,13 +7,10 @@ local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" - - local PowerConfiguration = clusters.PowerConfiguration local selfCheck = capabilities["stse.selfCheck"] local selfCheckId = "stse.selfCheck" - test.add_package_capability("selfCheck.yaml") local PRIVATE_CLUSTER_ID = 0xFCC0 @@ -34,8 +20,6 @@ local PRIVATE_MUTE_ATTRIBUTE_ID = 0x0126 local PRIVATE_SELF_CHECK_ATTRIBUTE_ID = 0x0127 local PRIVATE_SMOKE_ZONE_STATUS_ATTRIBUTE_ID = 0x013A - - local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("smoke-battery-aqara.yml"), @@ -50,15 +34,11 @@ local mock_device = test.mock_device.build_test_zigbee_device( } ) - - zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) end - - test.set_test_init_function(test_init) test.register_coroutine_test( @@ -69,7 +49,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.unmuted())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.idle())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -97,10 +80,12 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) - test.register_coroutine_test( "smokeDetector report should be handled", function() @@ -113,11 +98,12 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected())) - end + end, + { + min_api_version = 17 + } ) - - test.register_coroutine_test( "audioMute report should be handled", function() @@ -130,11 +116,12 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.muted())) - end + end, + { + min_api_version = 17 + } ) - - test.register_coroutine_test( "Capability on command should be handled : device mute", function() @@ -143,11 +130,12 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_MUTE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 17 + } ) - - test.register_coroutine_test( "selfCheck report should be handled", function() @@ -160,11 +148,12 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.selfCheckCompleted())) - end + end, + { + min_api_version = 17 + } ) - - test.register_coroutine_test( "Capability on command should be handled : device selfCheck", function() @@ -174,10 +163,65 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) +test.register_coroutine_test( + "smokeDetector report should be handled, smoke clear", + function() + local attr_report_data = { + { PRIVATE_SMOKE_ZONE_STATUS_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.smokeDetector.smoke.clear())) + end, + { + min_api_version = 17 + } +) +test.register_coroutine_test( + "audioMute report should be handled, unmuted", + function() + local attr_report_data = { + { PRIVATE_MUTE_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.audioMute.mute.unmuted())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "selfCheck report should be handled, idle", + function() + local attr_report_data = { + { PRIVATE_SELF_CHECK_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + selfCheck.selfCheckState.idle())) + end, + { + min_api_version = 17 + } +) test.register_message_test( "Battery voltage report should be handled", @@ -192,8 +236,10 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) - test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua new file mode 100644 index 0000000000..7f1a7e5f3a --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua @@ -0,0 +1,650 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local IASWD = clusters.IASWD +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local Basic = clusters.Basic +local capabilities = require "st.capabilities" +local alarm = capabilities.alarm +local temperatureAlarm = capabilities.temperatureAlarm +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" +local SirenConfiguration = require "st.zigbee.generated.zcl_clusters.IASWD.types.SirenConfiguration" +local ALARM_DEFAULT_MAX_DURATION = 0x00F0 +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local IASZONE_ENDPOINT = 0x23 +local TEMPERATURE_MEASUREMENT_ENDPOINT = 0x26 +local base64 = require "base64" +local PRIMARY_SW_VERSION = "primary_sw_version" +local SIREN_ENDIAN = "siren_endian" +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local cluster_base = require "st.zigbee.cluster_base" +local defaultWarningDuration = 240 + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("heat-temp-battery-alarm.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "HESZB-120", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x000f, 0x0020, 0x0500, 0x0502 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0000, 0x0003, 0x0402 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Clear alarm and temperatureAlarm states, and read firmware version when the device is added", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", temperatureAlarm.temperatureAlarm.cleared()) + ) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", temperatureAlarm.temperatureAlarm.cleared()) + ) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE) + }) + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_MEASUREMENT_ENDPOINT + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 60, + 600, + 100 + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 0 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, ALARM_DEFAULT_MAX_DURATION) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 24) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZoneStatusChangeNotification should be handled: detected", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) + ) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZoneStatusChangeNotification should be handled: tested", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0100, 0x01) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) + ) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZoneStatusChangeNotification should be handled: cleared", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) + }) + + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + ) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: tempSensitivity, warningDuration", + function() + local updates = { + preferences = { + tempSensitivity = 1.3, + warningDuration = 100 + } + } + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 60, + 600, + 130 + )--:to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, 0x0064)--:to_endpoint(IASZONE_ENDPOINT) + }) + + + test.socket.zigbee:__set_channel_ordering("relaxed") + + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on the siren", + function() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040002", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, "reverse", {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "040005", "PRIMARY_SW_VERSION should be less than '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == "reverse", "SIREN_ENDIAN should be set to 'reverse'") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x01) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(defaultWarningDuration), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on the siren", + function() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040005", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "040005", "PRIMARY_SW_VERSION should be greater than or equal to '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == nil, "SIREN_ENDIAN should be set to 'nil'") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x00) + expectedConfiguration:set_warning_mode(0x01) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(defaultWarningDuration), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on the siren", + function() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040002", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, "reverse", {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "040005", "PRIMARY_SW_VERSION should be less than '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == "reverse", "SIREN_ENDIAN should be set to 'reverse'") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(0x00), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn off the siren", + function() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040005", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "040005", "PRIMARY_SW_VERSION should be greater than or equal to '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == nil, "SIREN_ENDIAN should be set to 'nil'") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x00) + expectedConfiguration:set_warning_mode(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(0x00), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test firmware version conversion using direct simulation", + function() + -- Binary firmware version test cases + local test_cases = { + { + binary = string.char(4, 0, 5), -- \004\000\005 + expected_hex = "040005" -- Expected output + }, + { + binary = string.char(4, 0, 12), -- \004\000\012 + expected_hex = "04000c" -- Expected output + }, + { + binary = string.char(5, 1, 3), -- \005\001\003 + expected_hex = "050103" -- Expected output + } + } + + for i, test_case in ipairs(test_cases) do + print("\n----- Test Case " .. i .. " -----") + local binary_fw = test_case.binary + local expected_hex = test_case.expected_hex + + -- Print the raw binary version and its byte values + print("Binary firmware version (raw):", binary_fw) + print("Binary firmware bytes:", string.format( + "\\%03d\\%03d\\%03d", + string.byte(binary_fw, 1), + string.byte(binary_fw, 2), + string.byte(binary_fw, 3) + )) + + -- Reset the field for clean test + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + + -- Create a mock value object + local mock_value = { + value = binary_fw + } + + -- Simulate what happens in primary_sw_version_attr_handler + local primary_sw_version = mock_value.value:gsub('.', function (c) + return string.format('%02x', string.byte(c)) + end) + + -- Store the version in PRIMARY_SW_VERSION field + mock_device:set_field(PRIMARY_SW_VERSION, primary_sw_version, {persist = true}) + + -- What the conversion should do + print("\nConversion steps:") + local hex_result = "" + for i = 1, #binary_fw do + local char = binary_fw:sub(i, i) + local byte_val = string.byte(char) + local hex_val = string.format('%02x', byte_val) + hex_result = hex_result .. hex_val + print(string.format("Character at position %d: byte value = %d, hex = %02x", + i, byte_val, byte_val)) + end + + print("\nExpected hex result:", expected_hex) + print("Manual conversion result:", hex_result) + + -- Verify the stored version + local stored_version = mock_device:get_field(PRIMARY_SW_VERSION) + print("\nStored version in device field:", stored_version) + assert(stored_version == expected_hex, + string.format("Version mismatch! Expected '%s' but got '%s'", + expected_hex, stored_version or "nil")) + end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "IASZone attribute report should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "IASZone attribute report should be handled: cleared", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) + }) + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua index fae04f8963..7596148a66 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua @@ -1,195 +1,753 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" +local IASWD = clusters.IASWD local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local Basic = clusters.Basic local capabilities = require "st.capabilities" +local alarm = capabilities.alarm +local smokeDetector = capabilities.smokeDetector local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" +local SirenConfiguration = require "st.zigbee.generated.zcl_clusters.IASWD.types.SirenConfiguration" +local ALARM_DEFAULT_MAX_DURATION = 0x00F0 +local POWER_CONFIGURATION_ENDPOINT = 0x23 +local IASZONE_ENDPOINT = 0x23 +local TEMPERATURE_MEASUREMENT_ENDPOINT = 0x26 +local base64 = require "base64" +local PRIMARY_SW_VERSION = "primary_sw_version" +local SIREN_ENDIAN = "siren_endian" +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local cluster_base = require "st.zigbee.cluster_base" +local defaultWarningDuration = 240 +local messages = require "st.zigbee.messages" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local zb_const = require "st.zigbee.constants" +local Status = require "st.zigbee.generated.types.ZclStatus" +local zcl_messages = require "st.zigbee.zcl" +local ALARM_COMMAND = "alarmCommand" + local mock_device = test.mock_device.build_test_zigbee_device( - { profile = t_utils.get_profile_definition("smoke-battery.yml"), - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "frient A/S", - model = "SMSZB-120", - server_clusters = {0x0000,0x0001,0x0003,0x000F,0x0020,0x0500,0x0502} - } - } - } + { profile = t_utils.get_profile_definition("smoke-temp-battery-alarm.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "SMSZB-120", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x23] = { + id = 0x23, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x000f, 0x0020, 0x0500, 0x0502 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0000, 0x0003, 0x0402 } + } + } + } ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Clear alarm and smokeDetector states, and read firmware version when the device is added", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE) + }) + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_MEASUREMENT_ENDPOINT + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 60, + 600, + 100 + ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + IASZONE_ENDPOINT + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 30, + 300, + 0 + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(IASZONE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, ALARM_DEFAULT_MAX_DURATION) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 24) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) + } + }, + { + min_api_version = 17 + } +) + test.register_message_test( - "Battery voltage report should be handled", + "ZoneStatusChangeNotification should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit0 Alarm1 set to 1 + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: tested", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit8: Test set to 1 + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x100, 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZoneStatusChangeNotification should be handled: clear", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) + }) + + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + ) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.wait_for_events() + +-- test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval +-- test.socket.zigbee:__set_channel_ordering("relaxed") + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryVoltage:read(mock_device) +-- } +-- ) + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) +-- } +-- ) + +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- IASZone.attributes.ZoneStatus:read(mock_device) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: tempSensitivity, warningDuration", + function() + local updates = { + preferences = { + tempSensitivity = 1.3, + warningDuration = 100 + } + } + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 60, + 600, + 130 + )--:to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, 0x0064)--:to_endpoint(IASZONE_ENDPOINT) + }) + + + test.socket.zigbee:__set_channel_ordering("relaxed") + + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on the siren", + function() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040002", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, "reverse", {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "040005", "PRIMARY_SW_VERSION should be less than '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == "reverse", "SIREN_ENDIAN should be set to 'reverse'") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x01) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(defaultWarningDuration), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() + end, { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 24) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) - } + min_api_version = 17 } ) test.register_coroutine_test( - "Health check should check all relevant attributes", + "Should detect newer firmware version and use correct endian format to turn on the siren", function() - test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) - test.wait_for_events() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040005", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "040005", "PRIMARY_SW_VERSION should be greater than or equal to '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == nil, "SIREN_ENDIAN should be set to 'nil'") - test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send( - { + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:read(mock_device) - } - ) - test.socket.zigbee:__expect_send( - { + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x00) + expectedConfiguration:set_warning_mode(0x01) + + test.socket.zigbee:__expect_send({ mock_device.id, - IASZone.attributes.ZoneStatus:read(mock_device) - } - ) + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(defaultWarningDuration), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() end, { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end + min_api_version = 17 } ) test.register_coroutine_test( - "Configure should configure all necessary attributes", - function () - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) - test.socket.zigbee:__expect_send({ - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:read(mock_device) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.attributes.ZoneStatus:read(mock_device) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:configure_reporting( - mock_device, - 30, - 21600, - 1 - ) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request( - mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID - ) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.attributes.IASCIEAddress:write( - mock_device, - zigbee_test_utils.mock_hub_eui - ) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.server.commands.ZoneEnrollResponse( - mock_device, - IasEnrollResponseCode.SUCCESS, - 0x00 - ) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.attributes.ZoneStatus:configure_reporting( - mock_device, - 0, - 180, - 0 - ) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request( - mock_device, - zigbee_test_utils.mock_hub_eui, - IASZone.ID - ) - }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + "Should detect older firmware version and use correct endian format to turn on the siren", + function() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040002", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, "reverse", {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "040005", "PRIMARY_SW_VERSION should be less than '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == "reverse", "SIREN_ENDIAN should be set to 'reverse'") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(0x00), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } ) -test.register_message_test( - "Refresh should read all necessary attributes", +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn off the siren", + function() + -- Manually set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "040005", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "040005", "PRIMARY_SW_VERSION should be greater than or equal to '040005'") + assert(mock_device:get_field(SIREN_ENDIAN) == nil, "SIREN_ENDIAN should be set to 'nil'") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + -- Expect the command with reverse endian format + local expectedConfiguration = SirenConfiguration(0x00) + expectedConfiguration:set_warning_mode(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning(mock_device, + expectedConfiguration, + data_types.Uint16(0x00), + data_types.Uint8(00), + data_types.Enum8(00)) + }) + + test.wait_for_events() + end, { - { - channel = "device_lifecycle", - direction = "receive", - message = {mock_device.id, "added"} - }, - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "refresh", component = "main", command = "refresh", args = {} } - } - }, - { - channel = "zigbee", - direction = "send", - message = { - mock_device.id, - PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test firmware version conversion using direct simulation", + function() + -- Binary firmware version test cases + local test_cases = { + { + binary = string.char(4, 0, 5), -- \004\000\005 + expected_hex = "040005" -- Expected output + }, + { + binary = string.char(4, 0, 12), -- \004\000\012 + expected_hex = "04000c" -- Expected output + }, + { + binary = string.char(5, 1, 3), -- \005\001\003 + expected_hex = "050103" -- Expected output + } } - }, - { - channel = "zigbee", - direction = "send", - message = { - mock_device.id, - IASZone.attributes.ZoneStatus:read(mock_device) + + for i, test_case in ipairs(test_cases) do + print("\n----- Test Case " .. i .. " -----") + local binary_fw = test_case.binary + local expected_hex = test_case.expected_hex + + -- Print the raw binary version and its byte values + print("Binary firmware version (raw):", binary_fw) + print("Binary firmware bytes:", string.format( + "\\%03d\\%03d\\%03d", + string.byte(binary_fw, 1), + string.byte(binary_fw, 2), + string.byte(binary_fw, 3) + )) + + -- Reset the field for clean test + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + + -- Create a mock value object + local mock_value = { + value = binary_fw + } + + -- Simulate what happens in primary_sw_version_attr_handler + local primary_sw_version = mock_value.value:gsub('.', function (c) + return string.format('%02x', string.byte(c)) + end) + + -- Store the version in PRIMARY_SW_VERSION field + mock_device:set_field(PRIMARY_SW_VERSION, primary_sw_version, {persist = true}) + + -- What the conversion should do + print("\nConversion steps:") + local hex_result = "" + for i = 1, #binary_fw do + local char = binary_fw:sub(i, i) + local byte_val = string.byte(char) + local hex_val = string.format('%02x', byte_val) + hex_result = hex_result .. hex_val + print(string.format("Character at position %d: byte value = %d, hex = %02x", + i, byte_val, byte_val)) + end + + print("\nExpected hex result:", expected_hex) + print("Manual conversion result:", hex_result) + + -- Verify the stored version + local stored_version = mock_device:get_field(PRIMARY_SW_VERSION) + print("\nStored version in device field:", stored_version) + assert(stored_version == expected_hex, + string.format("Version mismatch! Expected '%s' but got '%s'", + expected_hex, stored_version or "nil")) + end + end, + { + min_api_version = 17 + } +) + +local function build_default_response_msg(device, cluster, command, status) + local addr_header = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local default_response_body = default_response.DefaultResponse(command, status) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(default_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = default_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +test.register_message_test( + "IASZone attribute report should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", smokeDetector.smoke.detected()) } - }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 + } +) + +test.register_coroutine_test( + "IASZone attribute report should be handled: clear", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) + }) + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "default_response_handler: siren active, emit siren then off after delay", + function() + mock_device:set_field(ALARM_COMMAND, 1) + test.timer.__create_and_queue_test_time_advance_timer(ALARM_DEFAULT_MAX_DURATION, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(mock_device, IASWD.ID, IASWD.server.commands.StartWarning.ID, Status.SUCCESS) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", alarm.alarm.siren())) + test.mock_time.advance_time(ALARM_DEFAULT_MAX_DURATION) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", alarm.alarm.off())) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "default_response_handler: alarm off, emit off", + function() + mock_device:set_field(ALARM_COMMAND, 0) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(mock_device, IASWD.ID, IASWD.server.commands.StartWarning.ID, Status.SUCCESS) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", alarm.alarm.off())) + test.wait_for_events() + end, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_multiir_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_multiir_smoke_detector.lua new file mode 100644 index 0000000000..e79df95f01 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_multiir_smoke_detector.lua @@ -0,0 +1,195 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local IASZone = clusters.IASZone + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("smoke-battery-tamper-no-fw-update.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "MultIR", + model = "MIR-SM200", + server_clusters = { 0x0001,0x0020, 0x0500, 0x0502 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.smokeDetector.smoke.clear())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tamperAlert.tamper.clear())) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: smoke/clear tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: smoke/detected tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: smoke/tested tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0006) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: smoke/detected tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0005, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: smoke/tested tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0006, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: smoke/clear tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua index e45c150824..f0f9b8259e 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -30,9 +19,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -47,7 +34,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } + } + }, + }, + { + min_api_version = 17 } ) @@ -63,7 +61,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } + } + }, + }, + { + min_api_version = 17 } ) @@ -79,7 +88,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } + } + }, + }, + { + min_api_version = 17 } ) @@ -95,7 +115,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } + } + }, + }, + { + min_api_version = 17 } ) @@ -112,37 +143,40 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) - test.wait_for_events() +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) +-- test.wait_for_events() - test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send( - { - mock_device.id, - PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - IASZone.attributes.ZoneStatus:read(mock_device) - } - ) - end, - { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end - } -) +-- test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- IASZone.attributes.ZoneStatus:read(mock_device) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) test.register_coroutine_test( "Configure should configure all necessary attributes", @@ -209,7 +243,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -246,7 +283,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-sound-sensor/src/init.lua b/drivers/SmartThings/zigbee-sound-sensor/src/init.lua index cf305ef133..fefa93f611 100644 --- a/drivers/SmartThings/zigbee-sound-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-sound-sensor/src/init.lua @@ -21,8 +21,14 @@ local zcl_clusters = require "st.zigbee.zcl.clusters" local PollControl = zcl_clusters.PollControl local IASZone = zcl_clusters.IASZone local PowerConfiguration = zcl_clusters.PowerConfiguration +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + local CHECK_IN_INTERVAL = 6480 local LONG_POLL_INTERVAL = 1200 local SHORT_POLL_INTERVAL = 2 @@ -52,6 +58,29 @@ local ias_zone_status_change_handler = function(driver, device, zb_rx) generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) end +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + local do_configure = function(self, device) device_management.write_ias_cie_address(device, self.environment_info.hub_zigbee_eui) device:send(IASZone.server.commands.ZoneEnrollResponse(device, 0x00, 0x00)) -- ZoneEnroll Response should be called first in case of this device. @@ -67,6 +96,8 @@ end local added_handler = function(self, device) device:emit_event(capabilities.soundSensor.sound.not_detected()) + device:send(TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) + device:send(TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) end local zigbee_sound_sensor_driver_template = { @@ -79,6 +110,10 @@ local zigbee_sound_sensor_driver_template = { attr = { [IASZone.ID] = { [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), } }, cluster = { @@ -91,9 +126,11 @@ local zigbee_sound_sensor_driver_template = { added = added_handler, doConfigure = do_configure, init = battery_defaults.build_linear_voltage_init(2.2, 3.0) - } + }, + health_check = false, } -defaults.register_for_default_handlers(zigbee_sound_sensor_driver_template, zigbee_sound_sensor_driver_template.supported_capabilities) +defaults.register_for_default_handlers(zigbee_sound_sensor_driver_template, + zigbee_sound_sensor_driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) local zigbee_sound_sensor = ZigbeeDriver("zigbee-sound-sensor", zigbee_sound_sensor_driver_template) zigbee_sound_sensor:run() diff --git a/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua b/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua index 90249669ba..11a251b1d7 100644 --- a/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua +++ b/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua @@ -43,9 +43,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -123,7 +121,10 @@ test.register_coroutine_test( PollControl.attributes.CheckInInterval:write(mock_device, data_types.Uint32(6480)) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -139,6 +140,22 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.not_detected()) }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, { channel = "capability", direction = "receive", @@ -173,7 +190,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -190,6 +208,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.detected()) } + }, + { + min_api_version = 17 } ) @@ -206,6 +227,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.detected()) } + }, + { + min_api_version = 17 } ) @@ -222,6 +246,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.not_detected()) } + }, + { + min_api_version = 17 } ) @@ -238,6 +265,60 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index d55ab3e55c..08eb875591 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -26,7 +26,7 @@ zigbeeManufacturer: model: SLA02 deviceProfileName: basic-switch - id: "SMARTvill/SLA01" - deviceLabel: SMARTvill Switch 1 + deviceLabel: SMARTvill Switch manufacturer: SMARTvill model: SLA01 deviceProfileName: basic-switch @@ -75,7 +75,7 @@ zigbeeManufacturer: deviceLabel: Aqara Smart Wall Switch (No Neutral, Single Rocker) manufacturer: LUMI model: lumi.switch.b1laus01 - deviceProfileName: basic-switch + deviceProfileName: aqara-switch-child - id: "LUMI/lumi.switch.b2laus01" deviceLabel: Aqara Smart Wall Switch (No Neutral, Double Rocker) 1 manufacturer: LUMI @@ -101,6 +101,11 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.switch.n0agl1 deviceProfileName: aqara-switch-module + - id: "LUMI/lumi.switch.l0agl1" + deviceLabel: Aqara Single Switch Module T1 (No Neutral) + manufacturer: LUMI + model: lumi.switch.l0agl1 + deviceProfileName: aqara-switch-module-no-power - id: "LUMI/lumi.switch.n0acn2" deviceLabel: Aqara Single Switch Module T1 (With Neutral) manufacturer: LUMI @@ -111,11 +116,51 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.light.acn004 deviceProfileName: aqara-light + - id: "LUMI/lumi.light.cwacn1" + deviceLabel: Aqara Smart Dimmer Controller T1 (0-10v) + manufacturer: LUMI + model: lumi.light.cwacn1 + deviceProfileName: aqara-light - id: "Aqara/lumi.light.acn014" deviceLabel: Aqara LED Light Bulb T1 (Tunable White) manufacturer: Aqara model: lumi.light.acn014 deviceProfileName: aqara-led-bulb + - id: "LUMI/lumi.switch.n1aeu1" + deviceLabel: Aqara Smart Wall Switch H1 EU (With Neutral, Single Rocker) + manufacturer: LUMI + model: lumi.switch.n1aeu1 + deviceProfileName: aqara-switch-power + - id: "LUMI/lumi.switch.n2aeu1" + deviceLabel: Aqara Smart Wall Switch H1 EU (With Neutral, Double Rocker) 1 + manufacturer: LUMI + model: lumi.switch.n2aeu1 + deviceProfileName: aqara-switch-power + - id: "LUMI/lumi.switch.l1aeu1" + deviceLabel: Aqara Smart Wall Switch H1 EU (No Neutral, Single Rocker) + manufacturer: LUMI + model: lumi.switch.l1aeu1 + deviceProfileName: aqara-switch-no-power + - id: "LUMI/lumi.switch.l2aeu1" + deviceLabel: Aqara Smart Wall Switch H1 EU (No Neutral, Double Rocker) 1 + manufacturer: LUMI + model: lumi.switch.l2aeu1 + deviceProfileName: aqara-switch-no-power + - id: "LUMI/lumi.switch.b1nacn01" + deviceLabel: Aqara Smart Wall Switch T1 (With Neutral, Single Rocker) + manufacturer: LUMI + model: lumi.switch.b1nacn01 + deviceProfileName: aqara-switch-power + - id: "LUMI/lumi.switch.b2nacn01" + deviceLabel: Aqara Smart Wall Switch T1 (With Neutral, Double Rocker) 1 + manufacturer: LUMI + model: lumi.switch.b2nacn01 + deviceProfileName: aqara-switch-power + - id: "LUMI/lumi.switch.b3n01" + deviceLabel: Aqara Smart Wall Switch T1 (With Neutral, Triple Rocker) 1 + manufacturer: LUMI + model: lumi.switch.b3n01 + deviceProfileName: aqara-switch-power # VIMAR - id: "Vimar/xx592-2-way-smart-switch" deviceLabel: Vimar 2-way Smart Switch @@ -401,30 +446,70 @@ zigbeeManufacturer: deviceProfileName: switch-power-energy #frient A/S - id: "frient A/S/SPLZB-131" - deviceLabel: "frient Outlet" + deviceLabel: "Smart Plug Mini" manufacturer: frient A/S model: SPLZB-131 - deviceProfileName: switch-power-energy + deviceProfileName: frient-switch-power-energy-voltage - id: "frient A/S/SPLZB-132" - deviceLabel: "frient Outlet" + deviceLabel: "Smart Plug Mini" manufacturer: frient A/S model: SPLZB-132 - deviceProfileName: switch-power-energy + deviceProfileName: frient-switch-power-energy-voltage - id: "frient A/S/SPLZB-134" - deviceLabel: "frient Outlet" + deviceLabel: "Smart Plug Mini UK" manufacturer: frient A/S model: SPLZB-134 - deviceProfileName: switch-power-energy + deviceProfileName: frient-switch-power-energy-voltage - id: "frient A/S/SPLZB-137" - deviceLabel: "frient Outlet" + deviceLabel: "Smart Plug Mini" manufacturer: frient A/S model: SPLZB-137 - deviceProfileName: switch-power-energy + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient A/S/SPLZB-141" + deviceLabel: "Smart Plug Mini 2" + manufacturer: frient A/S + model: SPLZB-141 + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient A/S/SPLZB-142" + deviceLabel: Smart Plug Mini 2 + manufacturer: frient A/S + model: SPLZB-142 + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient A/S/SPLZB-144" + deviceLabel: Smart Plug Mini UK 2 + manufacturer: frient A/S + model: SPLZB-144 + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient A/S/SPLZB-147" + deviceLabel: "Smart Plug Mini 2" + manufacturer: frient A/S + model: SPLZB-147 + deviceProfileName: frient-switch-power-energy-voltage - id: "frient A/S/SMRZB-143" - deviceLabel: "frient Outlet" + deviceLabel: "Smart Cable" manufacturer: frient A/S model: SMRZB-143 - deviceProfileName: switch-power-energy + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient A/S/SMRZB-153" + deviceLabel: "Smart Cable 2" + manufacturer: frient A/S + model: SMRZB-153 + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient A/S/SMRZB-332" + deviceLabel: "Smart Din Relay" + manufacturer: frient A/S + model: SMRZB-332 + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient A/S/SMRZB-342" + deviceLabel: Smart DIN Relay 2 + manufacturer: frient A/S + model: SMRZB-342 + deviceProfileName: frient-switch-power-energy-voltage + - id: "frient/IOMZB-110" + deviceLabel: frient IO Module + manufacturer: frient A/S + model: IOMZB-110 + deviceProfileName: switch-4inputs-2outputs - id: "AduroSmart Eria/AD-DimmableLight3001" deviceLabel: Eria Light manufacturer: AduroSmart Eria @@ -511,15 +596,30 @@ zigbeeManufacturer: model: "TRADFRI bulb E27 WW clear 250lm" deviceProfileName: on-off-level - id: "ubisys/D1 (5503)" - deviceLabel: INGENIUM Light + deviceLabel: Dimmer D1 manufacturer: ubisys model: "D1 (5503)" - deviceProfileName: on-off-level + deviceProfileName: switch-level-power + - id: "ubisys/S1 (5501)" + deviceLabel: Switching Actuator S1 + manufacturer: ubisys + model: "S1 (5501)" + deviceProfileName: switch-power - id: "ubisys/S2 (5502)" deviceLabel: ubisys S2 manufacturer: ubisys model: "S2 (5502)" deviceProfileName: switch-power-firmware-2 + - id: "ubisys/1151" + deviceLabel: Switching Actuator S1-R (Series 2) + manufacturer: ubisys + model: "1151" + deviceProfileName: switch-power + - id: "ubisys/D1-R (5603)" + deviceLabel: Dimmer D1-R + manufacturer: ubisys + model: "D1-R (5603)" + deviceProfileName: switch-level-power - id: "Megaman/AD-DimmableLight3001" deviceLabel: INGENIUM Light manufacturer: Megaman @@ -955,6 +1055,21 @@ zigbeeManufacturer: manufacturer: SONOFF model: ZBMINIL2 deviceProfileName: basic-switch + - id: "SONOFF/ZBM5-1C-120W" + deviceLabel: SONOFF Zigbee Smart Wall Switch + manufacturer: SONOFF + model: ZBM5-1C-120W + deviceProfileName: basic-switch + - id: "SONOFF/ZBM5-2C-120W" + deviceLabel: SONOFF Zigbee Smart Wall Switch + manufacturer: SONOFF + model: ZBM5-2C-120W + deviceProfileName: basic-switch + - id: "SONOFF/ZBM5-3C-120W" + deviceLabel: SONOFF Zigbee Smart Wall Switch + manufacturer: SONOFF + model: ZBM5-3C-120W + deviceProfileName: basic-switch - id: "/TERNCY-LS01" deviceLabel: Terncy Switch manufacturer: @@ -1065,6 +1180,31 @@ zigbeeManufacturer: manufacturer: ShinaSystem model: SBM300Z6 deviceProfileName: basic-switch + - id: ShinaSystem/SQM300Z1 + deviceLabel: SiHAS Switch + manufacturer: ShinaSystem + model: SQM300Z1 + deviceProfileName: basic-switch + - id: ShinaSystem/SQM300Z2 + deviceLabel: SiHAS Switch 1 + manufacturer: ShinaSystem + model: SQM300Z2 + deviceProfileName: basic-switch + - id: ShinaSystem/SQM300Z3 + deviceLabel: SiHAS Switch 1 + manufacturer: ShinaSystem + model: SQM300Z3 + deviceProfileName: basic-switch + - id: ShinaSystem/SQM300Z4 + deviceLabel: SiHAS Switch 1 + manufacturer: ShinaSystem + model: SQM300Z4 + deviceProfileName: basic-switch + - id: ShinaSystem/SQM300Z6 + deviceLabel: SiHAS Switch 1 + manufacturer: ShinaSystem + model: SQM300Z6 + deviceProfileName: basic-switch - id: Samsung/SAMSUNG-ITM-Z-002 deviceLabel: Samsung Light manufacturer: Samsung Electronics @@ -1590,6 +1730,16 @@ zigbeeManufacturer: manufacturer: LEDVANCE model: RT TW deviceProfileName: color-temp-bulb + - id: "LEDVANCE/PLUG COMPACT EU EM T" + deviceLabel: SMART ZIGBEE COMPACT OUTDOOR PLUG EU + manufacturer: LEDVANCE + model: PLUG COMPACT EU EM T + deviceProfileName: switch-power-energy + - id: "LEDVANCE/PLUG EU EM T" + deviceLabel: SMART ZIGBEE PLUG EU EM T + manufacturer: LEDVANCE + model: PLUG EU EM T + deviceProfileName: switch-power-energy - id: "OSRAM/LIGHTIFY Edge-lit flushmount" deviceLabel: SYLVANIA Light manufacturer: OSRAM @@ -2198,36 +2348,36 @@ zigbeeManufacturer: deviceProfileName: on-off-level # Wall Hero - id: "WALL HERO/ACL-401S4I" - deviceLabel: 四位智能开关面板 1 + deviceLabel: WallHero Switch (4 Way) 1 manufacturer: WALL HERO model: ACL-401S4I deviceProfileName: wallhero - id: "WALL HERO/ACL-401S8I" - deviceLabel: 八位智能开关/场景面板 1 + deviceLabel: WallHero Switch (8 Way) 1 manufacturer: WALL HERO model: ACL-401S8I deviceProfileName: wallhero - id: "WALL HERO/ACL-401ON" - deviceLabel: 智能墙面五孔插座 + deviceLabel: WallHero Outlet manufacturer: WALL HERO model: ACL-401ON - deviceProfileName: wallhero + deviceProfileName: wallhero - id: "WALL HERO/ACL-401S1I" - deviceLabel: 一位智能开关面板 + deviceLabel: WallHero Switch (1 Way) manufacturer: WALL HERO model: ACL-401S1I - deviceProfileName: wallhero + deviceProfileName: wallhero - id: "WALL HERO/ACL-401S3I" - deviceLabel: 三位智能开关面板 1 + deviceLabel: WallHero Switch (3 Way) 1 manufacturer: WALL HERO model: ACL-401S3I - deviceProfileName: wallhero + deviceProfileName: wallhero - id: "WALL HERO/ACL-401S2I" - deviceLabel: 二位智能开关面板 1 + deviceLabel: WallHero Switch (2 Way) 1 manufacturer: WALL HERO model: ACL-401S2I deviceProfileName: wallhero - # End Wall Hero + # NEXENTRO - id: "Insta GmbH/Switching Actuator" deviceLabel: NEXENTRO Switching Actuator manufacturer: Insta GmbH @@ -2238,6 +2388,165 @@ zigbeeManufacturer: manufacturer: Insta GmbH model: NEXENTRO Dimming Actuator deviceProfileName: on-off-level + # Inovelli + - id: "Inovelli/VZM30-SN" + deviceLabel: "Inovelli On/Off Blue Series" + manufacturer: Inovelli + model: VZM30-SN + deviceProfileName: inovelli-vzm30-sn + - id: "Inovelli/VZM31-SN" + deviceLabel: "Inovelli 2-in-1 Blue Series" + manufacturer: Inovelli + model: VZM31-SN + deviceProfileName: inovelli-vzm31-sn + - id: "Inovelli/VZM32-SN" + deviceLabel: "Inovelli mmWave Dimmer Blue Series" + manufacturer: Inovelli + model: VZM32-SN + deviceProfileName: inovelli-vzm32-sn + - id: "LAISIAO/BATH" + deviceLabel: Laisiao Bathroom Heater + manufacturer: LAISIAO + model: yuba + deviceProfileName: switch-smart-bath-heater-laisiao + - id: "LAISIAO/DG60GCM-04-2904W" + deviceLabel: Laisiao Bathroom Heater + manufacturer: LAISIAO + model: DG60GCM-04-2904W + deviceProfileName: switch-smart-bath-heater-laisiao + # NodOn + - id: "NodOn/SIN-4-1-20" + deviceLabel: Zigbee Multifunction Relay Switch + manufacturer: NodOn + model: SIN-4-1-20 + deviceProfileName: basic-switch + - id: "NodOn/SIN-4-1-20-PRO" + deviceLabel: Zigbee Multifunction Relay Switch PRO + manufacturer: NodOn + model: SIN-4-1-20-PRO + deviceProfileName: basic-switch + - id: "NodOn/SIN-4-2-20" + deviceLabel: Zigbee ON/OFF Lighting Relay Switch + manufacturer: NodOn + model: SIN-4-2-20 + deviceProfileName: switch-level + - id: "NodOn/SIN-4-1-21" + deviceLabel: Zigbee Multifunction Relay Switch with Metering + manufacturer: NodOn + model: SIN-4-1-21 + deviceProfileName: switch-power-energy + #Yanmi + - id: "JNL/Y-K003-001" + deviceLabel: Yanmi Switch (3 Way) 1 + manufacturer: JNL + model: Y-K003-001 + deviceProfileName: basic-switch + - id: "JNL/Y-K001-001" + deviceLabel: Yanmi Switch (1 Way) + manufacturer: JNL + model: Y-K001-001 + deviceProfileName: basic-switch + - id: "JNL/Y-K002-001" + deviceLabel: Yanmi Switch (2 Way) 1 + 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 + - id: "FIRSTLED/M3S3BAC" + deviceLabel: Mirror Series 3x3 1 + manufacturer: FIRSTLED + model: M3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/P3S3BAC" + deviceLabel: Punk Series 3x3 1 + manufacturer: FIRSTLED + model: P3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/P2S2BAC" + deviceLabel: Punk Series 2x2 1 + manufacturer: FIRSTLED + model: P2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/P1S1BAC" + deviceLabel: Punk Series 1x1 1 + manufacturer: FIRSTLED + model: P1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX4S2BAC" + deviceLabel: Matrix X Series 4x2 1 + manufacturer: FIRSTLED + model: MX4S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX3S2BAC" + deviceLabel: Matrix X Series 3x2 1 + manufacturer: FIRSTLED + model: MX3S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX2S2BAC" + deviceLabel: Matrix X Series 2x2 1 + manufacturer: FIRSTLED + model: MX2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX1S2BAC" + deviceLabel: Matrix X Series 1x2 1 + manufacturer: FIRSTLED + model: MX1S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/G3S3BAC" + deviceLabel: Guanzhi Series 3x3 1 + manufacturer: FIRSTLED + model: G3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/G2S2BAC" + deviceLabel: Guanzhi Series 2x2 1 + manufacturer: FIRSTLED + model: G2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/G1S1BAC" + deviceLabel: Guanzhi Series 1x1 1 + manufacturer: FIRSTLED + model: G1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL4S4BAC" + deviceLabel: Dynamic Lite Series 4x4 1 + manufacturer: FIRSTLED + model: DL4S4BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL3S3BAC" + deviceLabel: Dynamic Lite Series 3x3 1 + manufacturer: FIRSTLED + model: DL3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL2S2BAC" + deviceLabel: Dynamic Lite Series 2x2 1 + manufacturer: FIRSTLED + model: DL2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL1S1BAC" + deviceLabel: Dynamic Lite Series 1x1 1 + manufacturer: FIRSTLED + model: DL1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/M1S1BAC" + deviceLabel: Mirror Series 1x1 1 + manufacturer: FIRSTLED + model: M1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/M2S2BAC" + deviceLabel: Mirror Series 2x2 1 + manufacturer: FIRSTLED + model: M2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DC2DC12MiV1" + deviceLabel: Easy-Fit Driver + manufacturer: FIRSTLED + model: DC2DC12MiV1 + deviceProfileName: light-color-temp-time-restore zigbeeGeneric: - id: "genericSwitch" deviceLabel: Zigbee Switch @@ -2260,6 +2569,26 @@ zigbeeGeneric: - 0x0008 #Level - 0x0300 #Color Control deviceProfileName: rgbw-bulb + - id: "genericZLLColorTempBulb" + deviceLabel: Zigbee Light + clusters: + server: + - 0x0006 #OnOff + - 0x0008 #Level + - 0x0300 #Color Control + deviceIdentifiers: + - 0x0220 + deviceProfileName: color-temp-bulb-2700K-6500K + - id: "genericZHAColorTempBulb" + deviceLabel: Zigbee Light + clusters: + server: + - 0x0006 #OnOff + - 0x0008 #Level + - 0x0300 #Color Control + deviceIdentifiers: + - 0x010C + deviceProfileName: color-temp-bulb-2700K-6500K - id: "genericMeteredSwitch" deviceLabel: Zigbee Switch clusters: @@ -2290,3 +2619,11 @@ zigbeeGeneric: - 0x0008 #Level - 0x0B04 #Electrical Measurment deviceProfileName: switch-level-power + - id: "genericMeteredSwitch/3" + deviceLabel: Zigbee Switch + clusters: + server: + - 0x0006 #OnOff + - 0x0702 #Simple Metering + - 0x0B04 #Electrical Measurment + deviceProfileName: switch-power diff --git a/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml index 8a0d62b1f6..28d233316c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml @@ -6,8 +6,12 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml index 65ee11beb0..c3d8f2f16d 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [2700, 6500] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml index 3b4462bce2..07cc4b4904 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml @@ -10,12 +10,12 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 - config: - values: - - key: "colorTemperature.value" - range: [2700, 6000] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-switch-module-no-power.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-switch-module-no-power.yml new file mode 100644 index 0000000000..b1552ddfb1 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-switch-module-no-power.yml @@ -0,0 +1,17 @@ +name: aqara-switch-module-no-power +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - preferenceId: stse.restorePowerState + explicit: true + - preferenceId: stse.electricSwitchType + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-switch-no-power.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-switch-no-power.yml new file mode 100644 index 0000000000..54ff6b731c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-switch-no-power.yml @@ -0,0 +1,19 @@ +name: aqara-switch-no-power +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - preferenceId: stse.restorePowerState + explicit: true + - preferenceId: stse.changeToWirelessSwitch + explicit: true \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml index fbe4243f6a..8395432142 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml new file mode 100644 index 0000000000..43b1953caf --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml @@ -0,0 +1,28 @@ +name: color-temp-bulb-2000k-6500k +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: colorTemperature + version: 1 + config: + values: + - key: "colorTemperature.value" + range: [ 2000, 6500 ] + - id: statelessColorTemperatureStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml index 0e542eff43..4a81aac14c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 4000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml index e8495a5b6c..420f43c959 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml index 985ec05a4f..221be95e5d 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml index e6ffe1a46f..7715942435 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2500, 6000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml index 15677d1307..a1b8fa2b7c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml index b56cb5f84e..2cbe79b906 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml index c30ba1d25f..e882f23098 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml new file mode 100644 index 0000000000..551061d7b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml @@ -0,0 +1,25 @@ +name: frient-io-output-switch +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 +preferences: + - title: "Output: On Time" + name: configOnTime + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 6553 + default: 0 + - title: "Output: Off Wait Time" + name: configOffWaitTime + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 6553 + default: 0 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/profiles/frient-switch-power-energy-voltage.yml b/drivers/SmartThings/zigbee-switch/profiles/frient-switch-power-energy-voltage.yml new file mode 100644 index 0000000000..685d9709ea --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/frient-switch-power-energy-voltage.yml @@ -0,0 +1,29 @@ +name: frient-switch-power-energy-voltage +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: powerSource + version: 1 + config: + values: + - key: "powerSource.value" + enabledValues: + - unknown + - mains + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug + diff --git a/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml index e0c09885c4..0381032825 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml new file mode 100644 index 0000000000..6a41cae14b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml @@ -0,0 +1,233 @@ +name: inovelli-vzm30-sn +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: statelessSwitchLevelStep + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: Switch + - id: button1 + label: Down Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + label: Up Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + label: Config Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "255": "Clear" + "1": "Solid" + "2": "Fast Blink" + "3": "Slow Blink" + "4": "Pulse" + "5": "Chase" + "6": "Open/Close" + "7": "Small-to-Big" + "8": "Aurora" + "9": "Slow Falling" + "10": "Medium Falling" + "11": "Fast Falling" + "12": "Slow Rising" + "13": "Medium Rising" + "14": "Fast Rising" + "15": "Medium Blink" + "16": "Slow Chase" + "17": "Fast Chase" + "18": "Fast Siren" + "19": "Slow Siren" + default: 1 + - name: "parameter258" + title: "258. Switch Mode" + description: "Use as a Dimmer or an On/Off switch" + required: false + preferenceType: enumeration + definition: + options: + "0": "Dimmer" + "1": "On/Off (default)" + default: 1 + - name: "parameter22" + title: "22. Aux Switch Type" + description: "Set the Aux switch type. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: false + preferenceType: enumeration + definition: + options: + "0": "None" + "1": "3-Way Aux Switch (default)" + default: 1 + - name: "parameter52" + title: "52. Smart Bulb Mode" + description: "For use with Smart Bulbs that need constant power and are controlled via commands rather than power. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: false + preferenceType: enumeration + definition: + options: + "0": "Disabled (default)" + "1": "Smart Bulb Mode" + default: 0 + - name: "parameter1" + title: "1. Dimming Speed (Remote)" + description: "This changes the speed that the light dims up when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + Default=25 (2500ms or 2.5s)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 126 + default: 25 + - name: "parameter2" + title: "2. Dimming Speed (Local)" + description: "This changes the speed that the light dims up when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter3" + title: "3. Ramp Rate (Remote)" + description: "This changes the speed that the light turns on when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=0" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 0 + - name: "parameter4" + title: "4. Ramp Rate (Local)" + description: "This changes the speed that the light turns on when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 3)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter11" + title: "11. Invert Switch" + description: "Inverts the orientation of the switch. Useful when the switch is installed upside down. Essentially up becomes down and down becomes up." + required: false + preferenceType: enumeration + definition: + options: + "0": "No (default)" + "1": "Yes" + default: 0 + - name: "parameter15" + title: "15. Level After Power Restored" + description: "The level the switch will return to when power is restored after power failure. + 0=Off + 1-100=Set Level + 101=Use previous level." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 101 + default: 101 + - name: "parameter95" + title: "95. LED Indicator Color (w/On)" + description: "Set the color of the Full LED Indicator when the load is on." + required: false + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter96" + title: "96. LED Indicator Color (w/Off)" + description: "Set the color of the Full LED Indicator when the load is off." + required: false + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter97" + title: "97. LED Indicator Intensity (w/On)" + description: "Set the intensity of the Full LED Indicator when the load is on." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 50 + - name: "parameter98" + title: "98. LED Indicator Intensity (w/Off)" + description: "Set the intensity of the Full LED Indicator when the load is off." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 5 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml new file mode 100644 index 0000000000..47368ca7e3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml @@ -0,0 +1,271 @@ +name: inovelli-vzm31-sn +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: statelessSwitchLevelStep + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + - id: configuration + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: Switch + - id: button1 + label: Down Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + label: Up Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + label: Config Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "255": "Clear" + "1": "Solid" + "2": "Fast Blink" + "3": "Slow Blink" + "4": "Pulse" + "5": "Chase" + "6": "Open/Close" + "7": "Small-to-Big" + "8": "Aurora" + "9": "Slow Falling" + "10": "Medium Falling" + "11": "Fast Falling" + "12": "Slow Rising" + "13": "Medium Rising" + "14": "Fast Rising" + "15": "Medium Blink" + "16": "Slow Chase" + "17": "Fast Chase" + "18": "Fast Siren" + "19": "Slow Siren" + default: 1 + - name: "parameter258" + title: "258. Switch Mode" + description: "Use as a Dimmer or an On/Off switch" + required: false + preferenceType: enumeration + definition: + options: + "0": "Dimmer" + "1": "On/Off (default)" + default: 1 + - name: "parameter22" + title: "22. Aux Switch Type" + description: "Set the Aux switch type. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: false + preferenceType: enumeration + definition: + options: + "0": "None (default)" + "1": "3-Way Dumb Switch" + "2": "3-Way Aux Switch" + "3": "Full Sine Wave (fw 2.11+)" + default: 0 + - name: "parameter52" + title: "52. Smart Bulb Mode" + description: "For use with Smart Bulbs that need constant power and are controlled via commands rather than power. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: false + preferenceType: enumeration + definition: + options: + "0": "Disabled (default)" + "1": "Smart Bulb Mode" + default: 0 + - name: "parameter1" + title: "1. Dimming Speed (Remote)" + description: "This changes the speed that the light dims up when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + Default=25 (2500ms or 2.5s)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 126 + default: 25 + - name: "parameter2" + title: "2. Dimming Speed (Local)" + description: "This changes the speed that the light dims up when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter3" + title: "3. Ramp Rate (Remote)" + description: "This changes the speed that the light turns on when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter4" + title: "4. Ramp Rate (Local)" + description: "This changes the speed that the light turns on when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 3)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter9" + title: "9. Minimum Level" + description: "The minimum level that the light can be dimmed. Useful when the user has a light that does not turn on or flickers at a lower level." + required: false + preferenceType: number + definition: + minimum: 1 + maximum: 99 + default: 1 + - name: "parameter10" + title: "10. Maximum Level" + description: "The maximum level that the light can be dimmed. Useful when the user wants to limit the maximum brighness." + required: false + preferenceType: number + definition: + minimum: 2 + maximum: 100 + default: 100 + - name: "parameter11" + title: "11. Invert Switch" + description: "Inverts the orientation of the switch. Useful when the switch is installed upside down. Essentially up becomes down and down becomes up." + required: false + preferenceType: enumeration + definition: + options: + "0": "No (default)" + "1": "Yes" + default: 0 + - name: "parameter15" + title: "15. Level After Power Restored" + description: "The level the switch will return to when power is restored after power failure. + 0=Off + 1-100=Set Level + 101=Use previous level." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 101 + default: 101 + - name: "parameter17" + title: "17. Load Level Indicator Timeout" + description: "Shows the level that the load is at for x number of seconds after the load is adjusted and then returns to the Default LED state." + required: false + preferenceType: enumeration + definition: + options: + "0": "Do not display Load Level" + "1": "1 Second" + "2": "2 Seconds" + "3": "3 Seconds" + "4": "4 Seconds" + "5": "5 Seconds" + "6": "6 Seconds" + "7": "7 Seconds" + "8": "8 Seconds" + "9": "9 Seconds" + "10": "10 Seconds" + "11": "Display Load Level with no timeout" + default: 11 + - name: "parameter95" + title: "95. LED Indicator Color (w/On)" + description: "Set the color of the Full LED Indicator when the load is on." + required: false + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter96" + title: "96. LED Indicator Color (w/Off)" + description: "Set the color of the Full LED Indicator when the load is off." + required: false + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter97" + title: "97. LED Indicator Intensity (w/On)" + description: "Set the intensity of the Full LED Indicator when the load is on." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 50 + - name: "parameter98" + title: "98. LED Indicator Intensity (w/Off)" + description: "Set the intensity of the Full LED Indicator when the load is off." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 5 diff --git a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml new file mode 100644 index 0000000000..fe3b3de11b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml @@ -0,0 +1,357 @@ +name: inovelli-vzm32-sn +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: statelessSwitchLevelStep + version: 1 + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + config: + values: + - key: "illuminance.value" + range: [0, 5000] + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: Switch + - id: button1 + label: Down Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + label: Up Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + label: Config Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "255": "Clear" + "1": "Solid" + "2": "Fast Blink" + "3": "Slow Blink" + "4": "Pulse" + "5": "Chase" + "6": "Open/Close" + "7": "Small-to-Big" + "8": "Aurora" + "9": "Slow Falling" + "10": "Medium Falling" + "11": "Fast Falling" + "12": "Slow Rising" + "13": "Medium Rising" + "14": "Fast Rising" + "15": "Medium Blink" + "16": "Slow Chase" + "17": "Fast Chase" + "18": "Fast Siren" + "19": "Slow Siren" + default: 1 + - name: "parameter258" + title: "258. Switch Mode" + description: "Use as a Dimmer or an On/Off switch" + required: false + preferenceType: enumeration + definition: + options: + "0": "Dimmer (default)" + "1": "On/Off" + default: 0 + - name: "parameter52" + title: "52. Smart Bulb Mode" + description: "For use with Smart Bulbs that need constant power and are controlled via commands rather than power. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: false + preferenceType: enumeration + definition: + options: + "0": "Disabled (default)" + "1": "Smart Bulb Mode" + default: 0 + - name: "parameter1" + title: "1. Dimming Speed (Remote)" + description: "This changes the speed that the light dims up when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + Default=25 (2500ms or 2.5s)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 126 + default: 25 + - name: "parameter2" + title: "2. Dimming Speed (Local)" + description: "This changes the speed that the light dims up when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter3" + title: "3. Ramp Rate (Remote)" + description: "This changes the speed that the light turns on when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter4" + title: "4. Ramp Rate (Local)" + description: "This changes the speed that the light turns on when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=127 (Sync with parameter 3)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 127 + default: 127 + - name: "parameter9" + title: "9. Minimum Level" + description: "The minimum level that the light can be dimmed. Useful when the user has a light that does not turn on or flickers at a lower level." + required: false + preferenceType: number + definition: + minimum: 1 + maximum: 99 + default: 1 + - name: "parameter10" + title: "10. Maximum Level" + description: "The maximum level that the light can be dimmed. Useful when the user wants to limit the maximum brighness." + required: false + preferenceType: number + definition: + minimum: 2 + maximum: 100 + default: 100 + - name: "parameter15" + title: "15. Level After Power Restored" + description: "The level the switch will return to when power is restored after power failure. + 0=Off + 1-100=Set Level + 101=Use previous level." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 101 + default: 101 + - name: "parameter95" + title: "95. LED Indicator Color (w/On)" + description: "Set the color of the Full LED Indicator when the load is on." + required: false + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter96" + title: "96. LED Indicator Color (w/Off)" + description: "Set the color of the Full LED Indicator when the load is off." + required: false + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter97" + title: "97. LED Indicator Intensity (w/On)" + description: "Set the intensity of the Full LED Indicator when the load is on." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 50 + - name: "parameter98" + title: "98. LED Indicator Intensity (w/Off)" + description: "Set the intensity of the Full LED Indicator when the load is off." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 5 + - name: "parameter101" + title: "101. mmWave Height Minimum (Floor)" + description: "Minimum range of the Z-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: -300 + - name: "parameter102" + title: "102. mmWave Height Maximum (Ceiling)" + description: "Maximum range of the Z-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: 300 + - name: "parameter103" + title: "103. mmWave Width Minimum (Left)" + description: "Minimum range of the X-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: -600 + - name: "parameter104" + title: "104. mmWave Width Maximum (Right)" + description: "Maximum range of the X-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: 600 + - name: "parameter105" + title: "105. mmWave Depth Minimum (Near)" + description: "Minimum range of the Y-Axis in cm" + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 600 + default: 0 + - name: "parameter106" + title: "106. mmWave Depth Maximum (Far)" + description: "Maximum range of the Y-Axis in cm" + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 600 + default: 600 + - name: "parameter110" + title: "110. Light On Presence Behavior" + description: "When presence is detected, choose how to control the light load" + required: true + preferenceType: enumeration + definition: + options: + "0": "Disabled" + "1": "Auto On/Off when occupied (default)" + "2": "Auto Off when vacant" + "3": "Auto On when occupied" + "4": "Auto On/Off when Vacant" + "5": "Auto On when Vacant" + "6": "Auto Off when Occupied" + default: 1 + - name: "parameter111" + title: "111. mmWave Control Commands" + description: "Advanced commands to send to the mmWave Module (Please see documentation)" + required: false + preferenceType: enumeration + definition: + options: + "1": "Set Interference Area" + "3": "Clear Interference Area" + "0": "Factory Reset Module" + default: 3 + - name: "parameter112" + title: "112. mmWave Detection Sensitivity" + description: "Adjust the sensitivity of the mmWave sensor. 0-Low, 1-Medium, 2-High." + required: false + preferenceType: enumeration + definition: + options: + "0": "Low" + "1": "Medium" + "2": "High (default)" + default: 2 + - name: "parameter113" + title: "113. mmWave Detection Delay" + description: "The time from detecting a person to triggering an action. 0-Low (5s), 1-Medium (1s), 2-Fast (0.2s)." + required: false + preferenceType: enumeration + definition: + options: + "0": "5 seconds" + "1": "1 second" + "2": "0.2 seconds (default)" + default: 2 + - name: "parameter114" + title: "114. mmWave Time Out" + description: "Adjust the timeout after presence is no longer detected. After the timeout the load will turn off." + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 4294967295 + default: 30 + - name: "parameter34" + title: "34. OTA Image Type" + description: "Which endpoint should the switch advertise for OTA update (Zigbee, mmWave, or both)." + required: true + preferenceType: enumeration + definition: + options: + "0": "Zigbee (default)" + "1": "mmWave" + "2": "Alternating" + default: 0 diff --git a/drivers/SmartThings/zigbee-switch/profiles/light-color-temp-time-restore.yml b/drivers/SmartThings/zigbee-switch/profiles/light-color-temp-time-restore.yml new file mode 100644 index 0000000000..45fd965a6c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/light-color-temp-time-restore.yml @@ -0,0 +1,74 @@ +name: light-color-temp-time-restore +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: colorTemperature + version: 1 + - id: statelessColorTemperatureStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +preferences: + - title: "上电状态(power-on state/파워온 상태)" + name: powerOnMode + description: "上电状态(power-on state/파워온 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 0 + - title: "驱动电流(drive current/구동 전류)" + name: ledDriveCurrent + description: "驱动电流(drive current/구동 전류)/50~333mA" + required: false + preferenceType: integer + definition: + minimum: 50 + maximum: 333 + default: 270 + - title: "亮度时间(dimmingTransitionTime/밝기 전환 시간)" + name: dimTransitionTime + description: "亮度过渡时间(dimming transition time/밝기 전환 시간)1000~10000ms" + required: false + preferenceType: integer + definition: + minimum: 1000 + maximum: 10000 + default: 1000 + - title: "色温时间(CCTTransitionTime/색온 전환 시간)" + name: colorTempTransitionTime + description: "色温过渡时间(CCT transition time/색온 전환 시간)1000~10000ms" + required: false + preferenceType: integer + definition: + minimum: 1000 + maximum: 10000 + default: 1000 + - title: "输出模式(output mode/출력 모드)" + name: outputMode + description: "输出模式(output mode/출력 모드)" + required: false + preferenceType: enumeration + definition: + options: + 0: "双路CW(dual CW/듀얼 CW)" + 1: "双路WC(dual WC/듀얼 WC)" + 2: "单路(single channel/단일 채널)" + default: 0 diff --git a/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml b/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml index b45fc5e0e8..ac516c23c0 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: energyMeter diff --git a/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml b/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml index 6eca96ab18..a07047269b 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml index 0d8688cb6b..dc5e6e0ca2 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml index 248bd66e7f..f344f32a34 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: motionSensor version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml index a25ef4aa2c..f5ef30908f 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml index 350c51c722..67f27f7289 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml b/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml index d6ac987d50..a234f15409 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml index c95d6c4b16..6f6a78a4b5 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1800, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml index 89af9a7f94..bf7f81832d 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2000, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml index 2e17ba527d..b1c7d3e379 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 4000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml index d83b671f12..9032ba0fe0 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml index 0d334ca62e..1a13390cf5 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml index 3bd54e3a59..c74ba232c0 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2500, 6000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml index 740a002b83..466a34c06a 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml new file mode 100644 index 0000000000..dcab0e8224 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml @@ -0,0 +1,26 @@ +name: rgbw-bulb-2700K-6500K +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: statelessSwitchLevelStep + version: 1 + - id: colorTemperature + version: 1 + config: + values: + - key: "colorTemperature.value" + range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 + - id: colorControl + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml index b863f9e587..57f566bdfb 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml new file mode 100644 index 0000000000..c6120b2561 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml @@ -0,0 +1,107 @@ +name: switch-4inputs-2outputs +components: + - id: main + capabilities: + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch + - id: input1 + label: "Input 1" + capabilities: + - id: switch + version: 1 + - id: input2 + label: "Input 2" + capabilities: + - id: switch + version: 1 + - id: input3 + label: "Input 3" + capabilities: + - id: switch + version: 1 + - id: input4 + label: "Input 4" + capabilities: + - id: switch + version: 1 +preferences: + # Input 1 + - title: "Input 1: Reverse Polarity" + name: reversePolarity1 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 1: Control Output 1" + name: controlOutput11 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 1: Control Output 2" + name: controlOutput21 + required: true + preferenceType: boolean + definition: + default: false + # Input 2 + - title: "Input 2: Reverse Polarity" + name: reversePolarity2 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 2: Control Output 1" + name: controlOutput12 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 2: Control Output 2" + name: controlOutput22 + required: true + preferenceType: boolean + definition: + default: false + # Input 3 + - title: "Input 3: Reverse Polarity" + name: reversePolarity3 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 3: Control Output 1" + name: controlOutput13 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 3: Control Output 2" + name: controlOutput23 + required: true + preferenceType: boolean + definition: + default: false + # Input 4 + - title: "Input 4: Reverse Polarity" + name: reversePolarity4 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 4: Control Output 1" + name: controlOutput14 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 4: Control Output 2" + name: controlOutput24 + required: true + preferenceType: boolean + definition: + default: false \ No newline at end of file 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-dimmer-power-energy.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-dimmer-power-energy.yml index 4507ab5282..623156bf6c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/switch-dimmer-power-energy.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-dimmer-power-energy.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: energyMeter diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml index 2042471bf3..43b2b26581 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml index 96166ef0d9..abcaba3e21 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh 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-smart-bath-heater-laisiao.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-smart-bath-heater-laisiao.yml new file mode 100755 index 0000000000..214784e70d --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-smart-bath-heater-laisiao.yml @@ -0,0 +1,62 @@ +name: switch-smart-bath-heater-laisiao +components: + - id: main + label: "待机" + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: BathroomHeater + - id: switch2 + label: "照明" + capabilities: + - id: switch + version: 1 + categories: + - name: BathroomHeater + - id: switch3 + label: "强暖" + capabilities: + - id: switch + version: 1 + categories: + - name: BathroomHeater + - id: switch4 + label: "弱暖" + capabilities: + - id: switch + version: 1 + categories: + - name: BathroomHeater + - id: switch5 + label: "换气" + capabilities: + - id: switch + version: 1 + categories: + - name: BathroomHeater + - id: switch6 + label: "自然风" + capabilities: + - id: switch + version: 1 + categories: + - name: BathroomHeater + - id: switch7 + label: "干燥" + capabilities: + - id: switch + version: 1 + categories: + - name: BathroomHeater + - id: switch8 + label: "摆页" + capabilities: + - id: switch + version: 1 + categories: + - name: BathroomHeater 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/aqara-light/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara-light/can_handle.lua new file mode 100644 index 0000000000..7b20caf5c7 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara-light/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.light.acn004" }, + { mfr = "Aqara", model = "lumi.light.acn014" }, + { mfr = "LUMI", model = "lumi.light.cwacn1" } + } + + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("aqara-light") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua index 7225246299..2192716b21 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua @@ -1,7 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + 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 preferences = require "preferences" local OnOff = clusters.OnOff local Level = clusters.Level @@ -11,34 +15,33 @@ local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.light.acn004" }, - { mfr = "Aqara", model = "lumi.light.acn014" } -} - -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("aqara-light") - return true, subdriver - end - end - return false -end - local function do_refresh(self, device) device:send(OnOff.attributes.OnOff:read(device)) device:send(Level.attributes.CurrentLevel:read(device)) device:send(ColorControl.attributes.ColorTemperatureMireds:read(device)) end -local function do_configure(self, device) - device:configure() +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +local function device_added(driver, device, event) device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1)) -- private - device:send(Level.attributes.OnTransitionTime:write(device, 0)) - device:send(Level.attributes.OffTransitionTime:write(device, 0)) + local value = { minimum = 2700, maximum = 6000 } + if device:get_model() == "lumi.light.cwacn1" then + value.maximum = 6500 + end + emit_event_if_latest_state_missing(device, "main", capabilities.colorTemperature, capabilities.colorTemperature.colorTemperatureRange.NAME, capabilities.colorTemperature.colorTemperatureRange(value)) +end + +local function do_configure(self, device) + device:configure() + + preferences.sync_preferences(self, device) device:send(ColorControl.commands.MoveToColorTemperature(device, 200, 0x0000)) do_refresh(self, device) @@ -54,6 +57,7 @@ end local aqara_light_handler = { NAME = "Aqara Light Handler", lifecycle_handlers = { + added = device_added, doConfigure = do_configure }, capability_handlers = { @@ -61,7 +65,7 @@ local aqara_light_handler = { [capabilities.switchLevel.commands.setLevel.NAME] = set_level_handler } }, - can_handle = is_aqara_products + can_handle = require("aqara-light.can_handle"), } return aqara_light_handler diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e183f78266 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("aqara") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..63f94e2be3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua @@ -0,0 +1,22 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "LUMI", model = "lumi.plug.maeu01" }, + { mfr = "LUMI", model = "lumi.plug.macn01" }, + { mfr = "LUMI", model = "lumi.switch.n0agl1" }, + { mfr = "LUMI", model = "lumi.switch.l0agl1" }, + { mfr = "LUMI", model = "lumi.switch.n0acn2" }, + { mfr = "LUMI", model = "lumi.switch.n1acn1" }, + { mfr = "LUMI", model = "lumi.switch.n2acn1" }, + { mfr = "LUMI", model = "lumi.switch.n3acn1" }, + { mfr = "LUMI", model = "lumi.switch.b1laus01" }, + { mfr = "LUMI", model = "lumi.switch.b2laus01" }, + { mfr = "LUMI", model = "lumi.switch.n1aeu1" }, + { mfr = "LUMI", model = "lumi.switch.n2aeu1" }, + { mfr = "LUMI", model = "lumi.switch.l1aeu1" }, + { mfr = "LUMI", model = "lumi.switch.l2aeu1" }, + { mfr = "LUMI", model = "lumi.switch.b1nacn01" }, + { mfr = "LUMI", model = "lumi.switch.b2nacn01" }, + { mfr = "LUMI", model = "lumi.switch.b3n01" } + } diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara/init.lua index 7e49b2b395..0c64b594fe 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -24,17 +27,6 @@ local ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID = 0x000A local LAST_REPORT_TIME = "LAST_REPORT_TIME" local PRIVATE_MODE = "PRIVATE_MODE" -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.plug.maeu01" }, - { mfr = "LUMI", model = "lumi.plug.macn01" }, - { mfr = "LUMI", model = "lumi.switch.n0agl1" }, - { mfr = "LUMI", model = "lumi.switch.n0acn2" }, - { mfr = "LUMI", model = "lumi.switch.n1acn1" }, - { mfr = "LUMI", model = "lumi.switch.n2acn1" }, - { mfr = "LUMI", model = "lumi.switch.n3acn1" }, - { mfr = "LUMI", model = "lumi.switch.b2laus01" } -} - local preference_map = { ["stse.restorePowerState"] = { cluster_id = PRIVATE_CLUSTER_ID, @@ -128,15 +120,6 @@ local preference_map = { }, } -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("aqara") - return true, subdriver - end - end - return false -end local function private_mode_handler(driver, device, value, zb_rx) device:set_field(PRIVATE_MODE, value.value, { persist = true }) @@ -157,9 +140,20 @@ local function wireless_switch_handler(driver, device, value, zb_rx) end end -local function energy_meter_power_consumption_report(device, raw_value) +local function energy_meter_power_consumption_report(driver, device, value, zb_rx) + -- ignore unexpected event when the device is private mode + local private_mode = device:get_field(PRIVATE_MODE) or 0 + if private_mode == 1 then return end + + local raw_value = value.value -- energy meter - device:emit_event(capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) + local offset = device:get_field(constants.ENERGY_METER_OFFSET) or 0 + if raw_value < offset then + --- somehow our value has gone below the offset, so we'll reset the offset, since the device seems to have + offset = 0 + device:set_field(constants.ENERGY_METER_OFFSET, offset, {persist = true}) + end + device:emit_event(capabilities.energyMeter.energy({ value = raw_value - offset, unit = "Wh" })) -- report interval local current_time = os.time() @@ -181,20 +175,21 @@ local function energy_meter_power_consumption_report(device, raw_value) end local function power_meter_handler(driver, device, value, zb_rx) + -- ignore unexpected event when the device is private mode + local private_mode = device:get_field(PRIVATE_MODE) or 0 + if private_mode == 1 then return end + local raw_value = value.value -- '10W' raw_value = raw_value / 10 device:emit_event(capabilities.powerMeter.power({ value = raw_value, unit = "W" })) end -local function energy_meter_handler(driver, device, value, zb_rx) - local raw_value = value.value -- 'Wh' - energy_meter_power_consumption_report(device, raw_value) -end - local function do_refresh(self, device) device:send(OnOff.attributes.OnOff:read(device)) - device:send(ElectricalMeasurement.attributes.ActivePower:read(device)) - device:send(SimpleMetering.attributes.CurrentSummationDelivered:read(device)) + if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then + device:send(ElectricalMeasurement.attributes.ActivePower:read(device)) + device:send(SimpleMetering.attributes.CurrentSummationDelivered:read(device)) + end end local function device_info_changed(driver, device, event, args) @@ -224,9 +219,13 @@ local function do_configure(self, device) end local function device_added(driver, device) - device:emit_event(capabilities.powerMeter.power({ value = 0.0, unit = "W" })) - device:emit_event(capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) - + if (device:supports_capability_by_id(capabilities.button.ID)) then + device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) + end + if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then + device:emit_event(capabilities.powerMeter.power({ value = 0.0, unit = "W" })) + device:emit_event(capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + end end local aqara_switch_handler = { @@ -247,7 +246,7 @@ local aqara_switch_handler = { [ElectricalMeasurement.attributes.ActivePower.ID] = power_meter_handler }, [SimpleMetering.ID] = { - [SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_meter_handler + [SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_meter_power_consumption_report }, [WIRELESS_SWITCH_CLUSTER_ID] = { [WIRELESS_SWITCH_ATTRIBUTE_ID] = wireless_switch_handler @@ -261,7 +260,7 @@ local aqara_switch_handler = { require("aqara.version"), require("aqara.multi-switch") }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_switch_handler diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua new file mode 100644 index 0000000000..1524296cff --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local FINGERPRINTS = require("aqara.multi-switch.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara.multi-switch") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua new file mode 100644 index 0000000000..71ed1b26b3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "LUMI", model = "lumi.switch.n1acn1", children = 1, child_profile = "" }, + { mfr = "LUMI", model = "lumi.switch.n2acn1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.n3acn1", children = 3, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b1laus01", children = 1, child_profile = "" }, + { mfr = "LUMI", model = "lumi.switch.b2laus01", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.l2aeu1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.n2aeu1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b2nacn01", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b3n01", children = 3, child_profile = "aqara-switch-child" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua index 40de476c0e..4b2146f03c 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua @@ -1,27 +1,17 @@ +-- Copyright 2025 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 configurations = require "configurations" +local switch_utils = require "switch_utils" local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F - -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.switch.n1acn1", children = 1, child_profile = "" }, - { mfr = "LUMI", model = "lumi.switch.n2acn1", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.n3acn1", children = 3, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.b2laus01", children = 2, child_profile = "aqara-switch-child" } -} - -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end +local FINGERPRINTS = require("aqara.multi-switch.fingerprints") local function get_children_amount(device) for _, fingerprint in ipairs(FINGERPRINTS) do @@ -83,7 +73,7 @@ local function device_added(driver, device) end device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) - device:emit_event(capabilities.button.button.pushed({ state_change = false })) + switch_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) end local function device_init(self, device) @@ -96,10 +86,10 @@ end local aqara_multi_switch_handler = { NAME = "Aqara Multi Switch Handler", lifecycle_handlers = { - init = device_init, + init = configurations.reconfig_wrapper(device_init), added = device_added }, - can_handle = is_aqara_products + can_handle = require("aqara.multi-switch.can_handle"), } return aqara_multi_switch_handler diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/aqara/sub_drivers.lua new file mode 100644 index 0000000000..d064785cb4 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("aqara.multi-switch"), + lazy_load("aqara.version"), +} diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/version/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara/version/can_handle.lua new file mode 100644 index 0000000000..d56744fefa --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara/version/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function (opts, driver, device) + local PRIVATE_MODE = "PRIVATE_MODE" + local private_mode = device:get_field(PRIVATE_MODE) or 0 + local res = private_mode == 1 + if res then + return res, require("aqara.version") + else + return res + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/version/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara/version/init.lua index 41776ab308..bca3e9fb88 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/version/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/version/init.lua @@ -1,5 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" +local constants = require "st.zigbee.constants" local OnOff = clusters.OnOff local AnalogInput = clusters.AnalogInput @@ -22,7 +26,13 @@ end local function energy_meter_power_consumption_report(device, raw_value) -- energy meter - device:emit_event(capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) + local offset = device:get_field(constants.ENERGY_METER_OFFSET) or 0 + if raw_value < offset then + --- somehow our value has gone below the offset, so we'll reset the offset, since the device seems to have + offset = 0 + device:set_field(constants.ENERGY_METER_OFFSET, offset, {persist = true}) + end + device:emit_event(capabilities.energyMeter.energy({ value = raw_value - offset, unit = "Wh" })) -- report interval local current_time = os.time() @@ -49,6 +59,10 @@ local function round(num) end local function present_value_handler(driver, device, value, zb_rx) + -- ignore unexpected event when the device is not private mode + local private_mode = device:get_field(PRIVATE_MODE) or 0 + if private_mode == 0 then return end + local src_endpoint = zb_rx.address_header.src_endpoint.value if src_endpoint == POWER_METER_ENDPOINT then -- power meter @@ -68,8 +82,10 @@ end local function do_refresh(self, device) device:send(OnOff.attributes.OnOff:read(device)) - device:send(AnalogInput.attributes.PresentValue:read(device):to_endpoint(POWER_METER_ENDPOINT)) - device:send(AnalogInput.attributes.PresentValue:read(device):to_endpoint(ENERGY_METER_ENDPOINT)) + if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then + device:send(AnalogInput.attributes.PresentValue:read(device):to_endpoint(POWER_METER_ENDPOINT)) + device:send(AnalogInput.attributes.PresentValue:read(device):to_endpoint(ENERGY_METER_ENDPOINT)) + end end local aqara_switch_version_handler = { @@ -89,10 +105,7 @@ local aqara_switch_version_handler = { } } }, - can_handle = function (opts, driver, device) - local private_mode = device:get_field(PRIVATE_MODE) or 0 - return private_mode == 1 - end + can_handle = require("aqara.version.can_handle") } return aqara_switch_version_handler diff --git a/drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/can_handle.lua new file mode 100644 index 0000000000..49b42ddb35 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/can_handle.lua @@ -0,0 +1,24 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- There are reports of at least one device (SONOFF 01MINIZB) which occasionally +-- reports this value as an Int8, rather than a Boolean, as per the spec +return function(opts, driver, device, zb_rx, ...) + local zcl_clusters = require "st.zigbee.zcl.clusters" + local data_types = require "st.zigbee.data_types" + local can_handle = opts.dispatcher_class == "ZigbeeMessageDispatcher" and + device:get_manufacturer() == "SONOFF" and + zb_rx.body and + zb_rx.body.zcl_body and + zb_rx.body.zcl_body.attr_records and + zb_rx.address_header.cluster.value == zcl_clusters.OnOff.ID and + zb_rx.body.zcl_body.attr_records[1].attr_id.value == zcl_clusters.OnOff.attributes.OnOff.ID and + zb_rx.body.zcl_body.attr_records[1].data_type.value ~= data_types.Boolean.ID + if can_handle then + local subdriver = require("bad_on_off_data_type") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/init.lua b/drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/init.lua index ee0c82977e..2a59166bbb 100644 --- a/drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/bad_on_off_data_type/init.lua @@ -1,40 +1,9 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local zcl_clusters = require "st.zigbee.zcl.clusters" -local data_types = require "st.zigbee.data_types" local capabilities = require "st.capabilities" --- There are reports of at least one device (SONOFF 01MINIZB) which occasionally --- reports this value as an Int8, rather than a Boolean, as per the spec -local function incorrect_data_type_detected(opts, driver, device, zb_rx, ...) - local can_handle = opts.dispatcher_class == "ZigbeeMessageDispatcher" and - device:get_manufacturer() == "SONOFF" and - zb_rx.body and - zb_rx.body.zcl_body and - zb_rx.body.zcl_body.attr_records and - zb_rx.address_header.cluster.value == zcl_clusters.OnOff.ID and - zb_rx.body.zcl_body.attr_records[1].attr_id.value == zcl_clusters.OnOff.attributes.OnOff.ID and - zb_rx.body.zcl_body.attr_records[1].data_type.value ~= data_types.Boolean.ID - if can_handle then - local subdriver = require("bad_on_off_data_type") - return true, subdriver - else - return false - end -end - local function on_off_attr_handler(driver, device, value, zb_rx) local attr = capabilities.switch.switch device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, value.value == 0 and attr.off() or attr.on()) @@ -49,7 +18,7 @@ local bad_on_off_data_type = { } } }, - can_handle = incorrect_data_type_detected + can_handle = require("bad_on_off_data_type.can_handle"), } -return bad_on_off_data_type \ No newline at end of file +return bad_on_off_data_type diff --git a/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/can_handle.lua new file mode 100644 index 0000000000..56cd729659 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +return function(opts, driver, device) + if device:supports_capability(capabilities.colorTemperature) then + local subdriver = require("color_temp_range_handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/init.lua b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/init.lua new file mode 100644 index 0000000000..84185557af --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/init.lua @@ -0,0 +1,75 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local switch_utils = require "switch_utils" + +-- These values are a "sanity check" to ensure that max/min values we are getting are reasonable +local COLOR_TEMPERATURE_MIRED_MAX = 1000 -- 1000 Kelvin +local COLOR_TEMPERATURE_MIRED_MIN = 67 -- 15000 Kelvin + +local function color_temp_min_mireds_handler(driver, device, value, zb_rx) + -- if mired value is nil or outside of sane bounds, log and ignore. Else, save value + local min_mired_bound = value.value + if min_mired_bound == nil then + return + elseif (min_mired_bound < COLOR_TEMPERATURE_MIRED_MIN or min_mired_bound > COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", min_mired_bound, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) + return + end + device:set_field(switch_utils.MIRED_MIN_BOUND, min_mired_bound, {persist = true}) + + -- if we have already received a valid max mired bound, emit a colorTemperatureRange event + local max_mired_bound = device:get_field(switch_utils.MIRED_MAX_BOUND) + if max_mired_bound == nil then + return + elseif min_mired_bound < max_mired_bound then + local endpoint = zb_rx.address_header.src_endpoint.value + local max_kelvin_bound = switch_utils.convert_mired_to_kelvin(min_mired_bound) + local min_kelvin_bound = switch_utils.convert_mired_to_kelvin(max_mired_bound) + device:emit_event_for_endpoint(endpoint, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min_kelvin_bound, maximum = max_kelvin_bound}})) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a max color temperature %d Mireds that is not higher than the reported min color temperature %d Mireds", max_mired_bound, min_mired_bound)) + end +end + +local function color_temp_max_mireds_handler(driver, device, value, zb_rx) + -- if mired value is nil or outside of sane bounds, log and ignore. Else, save value + local max_mired_bound = value.value + if max_mired_bound == nil then + return + elseif (max_mired_bound < COLOR_TEMPERATURE_MIRED_MIN or max_mired_bound > COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", max_mired_bound, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) + return + end + device:set_field(switch_utils.MIRED_MAX_BOUND, max_mired_bound, {persist = true}) + + -- if we have already received a valid min mired bound, emit a colorTemperatureRange event + local min_mired_bound = device:get_field(switch_utils.MIRED_MIN_BOUND) + if min_mired_bound == nil then + return + elseif max_mired_bound > min_mired_bound then + local endpoint = zb_rx.address_header.src_endpoint.value + local max_kelvin_bound = switch_utils.convert_mired_to_kelvin(min_mired_bound) + local min_kelvin_bound = switch_utils.convert_mired_to_kelvin(max_mired_bound) + device:emit_event_for_endpoint(endpoint, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min_kelvin_bound, maximum = max_kelvin_bound}})) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min color temperature %d Mireds that is not lower than the reported max color temperature %d Mireds", min_mired_bound, max_mired_bound)) + end +end + +local color_temp_range_handlers = { + NAME = "Color temp range handlers", + zigbee_handlers = { + attr = { + [clusters.ColorControl.ID] = { + [clusters.ColorControl.attributes.ColorTempPhysicalMinMireds.ID] = color_temp_min_mireds_handler, + [clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds.ID] = color_temp_max_mireds_handler + } + } + }, + can_handle = require("color_temp_range_handlers.can_handle") +} + +return color_temp_range_handlers diff --git a/drivers/SmartThings/zigbee-switch/src/configurations.lua b/drivers/SmartThings/zigbee-switch/src/configurations.lua deleted file mode 100644 index 828c223d67..0000000000 --- a/drivers/SmartThings/zigbee-switch/src/configurations.lua +++ /dev/null @@ -1,87 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local clusters = require "st.zigbee.zcl.clusters" -local constants = require "st.zigbee.constants" - -local ColorControl = clusters.ColorControl -local IASZone = clusters.IASZone - -local devices = { - IKEA_RGB_BULB = { - FINGERPRINTS = { - { mfr = "IKEA of Sweden", model = "TRADFRI bulb E27 CWS opal 600lm" }, - { mfr = "IKEA of Sweden", model = "TRADFRI bulb E26 CWS opal 600lm" } - }, - CONFIGURATION = { - { - cluster = ColorControl.ID, - attribute = ColorControl.attributes.CurrentX.ID, - minimum_interval = 1, - maximum_interval = 3600, - data_type = ColorControl.attributes.CurrentX.base_type, - reportable_change = 16 - }, - { - cluster = ColorControl.ID, - attribute = ColorControl.attributes.CurrentY.ID, - minimum_interval = 1, - maximum_interval = 3600, - data_type = ColorControl.attributes.CurrentY.base_type, - reportable_change = 16 - } - } - }, - SENGLED_BULB_WITH_MOTION_SENSOR = { - FINGERPRINTS = { - { mfr = "sengled", model = "E13-N11" } - }, - CONFIGURATION = { - { - cluster = IASZone.ID, - attribute = IASZone.attributes.ZoneStatus.ID, - minimum_interval = 30, - maximum_interval = 300, - data_type = IASZone.attributes.ZoneStatus.base_type - } - }, - IAS_ZONE_CONFIG_METHOD = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE - } -} - -local configurations = {} - -configurations.get_device_configuration = function(zigbee_device) - for _, device in pairs(devices) do - for _, fingerprint in pairs(device.FINGERPRINTS) do - if zigbee_device:get_manufacturer() == fingerprint.mfr and zigbee_device:get_model() == fingerprint.model then - return device.CONFIGURATION - end - end - end - return nil -end - -configurations.get_ias_zone_config_method = function(zigbee_device) - for _, device in pairs(devices) do - for _, fingerprint in pairs(device.FINGERPRINTS) do - if zigbee_device:get_manufacturer() == fingerprint.mfr and zigbee_device:get_model() == fingerprint.model then - return device.IAS_ZONE_CONFIG_METHOD - end - end - end - return nil -end - -return configurations diff --git a/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua new file mode 100644 index 0000000000..d49d10e9c5 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua @@ -0,0 +1,176 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local ColorControl = clusters.ColorControl +local IASZone = clusters.IASZone +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local Alarms = clusters.Alarms +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +local constants = require "st.zigbee.constants" +local data_types = require "st.zigbee.data_types" + +local devices = { + IKEA_RGB_BULB = { + FINGERPRINTS = { + { mfr = "IKEA of Sweden", model = "TRADFRI bulb E27 CWS opal 600lm" }, + { mfr = "IKEA of Sweden", model = "TRADFRI bulb E26 CWS opal 600lm" } + }, + CONFIGURATION = { + { + cluster = ColorControl.ID, + attribute = ColorControl.attributes.CurrentX.ID, + minimum_interval = 1, + maximum_interval = 3600, + data_type = ColorControl.attributes.CurrentX.base_type, + reportable_change = 16 + }, + { + cluster = ColorControl.ID, + attribute = ColorControl.attributes.CurrentY.ID, + minimum_interval = 1, + maximum_interval = 3600, + data_type = ColorControl.attributes.CurrentY.base_type, + reportable_change = 16 + } + } + }, + SENGLED_BULB_WITH_MOTION_SENSOR = { + FINGERPRINTS = { + { mfr = "sengled", model = "E13-N11" } + }, + CONFIGURATION = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 30, + maximum_interval = 300, + data_type = IASZone.attributes.ZoneStatus.base_type + } + }, + IAS_ZONE_CONFIG_METHOD = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE + }, + FRIENT_SWITCHES = { + FINGERPRINTS = { + { mfr = "frient A/S", model = "SPLZB-131" }, + { mfr = "frient A/S", model = "SPLZB-132" }, + { mfr = "frient A/S", model = "SPLZB-134" }, + { mfr = "frient A/S", model = "SPLZB-137" }, + { mfr = "frient A/S", model = "SPLZB-141" }, + { mfr = "frient A/S", model = "SPLZB-142" }, + { mfr = "frient A/S", model = "SPLZB-144" }, + { mfr = "frient A/S", model = "SPLZB-147" }, + { mfr = "frient A/S", model = "SMRZB-143" }, + { mfr = "frient A/S", model = "SMRZB-153" }, + { mfr = "frient A/S", model = "SMRZB-332" }, + { mfr = "frient A/S", model = "SMRZB-342" } + }, + CONFIGURATION = { + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltage.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = ElectricalMeasurement.attributes.RMSVoltage.base_type, + reportable_change = 1 + },{ + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrent.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = ElectricalMeasurement.attributes.RMSCurrent.base_type, + reportable_change = 1 + },{ + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePower.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = ElectricalMeasurement.attributes.ActivePower.base_type, + reportable_change = 1 + },{ + cluster = SimpleMetering.ID, + attribute = SimpleMetering.attributes.InstantaneousDemand.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = SimpleMetering.attributes.InstantaneousDemand.base_type, + reportable_change = 1 + },{ + cluster = SimpleMetering.ID, + attribute = SimpleMetering.attributes.CurrentSummationDelivered.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = SimpleMetering.attributes.CurrentSummationDelivered.base_type, + reportable_change = 1 + },{ + cluster = Alarms.ID, + attribute = Alarms.attributes.AlarmCount.ID, + minimum_interval = 1, + maximum_interval = 3600, + data_type = Alarms.attributes.AlarmCount.base_type, + reportable_change = 1, + }, + } + }, + FRIENT_IO_MODULE = { + FINGERPRINTS = { + { mfr = "frient A/S", model = "IOMZB-110" } + }, + CONFIGURATION = { + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OnOff.base_type, + configurable = true, + monitored = true + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OffWaitTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OffWaitTime.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.PresentValue.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.PresentValue.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.Polarity.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.Polarity.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = 0x8000, -- IASActivation + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = data_types.Uint16, + mfg_code = 0x1015, + configurable = true, + monitored = true + } + } + } +} + +return devices \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/configurations/init.lua b/drivers/SmartThings/zigbee-switch/src/configurations/init.lua new file mode 100644 index 0000000000..a5e55b3953 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/configurations/init.lua @@ -0,0 +1,136 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local CONFIGURATION_VERSION_KEY = "_configuration_version" +local CONFIGURATION_ATTEMPTED = "_reconfiguration_attempted" + +local configurations = {} + +configurations.check_and_reconfig_devices = function(driver) + local clusters = require "st.zigbee.zcl.clusters" + local capabilities = require "st.capabilities" + local instantaneous_demand_configuration = { + cluster = clusters.SimpleMetering.ID, + attribute = clusters.SimpleMetering.attributes.InstantaneousDemand.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = clusters.SimpleMetering.attributes.InstantaneousDemand.base_type, + reportable_change = 5 + } + local active_power_configuration = { + cluster = clusters.ElectricalMeasurement.ID, + attribute = clusters.ElectricalMeasurement.attributes.ActivePower.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = clusters.ElectricalMeasurement.attributes.ActivePower.base_type, + reportable_change = 5 + } + + for device_id, device in pairs(driver.device_cache) do + local config_version = device:get_field(CONFIGURATION_VERSION_KEY) + if config_version == nil or config_version < driver.current_config_version then + if device:supports_capability(capabilities.powerMeter) then + if device:supports_server_cluster(clusters.ElectricalMeasurement.ID) then + -- Increase minimum reporting interval to 5 seconds + device:send(clusters.ElectricalMeasurement.attributes.ActivePower:configure_reporting(device, 5, 600, 5)) + device:add_configured_attribute(active_power_configuration) + end + if device:supports_server_cluster(clusters.SimpleMetering.ID) then + -- Increase minimum reporting interval to 5 seconds + device:send(clusters.SimpleMetering.attributes.InstantaneousDemand:configure_reporting(device, 5, 600, 5)) + device:add_configured_attribute(instantaneous_demand_configuration) + end + end + device:set_field(CONFIGURATION_ATTEMPTED, true, {persist = true}) + end + end + driver._reconfig_timer = nil +end + +configurations.handle_reporting_config_response = function(driver, device, zb_mess) + local Status = require "st.zigbee.generated.types.ZclStatus" + local clusters = require "st.zigbee.zcl.clusters" + local device_def = require "st.device" + + local dev = device + local find_child_fn = device:get_field(device_def.FIND_CHILD_KEY) + if find_child_fn ~= nil then + local child = find_child_fn(device, zb_mess.address_header.src_endpoint.value) + if child ~= nil then + dev = child + end + end + if dev:get_field(CONFIGURATION_ATTEMPTED) == true then + if zb_mess.body.zcl_body.global_status ~= nil and zb_mess.body.zcl_body.global_status.value == Status.SUCCESS then + dev:set_field(CONFIGURATION_VERSION_KEY, driver.current_config_version, {persist = true}) + elseif zb_mess.body.zcl_body.config_records ~= nil then + local config_records = zb_mess.body.zcl_body.config_records + for _, record in ipairs(config_records) do + if zb_mess.address_header.cluster.value == clusters.SimpleMetering.ID then + if record.attr_id.value == clusters.SimpleMetering.attributes.InstantaneousDemand.ID + and record.status.value == Status.SUCCESS then + dev:set_field(CONFIGURATION_VERSION_KEY, driver.current_config_version, {persist = true}) + end + elseif zb_mess.address_header.cluster.value == clusters.ElectricalMeasurement.ID then + if record.attr_id.value == clusters.ElectricalMeasurement.attributes.ActivePower.ID + and record.status.value == Status.SUCCESS then + dev:set_field(CONFIGURATION_VERSION_KEY, driver.current_config_version, {persist = true}) + end + end + + end + end + end +end + +configurations.reconfig_wrapper = function(orig_function) + local new_init = function(driver, device) + local config_version = device:get_field(CONFIGURATION_VERSION_KEY) + if config_version == nil or config_version < driver.current_config_version then + if driver._reconfig_timer == nil then + driver._reconfig_timer = driver:call_with_delay(5*60, configurations.check_and_reconfig_devices, "reconfig_power_devices") + end + end + + local capabilities = require "st.capabilities" + for id, _ in pairs(device.profile.components) do + if device:supports_capability(capabilities.colorTemperature, id) and + device:get_latest_state(id, capabilities.colorTemperature.ID, capabilities.colorTemperature.colorTemperatureRange.NAME) == nil then + local clusters = require "st.zigbee.zcl.clusters" + driver:call_with_delay(5*60, function() + device:send_to_component(id, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:read(device)) + device:send_to_component(id, clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:read(device)) + end) + end + end + orig_function(driver, device) + end + return new_init +end + +configurations.get_device_configuration = function(zigbee_device) + local devices = require "configurations.devices" + for _, device in pairs(devices) do + for _, fingerprint in pairs(device.FINGERPRINTS) do + if zigbee_device:get_manufacturer() == fingerprint.mfr and zigbee_device:get_model() == fingerprint.model then + return device.CONFIGURATION + end + end + end + return nil +end + +configurations.get_ias_zone_config_method = function(zigbee_device) + local devices = require "configurations.devices" + for _, device in pairs(devices) do + for _, fingerprint in pairs(device.FINGERPRINTS) do + if zigbee_device:get_manufacturer() == fingerprint.mfr and zigbee_device:get_model() == fingerprint.model then + return device.IAS_ZONE_CONFIG_METHOD + end + end + end + return nil +end + +return configurations diff --git a/drivers/SmartThings/zigbee-switch/src/ezex/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/ezex/can_handle.lua new file mode 100644 index 0000000000..4bb61719ff --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/ezex/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local ZIGBEE_METERING_SWITCH_FINGERPRINTS = { + { model = "E240-KR116Z-HA" } + } + + for _, fingerprint in ipairs(ZIGBEE_METERING_SWITCH_FINGERPRINTS) do + if device:get_model() == fingerprint.model then + local subdriver = require("ezex") + return true, subdriver + end + end + + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/ezex/init.lua b/drivers/SmartThings/zigbee-switch/src/ezex/init.lua index 29ed8c8778..ff1b2deb7b 100644 --- a/drivers/SmartThings/zigbee-switch/src/ezex/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/ezex/init.lua @@ -1,64 +1,20 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local zcl_clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" +local zigbee_constants = require "st.zigbee.constants" +local configurations = require "configurations" -local SimpleMetering = zcl_clusters.SimpleMetering -local ElectricalMeasurement = zcl_clusters.ElectricalMeasurement - -local ZIGBEE_METERING_SWITCH_FINGERPRINTS = { - { model = "E240-KR116Z-HA" } -} - -local is_zigbee_ezex_switch = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_METERING_SWITCH_FINGERPRINTS) do - if device:get_model() == fingerprint.model then - local subdriver = require("ezex") - return true, subdriver - end - end - - return false -end - -local function energy_meter_handler(driver, device, value, zb_rx) - local raw_value = value.value - raw_value = raw_value / 1000000 - device:emit_event(capabilities.energyMeter.energy({value = raw_value, unit = "kWh" })) -end - -local function power_meter_handler(driver, device, value, zb_rx) - local raw_value = value.value - raw_value = raw_value / 1000 - device:emit_event(capabilities.powerMeter.power({value = raw_value, unit = "W" })) +local do_init = function(self, device) + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000000, {persist = true}) + device:set_field(zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 1000, {persist = true}) end local ezex_switch_handler = { NAME = "ezex switch handler", - zigbee_handlers = { - attr = { - [SimpleMetering.ID] = { - [SimpleMetering.attributes.InstantaneousDemand.ID] = power_meter_handler, - [SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_meter_handler - }, - [ElectricalMeasurement.ID] = { - [ElectricalMeasurement.attributes.ActivePower.ID] = power_meter_handler - } - } + lifecycle_handlers = { + init = configurations.reconfig_wrapper(do_init) }, - can_handle = is_zigbee_ezex_switch + can_handle = require("ezex.can_handle"), } return ezex_switch_handler 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..1b5722fa7c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua @@ -0,0 +1,26 @@ +-- 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" }, + { mfr = "FIRSTLED", model = "M3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "P3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "P2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "P1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX4S2BAC", children = 4, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX3S2BAC", children = 3, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX1S2BAC", children = 1, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "G3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "G2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "G1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL4S4BAC", children = 4, buttons = 4, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "M1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "M2S2BAC", children = 2, buttons = 2, 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/firstled-light/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/firstled-light/can_handle.lua new file mode 100644 index 0000000000..6b02320ebf --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-light/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "firstled-light.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("firstled-light") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-light/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/firstled-light/fingerprints.lua new file mode 100644 index 0000000000..a406321a2c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-light/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "FIRSTLED", model = "DC2DC12MiV1" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-light/init.lua b/drivers/SmartThings/zigbee-switch/src/firstled-light/init.lua new file mode 100644 index 0000000000..ab81b0f0cf --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-light/init.lua @@ -0,0 +1,63 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local PRIVATE_CLUSTER_ID = 0xFC00 + +local preference_map = { + ["outputMode"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0000, + data_type = data_types.Uint8 + }, + ["powerOnMode"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0001, + data_type = data_types.Uint8 + }, + ["ledDriveCurrent"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0002, + data_type = data_types.Uint16 + }, + ["dimTransitionTime"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0003, + data_type = data_types.Uint16 + }, + ["colorTempTransitionTime"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0004, + data_type = data_types.Uint16 + } +} + +local function device_info_changed(driver, device, event, 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 + value = tonumber(value) + device:send(cluster_base.write_attribute(device, + data_types.ClusterId(attr.cluster_id), + data_types.AttributeId(attr.attribute_id), + data_types.validate_or_build_type(value, attr.data_type, "payload"))) + end + end + end +end + +local firstled_light_handlers = { + NAME = "firstled-light handlers", + lifecycle_handlers = { + infoChanged = device_info_changed + }, + can_handle = require("firstled-light.can_handle") +} + +return firstled_light_handlers diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua new file mode 100644 index 0000000000..4e1d465ee3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Function to determine if the driver can handle this device +return function(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and device:get_model() == "IOMZB-110" then + local subdriver = require("frient-IO") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua new file mode 100644 index 0000000000..d7601ab535 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -0,0 +1,488 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Zigbee Spec Utils +local constants = require "st.zigbee.constants" +local messages = require "st.zigbee.messages" +local zdo_messages = require "st.zigbee.zdo" +local bind_request = require "st.zigbee.zdo.bind_request" +local unbind_request = require "frient-IO.unbind_request" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local clusters = require "st.zigbee.zcl.clusters" +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +-- Capabilities +local capabilities = require "st.capabilities" +local Switch = capabilities.switch +local CHILD_OUTPUT_PROFILE = "frient-io-output-switch" +local utils = require "st.utils" + +local configurationMap = require "configurations" + +local COMPONENTS = { + INPUT_1 = "input1", + INPUT_2 = "input2", + INPUT_3 = "input3", + INPUT_4 = "input4", + OUTPUT_1 = "output1", + OUTPUT_2 = "output2" +} + +local ZIGBEE_ENDPOINTS = { + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75 +} + +local INPUT_CONFIGS = { + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_1, + reverse_pref = "reversePolarity1", + binds = { + { pref = "controlOutput11", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput21", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_2, + reverse_pref = "reversePolarity2", + binds = { + { pref = "controlOutput12", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput22", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_3, + reverse_pref = "reversePolarity3", + binds = { + { pref = "controlOutput13", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput23", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_4, + reverse_pref = "reversePolarity4", + binds = { + { pref = "controlOutput14", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput24", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + } +} + +local OUTPUT_INFO = { + ["1"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1, key = "frient-io-output-1", label_suffix = "Output 1" }, + ["2"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2, key = "frient-io-output-2", label_suffix = "Output 2" } +} + +local OUTPUT_BY_ENDPOINT, OUTPUT_BY_KEY = {}, {} +for suffix, info in pairs(OUTPUT_INFO) do + info.suffix = suffix + OUTPUT_BY_ENDPOINT[info.endpoint] = info + OUTPUT_BY_KEY[info.key] = info +end + +local ZIGBEE_MFG_CODES = { + Develco = 0x1015 +} + +local ZIGBEE_MFG_ATTRIBUTES = { + client = { + OnWithTimeOff_OnTime = { + ID = 0x8000, + data_type = data_types.Uint16 + }, + OnWithTimeOff_OffWaitTime = { + ID = 0x8001, + data_type = data_types.Uint16 + } + }, + server = { IASActivation = { + ID = 0x8000, + data_type = data_types.Uint16 + } } +} + +local function write_client_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, data_type, + payload) + local message = cluster_base.write_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, + data_type, payload) + + message.body.zcl_header.frame_ctrl:set_direction_client() + return message +end + +local function write_basic_input_polarity_attr(device, ep_id, payload) + local value = data_types.validate_or_build_type(payload and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload") + device:send(cluster_base.write_attribute(device, data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + value):to_endpoint(ep_id)) +end + +local function ensure_child_devices(driver, device) + if device.parent_assigned_child_key ~= nil then + return + end + + for _, info in pairs(OUTPUT_INFO) do + local child = device:get_child_by_parent_assigned_key(info.key) + if child == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + parent_device_id = device.id, + parent_assigned_child_key = info.key, + profile = CHILD_OUTPUT_PROFILE, + label = string.format("%s %s", device.label, info.label_suffix), + vendor_provided_label = info.label_suffix + }) + child = device:get_child_by_parent_assigned_key(info.key) + end + if child then + child:set_field("endpoint", info.endpoint, { persist = true }) + end + end +end + +local function sanitize_timing(value) + local int = math.tointeger(value) or 0 + return utils.clamp_value(int, 0, 0xFFFF) +end + +local function get_output_timing(device, suffix) + local info = OUTPUT_INFO[suffix] + if not info then return 0, 0 end + local child = device:get_child_by_parent_assigned_key(info.key) + local on_time = math.floor((sanitize_timing(device.preferences["configOnTime" .. suffix]))*10) + local off_wait = math.floor((sanitize_timing(device.preferences["configOffWaitTime" .. suffix]))*10) + if child then + on_time = math.floor((sanitize_timing(child.preferences.configOnTime)) * 10) + off_wait = math.floor((sanitize_timing(child.preferences.configOffWaitTime)) * 10) + end + return on_time, off_wait +end + +local function handle_output_command(device, suffix, command_name) + local info = OUTPUT_INFO[suffix] + if info == nil then return end + local config_on_time, config_off_wait_time = get_output_timing(device, suffix) + local endpoint = info.endpoint + + if command_name == "on" then + if config_on_time == 0 then + device:send(OnOff.server.commands.On(device):to_endpoint(endpoint)) + else + device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), + data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) + end + else + device:send(OnOff.server.commands.Off(device):to_endpoint(endpoint)) + end +end + +local function emit_switch_event_for_endpoint(device, endpoint, event) + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child then + child:emit_event(event) + return + end + end + device:emit_event_for_endpoint(endpoint, event) +end + +local function register_native_switch_handler(device, endpoint) + local field_key = string.format("frient_io_native_%02X", endpoint) + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child and not child:get_field(field_key) then + child:register_native_capability_attr_handler("switch", "switch") + child:set_field(field_key, true) + end + return + end + + if not device:get_field(field_key) then + device:register_native_capability_attr_handler("switch", "switch") + device:set_field(field_key, true) + end +end + +local function on_off_attr_handler(driver, device, value, zb_message) + local endpoint = zb_message.address_header.src_endpoint.value + register_native_switch_handler(device, endpoint) + emit_switch_event_for_endpoint(device, endpoint, value.value and Switch.switch.on() or Switch.switch.off()) +end + +local function build_bind_request(device, src_cluster, src_ep_id, dest_ep_id) + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, bind_request.BindRequest.ID) + + local bind_req = bind_request.BindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + bind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = bind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd +end + +local function build_unbind_request(device, src_cluster, src_ep_id, dest_ep_id) + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, unbind_request.UNBIND_REQUEST_CLUSTER_ID) + + local unbind_req = unbind_request.UnbindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + unbind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = unbind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd +end + +local function apply_input_preference_changes(device, old_prefs, config) + if old_prefs[config.reverse_pref] ~= device.preferences[config.reverse_pref] then + write_basic_input_polarity_attr(device, config.endpoint, device.preferences[config.reverse_pref]) + end + + for _, bind_cfg in ipairs(config.binds) do + if old_prefs[bind_cfg.pref] ~= device.preferences[bind_cfg.pref] then + device:send(device.preferences[bind_cfg.pref] + and build_bind_request(device, BasicInput.ID, config.endpoint, bind_cfg.endpoint) + or build_unbind_request(device, BasicInput.ID, config.endpoint, bind_cfg.endpoint)) + end + end +end + +local function component_to_endpoint(device, component_id) + if component_id == COMPONENTS.INPUT_1 then + return ZIGBEE_ENDPOINTS.INPUT_1 + elseif component_id == COMPONENTS.INPUT_2 then + return ZIGBEE_ENDPOINTS.INPUT_2 + elseif component_id == COMPONENTS.INPUT_3 then + return ZIGBEE_ENDPOINTS.INPUT_3 + elseif component_id == COMPONENTS.INPUT_4 then + return ZIGBEE_ENDPOINTS.INPUT_4 + elseif component_id == COMPONENTS.OUTPUT_1 then + return ZIGBEE_ENDPOINTS.OUTPUT_1 + elseif component_id == COMPONENTS.OUTPUT_2 then + return ZIGBEE_ENDPOINTS.OUTPUT_2 + else + return device.fingerprinted_endpoint_id + end +end + +local function endpoint_to_component(device, ep) + local ep_id = type(ep) == "table" and ep.value or ep + if ep_id == ZIGBEE_ENDPOINTS.INPUT_1 then + return COMPONENTS.INPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_2 then + return COMPONENTS.INPUT_2 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_3 then + return COMPONENTS.INPUT_3 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_4 then + return COMPONENTS.INPUT_4 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_1 then + return COMPONENTS.OUTPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_2 then + return COMPONENTS.OUTPUT_2 + else + return "main" + end +end + +local function init_handler(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local function added_handler(self, device) + ensure_child_devices(self, device) +end + +local function configure_handler(self, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + if attribute.configurable ~= false then + device:add_configured_attribute(attribute) + end + end + end + device:configure() + if device.parent_assigned_child_key ~= nil then + return + end + + ensure_child_devices(self, device) + + local on1, off1 = get_output_timing(device, "1") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + + local on2, off2 = get_output_timing(device, "2") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + + -- Input 1 + local default_old_prefs = {} + for _, config in ipairs(INPUT_CONFIGS) do + apply_input_preference_changes(device, default_old_prefs, config) + end +end + +local function info_changed_handler(self, device, event, args) + if device.parent_assigned_child_key ~= nil then + -- This is a child device + local parent = device:get_parent_device() + if not parent then return end + + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if not info then return end + + -- Child devices have simple preference names without suffix + local on_time = math.floor(sanitize_timing(device.preferences.configOnTime) * 10) + local off_wait = math.floor(sanitize_timing(device.preferences.configOffWaitTime) * 10) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on_time):to_endpoint(info.endpoint)) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off_wait):to_endpoint(info.endpoint)) + return + else + local old_prefs = (args.old_st_store and args.old_st_store.preferences) or {} + for _, config in ipairs(INPUT_CONFIGS) do + apply_input_preference_changes(device, old_prefs, config) + end + end +end + +local function present_value_attr_handler(driver, device, value, zb_message) + local ep_id = zb_message.address_header.src_endpoint + register_native_switch_handler(device, ep_id.value) + device:emit_event_for_endpoint(ep_id, value.value and Switch.switch.on() or Switch.switch.off()) +end + +local function on_off_default_response_handler(driver, device, zb_rx) + local status = zb_rx.body.zcl_body.status.value + local endpoint = zb_rx.address_header.src_endpoint.value + + if status == Status.SUCCESS then + local cmd = zb_rx.body.zcl_body.cmd.value + local event = nil + + if cmd == OnOff.server.commands.On.ID then + event = Switch.switch.on() + elseif cmd == OnOff.server.commands.OnWithTimedOff.ID then + device:send(cluster_base.read_attribute(device, data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID)):to_endpoint(endpoint)) + elseif cmd == OnOff.server.commands.Off.ID then + event = Switch.switch.off() + end + + if event ~= nil then + emit_switch_event_for_endpoint(device, endpoint, event) + end + end +end + +local function make_switch_handler(command_name) + return function(driver, device, command) + local parent = device:get_parent_device() + if parent then + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if info then + handle_output_command(parent, info.suffix, command_name) + return + end + end + + local num = command.component and command.component:match("output(%d)") + if num then + handle_output_command(device, num, command_name) + return + end + num = command.component:match("input(%d)") + if num then + local component = device.profile.components[command.component] + local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) + if value == "on" then + device:emit_component_event(component, + Switch.switch.on({ state_change = true, visibility = { displayed = false } })) + elseif value == "off" then + device:emit_component_event(component, + Switch.switch.off({ state_change = true, visibility = { displayed = false } })) + end + end + end +end + +local frient_bridge_handler = { + NAME = "frient bridge handler", + zigbee_handlers = { + global = { + [OnOff.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = on_off_default_response_handler + } + }, + cluster = {}, + attr = { + [BasicInput.ID] = { + [BasicInput.attributes.PresentValue.ID] = present_value_attr_handler + }, + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = on_off_attr_handler + } + }, + zdo = {} + }, + capability_handlers = { + [Switch.ID] = { + [Switch.commands.on.NAME] = make_switch_handler("on"), + [Switch.commands.off.NAME] = make_switch_handler("off") + } + }, + lifecycle_handlers = { + added = added_handler, + init = configurationMap.reconfig_wrapper(init_handler), + doConfigure = configure_handler, + infoChanged = info_changed_handler + }, + can_handle = require("frient-IO.can_handle"), +} + +return frient_bridge_handler diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua new file mode 100644 index 0000000000..64c4b05464 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua @@ -0,0 +1,84 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local data_types = require "st.zigbee.data_types" +local utils = require "st.zigbee.utils" + +local unbind_request = {} + +unbind_request.UNBIND_REQUEST_CLUSTER_ID = 0x0022 +unbind_request.ADDRESS_MODE_16_BIT = 0x01 +unbind_request.ADDRESS_MODE_64_BIT = 0x03 + +local UnbindRequest = { + ID = unbind_request.UNBIND_REQUEST_CLUSTER_ID, + NAME = "UnbindRequest", +} +UnbindRequest.__index = UnbindRequest +unbind_request.UnbindRequest = UnbindRequest + +function UnbindRequest.deserialize(buf) + local self = {} + setmetatable(self, UnbindRequest) + + local fields = { + { name = "src_address", type = data_types.IeeeAddress }, + { name = "src_endpoint", type = data_types.Uint8 }, + { name = "cluster_id", type = data_types.ClusterId }, + { name = "dest_addr_mode", type = data_types.Uint8 }, + } + utils.deserialize_field_list(self, fields, buf) + + if self.dest_addr_mode.value == unbind_request.ADDRESS_MODE_16_BIT then + self.dest_address = data_types.Uint16.deserialize(buf) + else + self.dest_address = data_types.IeeeAddress.deserialize(buf) + self.dest_endpoint = data_types.Uint8.deserialize(buf) + end + return self +end + +--- A helper function used by common code to get all the component pieces of this message frame +function UnbindRequest:get_fields() + local out = {} + out[#out + 1] = self.src_address + out[#out + 1] = self.src_endpoint + out[#out + 1] = self.cluster_id + out[#out + 1] = self.dest_addr_mode + out[#out + 1] = self.dest_address + if self.dest_addr_mode.value == unbind_request.ADDRESS_MODE_64_BIT then + out[#out + 1] = self.dest_endpoint + end + return out +end + +UnbindRequest.get_length = utils.length_from_fields +UnbindRequest._serialize = utils.serialize_from_fields +UnbindRequest.pretty_print = utils.print_from_fields +UnbindRequest.__tostring = UnbindRequest.pretty_print +function UnbindRequest.from_values(orig, src_address, src_endpoint, cluster_id, dest_addr_mode, dest_address, + dest_endpoint) + local out = {} + if src_address == nil or src_endpoint == nil or cluster_id == nil or dest_addr_mode == nil or dest_address == nil then + error("Missing necessary values for bind request", 2) + end + + out.src_address = data_types.validate_or_build_type(src_address, data_types.IeeeAddress, "src_address") + out.src_endpoint = data_types.validate_or_build_type(src_endpoint, data_types.Uint8, "src_endpoint") + out.cluster_id = data_types.validate_or_build_type(cluster_id, data_types.ClusterId, "cluster") + out.dest_addr_mode = data_types.validate_or_build_type(dest_addr_mode, data_types.Uint8, "dest_addr_mode") + if (out.dest_addr_mode.value == unbind_request.ADDRESS_MODE_16_BIT) then + out.dest_address = data_types.validate_or_build_type(dest_address, data_types.Uint16, "dest_address") + elseif out.dest_addr_mode.value == unbind_request.ADDRESS_MODE_64_BIT then + out.dest_address = data_types.validate_or_build_type(dest_address, data_types.IeeeAddress, "dest_address") + out.dest_endpoint = data_types.validate_or_build_type(dest_endpoint, data_types.Uint8, "dest_endpoint") + else + error(string.format("Unrecognized destination address mode: %d", out.dest_addr_mode.value), 2) + end + + setmetatable(out, UnbindRequest) + return out +end + +setmetatable(unbind_request.UnbindRequest, { __call = unbind_request.UnbindRequest.from_values }) + +return unbind_request diff --git a/drivers/SmartThings/zigbee-switch/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/frient/can_handle.lua new file mode 100644 index 0000000000..2892763034 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Function to determine if the driver can handle this device +return function(opts, driver, device, ...) + local FRIENT_SMART_PLUG_FINGERPRINTS = require("frient.fingerprints") + for _, fingerprint in ipairs(FRIENT_SMART_PLUG_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("frient") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/frient/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/frient/fingerprints.lua new file mode 100644 index 0000000000..e2ac5ceaab --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient/fingerprints.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "frient A/S", model = "SPLZB-131" }, + { mfr = "frient A/S", model = "SPLZB-132" }, + { mfr = "frient A/S", model = "SPLZB-134" }, + { mfr = "frient A/S", model = "SPLZB-137" }, + { mfr = "frient A/S", model = "SPLZB-141" }, + { mfr = "frient A/S", model = "SPLZB-142" }, + { mfr = "frient A/S", model = "SPLZB-144" }, + { mfr = "frient A/S", model = "SPLZB-147" }, + { mfr = "frient A/S", model = "SMRZB-143" }, + { mfr = "frient A/S", model = "SMRZB-153" }, + { mfr = "frient A/S", model = "SMRZB-332" }, + { mfr = "frient A/S", model = "SMRZB-342" }, +} diff --git a/drivers/SmartThings/zigbee-switch/src/frient/init.lua b/drivers/SmartThings/zigbee-switch/src/frient/init.lua new file mode 100644 index 0000000000..e546de1a14 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient/init.lua @@ -0,0 +1,162 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local constants = require "st.zigbee.constants" +local log = require "log" +local configurationMap = require("configurations") + +local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local Alarms = clusters.Alarms + +local VOLTAGE_MEASUREMENT_MULTIPLIER_KEY = "_voltage_measurement_multiplier" +local VOLTAGE_MEASUREMENT_DIVISOR_KEY = "_voltage_measurement_divisor" +local CURRENT_MEASUREMENT_MULTIPLIER_KEY = "_current_measurement_multiplier" +local CURRENT_MEASUREMENT_DIVISOR_KEY = "_current_measurement_divisor" + + +local POWER_FAILURE_ALARM_CODE = 0x03 + + +local function alarm_report_handler(driver, device, zb_rx) + local alarm_status = zb_rx.body.zcl_body + if ((alarm_status.cluster_identifier.value == SimpleMetering.ID) and (alarm_status.alarm_code.value == POWER_FAILURE_ALARM_CODE)) then + device.thread:call_with_delay(2, function(d) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.powerSource.powerSource.unknown()) + end + ) + end +end + +local function device_added(driver, device) +end + +-- Handler for voltage measurement +local function voltage_measurement_handler(driver, device, value, zb_rx) + local raw_value = value.value + -- By default emit raw value + local multiplier = device:get_field(VOLTAGE_MEASUREMENT_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(VOLTAGE_MEASUREMENT_DIVISOR_KEY) or 1 + + raw_value = raw_value * multiplier / divisor + + local mult = 10 ^ 1 -- Round off to 1 decimal place + raw_value = math.floor(raw_value * mult + 0.5) / mult + + if device:supports_capability(capabilities.voltageMeasurement) then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.voltageMeasurement.voltage({ value = raw_value, unit = "V" })) + end + if device:supports_capability(capabilities.powerSource) then + device:emit_event(capabilities.powerSource.powerSource.mains()) + end +end + +local function current_measurement_divisor_handler(driver, device, divisor, zb_rx) + local raw_value = divisor.value + if raw_value == 0 then + log.warn("Current scale divisor is 0; using 1 to avoid division by zero") + raw_value = 1 + end + device:set_field(CURRENT_MEASUREMENT_DIVISOR_KEY, raw_value, { persist = true }) +end + +local function current_measurement_multiplier_handler(driver, device, multiplier, zb_rx) + local raw_value = multiplier.value + device:set_field(CURRENT_MEASUREMENT_MULTIPLIER_KEY, raw_value, { persist = true }) +end + +local function voltage_measurement_divisor_handler(driver, device, divisor, zb_rx) + local raw_value = divisor.value + if raw_value == 0 then + log.warn("Voltage scale divisor is 0; using 1 to avoid division by zero") + raw_value = 1 + end + device:set_field(VOLTAGE_MEASUREMENT_DIVISOR_KEY, raw_value, { persist = true }) +end + +local function voltage_measurement_multiplier_handler(driver, device, multiplier, zb_rx) + local raw_value = multiplier.value + + device:set_field(VOLTAGE_MEASUREMENT_MULTIPLIER_KEY, raw_value, { persist = true }) +end + +-- Handler for current measurement +local function current_measurement_handler(driver, device, value, zb_rx) + local raw_value = value.value + -- By default emit raw value + local multiplier = device:get_field(CURRENT_MEASUREMENT_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(CURRENT_MEASUREMENT_DIVISOR_KEY) or 1 + + raw_value = raw_value * multiplier / divisor + + local mult = 10 ^ 2 -- Round off to 2 decimal places + raw_value = math.floor(raw_value * mult + 0.5) / mult + + if device:supports_capability(capabilities.currentMeasurement) then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.currentMeasurement.current({ value = raw_value, unit = "A" })) + end +end + +-- Device init function +local function device_init(driver, device) + device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + device:set_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 1000, {persist = true}) + -- Indicate device is powered by mains + if device:supports_capability(capabilities.powerSource) then + device:emit_event(capabilities.powerSource.powerSource.mains()) + end +end + +local function do_configure(driver, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end + end + device:configure() + + local alarms_endpoint = device:get_endpoint(Alarms.ID) or device.fingerprinted_endpoint_id + -- Make sure we have a valid endpoint number before sending the bind request + if alarms_endpoint ~= nil then + device:send(ElectricalMeasurement.attributes.ACVoltageMultiplier:read(device)) + device:send(ElectricalMeasurement.attributes.ACVoltageDivisor:read(device)) + device:send(ElectricalMeasurement.attributes.ACCurrentMultiplier:read(device)) + device:send(ElectricalMeasurement.attributes.ACCurrentDivisor:read(device)) + else + log.warn("No valid endpoint found for Alarms cluster binding") + end + device:refresh() +end + +-- Main driver definition +local frient_smart_plug = { + NAME = "frient Smart Plug", + zigbee_handlers = { + cluster = { + [Alarms.ID] = { + [Alarms.client.commands.Alarm.ID] = alarm_report_handler + } + }, + attr = { + [ElectricalMeasurement.ID] = { + [ElectricalMeasurement.attributes.ACVoltageMultiplier.ID] = voltage_measurement_multiplier_handler, + [ElectricalMeasurement.attributes.ACVoltageDivisor.ID] = voltage_measurement_divisor_handler, + [ElectricalMeasurement.attributes.RMSVoltage.ID] = voltage_measurement_handler, + [ElectricalMeasurement.attributes.ACCurrentMultiplier.ID] = current_measurement_multiplier_handler, + [ElectricalMeasurement.attributes.ACCurrentDivisor.ID] = current_measurement_divisor_handler, + [ElectricalMeasurement.attributes.RMSCurrent.ID] = current_measurement_handler + } + }, + }, + lifecycle_handlers = { + init = configurationMap.reconfig_wrapper(device_init), + doConfigure = do_configure, + added = device_added, + }, + can_handle = require("frient.can_handle"), +} + +return frient_smart_plug diff --git a/drivers/SmartThings/zigbee-switch/src/ge-link-bulb/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/ge-link-bulb/can_handle.lua new file mode 100644 index 0000000000..6addbc463c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/ge-link-bulb/can_handle.lua @@ -0,0 +1,22 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local GE_LINK_BULB_FINGERPRINTS = { + ["GE_Appliances"] = { + ["ZLL Light"] = true, + }, + ["GE"] = { + ["Daylight"] = true, + ["SoftWhite"] = true + } +} + + local can_handle = (GE_LINK_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] + if can_handle then + local subdriver = require("ge-link-bulb") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/ge-link-bulb/init.lua b/drivers/SmartThings/zigbee-switch/src/ge-link-bulb/init.lua index 3bf3fa8952..b1f5ea8508 100644 --- a/drivers/SmartThings/zigbee-switch/src/ge-link-bulb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/ge-link-bulb/init.lua @@ -1,42 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local Level = clusters.Level -local GE_LINK_BULB_FINGERPRINTS = { - ["GE_Appliances"] = { - ["ZLL Light"] = true, - }, - ["GE"] = { - ["Daylight"] = true, - ["SoftWhite"] = true - } -} - -local function can_handle_ge_link_bulb(opts, driver, device) - local can_handle = (GE_LINK_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] - if can_handle then - local subdriver = require("ge-link-bulb") - return true, subdriver - else - return false - end -end - local function info_changed(driver, device, event, args) local command local new_dim_onoff_value = tonumber(device.preferences.dimOnOff) @@ -81,7 +50,7 @@ local ge_link_bulb = { [capabilities.switchLevel.commands.setLevel.NAME] = set_level_handler } }, - can_handle = can_handle_ge_link_bulb + can_handle = require("ge-link-bulb.can_handle"), } return ge_link_bulb diff --git a/drivers/SmartThings/zigbee-switch/src/hanssem/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/hanssem/can_handle.lua new file mode 100644 index 0000000000..9f9f1c4ace --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/hanssem/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "hanssem.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("hanssem") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/hanssem/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/hanssem/fingerprints.lua new file mode 100644 index 0000000000..65c4421053 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/hanssem/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "Winners", model = "LSS1-101", children = 0 }, + { mfr = "Winners", model = "LSS1-102", children = 1 }, + { mfr = "Winners", model = "LSS1-103", children = 2 }, + { mfr = "Winners", model = "LSS1-204", children = 3 }, + { mfr = "Winners", model = "LSS1-205", children = 4 }, + { mfr = "Winners", model = "LSS1-206", children = 5 } +} diff --git a/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua b/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua index e5ba00b77f..0ab333af8e 100644 --- a/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua @@ -1,39 +1,11 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local stDevice = require "st.device" - -local FINGERPRINTS = { - { mfr = "Winners", model = "LSS1-101", children = 0 }, - { mfr = "Winners", model = "LSS1-102", children = 1 }, - { mfr = "Winners", model = "LSS1-103", children = 2 }, - { mfr = "Winners", model = "LSS1-204", children = 3 }, - { mfr = "Winners", model = "LSS1-205", children = 4 }, - { mfr = "Winners", model = "LSS1-206", children = 5 } -} - -local function can_handle_hanssem_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("hanssem") - return true, subdriver - end - end - return false -end +local configurations = require "configurations" local function get_children_amount(device) + local FINGERPRINTS = require "hanssem.fingerprints" for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then return fingerprint.children @@ -78,9 +50,9 @@ local HanssemSwitch = { NAME = "Zigbee Hanssem Switch", lifecycle_handlers = { added = device_added, - init = device_init + init = configurations.reconfig_wrapper(device_init) }, - can_handle = can_handle_hanssem_switch + can_handle = require("hanssem.can_handle"), } -return HanssemSwitch \ No newline at end of file +return HanssemSwitch diff --git a/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/can_handle.lua new file mode 100644 index 0000000000..47992c0559 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(opts, driver, device) + local IKEA_XY_COLOR_BULB_FINGERPRINTS = { + ["IKEA of Sweden"] = { + ["TRADFRI bulb E27 CWS opal 600lm"] = true, + ["TRADFRI bulb E26 CWS opal 600lm"] = true + } + } + local res = (IKEA_XY_COLOR_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] + if res then + return res, require("ikea-xy-color-bulb") + end + return res +end diff --git a/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/ikea-xy-color-bulb/init.lua b/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/init.lua similarity index 84% rename from drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/ikea-xy-color-bulb/init.lua rename to drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/init.lua index 87c1f63803..a236f1c1cc 100644 --- a/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/ikea-xy-color-bulb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -27,17 +16,6 @@ local HUESAT_TIMER = "huesat_timer" local TARGET_HUE = "target_hue" local TARGET_SAT = "target_sat" -local IKEA_XY_COLOR_BULB_FINGERPRINTS = { - ["IKEA of Sweden"] = { - ["TRADFRI bulb E27 CWS opal 600lm"] = true, - ["TRADFRI bulb E26 CWS opal 600lm"] = true - } -} - -local function can_handle_ikea_xy_color_bulb(opts, driver, device) - return (IKEA_XY_COLOR_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] or false -end - local device_init = function(self, device) device:remove_configured_attribute(ColorControl.ID, ColorControl.attributes.CurrentHue.ID) device:remove_configured_attribute(ColorControl.ID, ColorControl.attributes.CurrentSaturation.ID) @@ -48,7 +26,6 @@ local device_init = function(self, device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end end @@ -90,6 +67,7 @@ end local huesat_timer_callback = function(driver, device, cmd) return function() + device:set_field(HUESAT_TIMER, nil) local hue = device:get_field(TARGET_HUE) local sat = device:get_field(TARGET_SAT) hue = hue ~= nil and hue or device:get_latest_state("main", capabilities.colorControl.ID, capabilities.colorControl.hue.NAME) @@ -169,7 +147,7 @@ end local ikea_xy_color_bulb = { NAME = "IKEA XY Color Bulb", lifecycle_handlers = { - init = device_init + init = configurationMap.reconfig_wrapper(device_init) }, capability_handlers = { [capabilities.colorControl.ID] = { @@ -186,7 +164,7 @@ local ikea_xy_color_bulb = { } } }, - can_handle = can_handle_ikea_xy_color_bulb + can_handle = require("ikea-xy-color-bulb.can_handle") } return ikea_xy_color_bulb diff --git a/drivers/SmartThings/zigbee-switch/src/init.lua b/drivers/SmartThings/zigbee-switch/src/init.lua index d26da471fc..9fbb09119e 100644 --- a/drivers/SmartThings/zigbee-switch/src/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/init.lua @@ -1,56 +1,21 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- The only reason we need this is because of supported_capabilities on the driver template local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" -local clusters = require "st.zigbee.zcl.clusters" local configurationMap = require "configurations" -local SimpleMetering = clusters.SimpleMetering -local ElectricalMeasurement = clusters.ElectricalMeasurement -local preferences = require "preferences" - -local function lazy_load_if_possible(sub_driver_name) - -- gets the current lua libs api version - local version = require "version" - - -- version 9 will include the lazy loading functions - if version.api >= 9 then - return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) - else - return require(sub_driver_name) - end +local CONFIGURE_REPORTING_RESPONSE_ID = 0x07 +local SIMPLE_METERING_ID = 0x0702 +local ELECTRICAL_MEASUREMENT_ID = 0x0B04 +local version = require "version" -end - -local function info_changed(self, device, event, args) - preferences.update_preferences(self, device, args) -end - -local do_configure = function(self, device) - device:refresh() - device:configure() - - -- Additional one time configuration - if device:supports_capability(capabilities.energyMeter) or device:supports_capability(capabilities.powerMeter) then - -- Divisor and multipler for EnergyMeter - device:send(ElectricalMeasurement.attributes.ACPowerDivisor:read(device)) - device:send(ElectricalMeasurement.attributes.ACPowerMultiplier:read(device)) - -- Divisor and multipler for PowerMeter - device:send(SimpleMetering.attributes.Divisor:read(device)) - device:send(SimpleMetering.attributes.Multiplier:read(device)) - end +local lazy_handler +if version.api >= 15 then + lazy_handler = require "st.utils.lazy_handler" +else + lazy_handler = require end local function component_to_endpoint(device, component_id) @@ -67,7 +32,7 @@ local function endpoint_to_component(device, ep) end end -local device_init = function(self, device) +local device_init = function(driver, device) device:set_component_to_endpoint_fn(component_to_endpoint) device:set_endpoint_to_component_fn(endpoint_to_component) @@ -75,7 +40,6 @@ local device_init = function(self, device) if configuration ~= nil then for _, attribute in ipairs(configuration) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -83,6 +47,11 @@ local device_init = function(self, device) if ias_zone_config_method ~= nil then device:set_ias_zone_config_method(ias_zone_config_method) end + local device_lib = require "st.device" + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + local find_child = require "lifecycle_handlers.find_child" + device:set_find_child(find_child) + end end local zigbee_switch_driver_template = { @@ -93,40 +62,35 @@ local zigbee_switch_driver_template = { capabilities.colorTemperature, capabilities.powerMeter, capabilities.energyMeter, - capabilities.motionSensor + capabilities.motionSensor, + capabilities.illuminanceMeasurement, + capabilities.relativeHumidityMeasurement, + capabilities.temperatureMeasurement, }, - sub_drivers = { - lazy_load_if_possible("hanssem"), - lazy_load_if_possible("aqara"), - lazy_load_if_possible("aqara-light"), - lazy_load_if_possible("ezex"), - lazy_load_if_possible("rexense"), - lazy_load_if_possible("sinope"), - lazy_load_if_possible("sinope-dimmer"), - lazy_load_if_possible("zigbee-dimmer-power-energy"), - lazy_load_if_possible("zigbee-metering-plug-power-consumption-report"), - lazy_load_if_possible("jasco"), - lazy_load_if_possible("multi-switch-no-master"), - lazy_load_if_possible("zigbee-dual-metering-switch"), - lazy_load_if_possible("rgb-bulb"), - lazy_load_if_possible("zigbee-dimming-light"), - lazy_load_if_possible("white-color-temp-bulb"), - lazy_load_if_possible("rgbw-bulb"), - lazy_load_if_possible("zll-dimmer-bulb"), - lazy_load_if_possible("zigbee-switch-power"), - lazy_load_if_possible("ge-link-bulb"), - lazy_load_if_possible("bad_on_off_data_type"), - lazy_load_if_possible("robb"), - lazy_load_if_possible("wallhero") + sub_drivers = require("sub_drivers"), + zigbee_handlers = { + global = { + [SIMPLE_METERING_ID] = { + [CONFIGURE_REPORTING_RESPONSE_ID] = configurationMap.handle_reporting_config_response + }, + [ELECTRICAL_MEASUREMENT_ID] = { + [CONFIGURE_REPORTING_RESPONSE_ID] = configurationMap.handle_reporting_config_response + } + } }, + current_config_version = 1, lifecycle_handlers = { - init = device_init, - infoChanged = info_changed, - doConfigure = do_configure - } + init = configurationMap.reconfig_wrapper(device_init), + added = lazy_handler("lifecycle_handlers.device_added"), + infoChanged = lazy_handler("lifecycle_handlers.info_changed"), + doConfigure = lazy_handler("lifecycle_handlers.do_configure"), + }, + health_check = false, + shared_device_thread_enabled = true, } - defaults.register_for_default_handlers(zigbee_switch_driver_template, - zigbee_switch_driver_template.supported_capabilities) + zigbee_switch_driver_template.supported_capabilities, + {native_capability_cmds_enabled = true, native_capability_attrs_enabled = true} +) local zigbee_switch = ZigbeeDriver("zigbee_switch", zigbee_switch_driver_template) zigbee_switch:run() diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/can_handle.lua new file mode 100644 index 0000000000..d9b676e307 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(opts, driver, device) + local INOVELLI_FINGERPRINTS = { + { mfr = "Inovelli", model = "VZM30-SN" }, + { mfr = "Inovelli", model = "VZM31-SN" }, + { mfr = "Inovelli", model = "VZM32-SN" } + } + for _, fp in ipairs(INOVELLI_FINGERPRINTS) do + if device:get_manufacturer() == fp.mfr and device:get_model() == fp.model then + local subdriver = require("inovelli") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/common.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/common.lua new file mode 100644 index 0000000000..c402731328 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/common.lua @@ -0,0 +1,73 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local device_management = require "st.zigbee.device_management" +local OTAUpgrade = require("st.zigbee.zcl.clusters").OTAUpgrade +local zigbee_constants = require "st.zigbee.constants" +local capabilities = require "st.capabilities" + +local M = {} + +M.supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} +} + +-- Utility function to check if device is VZM32-SN +function M.is_vzm32(device) + return device:get_model() == "VZM32-SN" +end + +-- Utility function to check if device is VZM32-SN +function M.is_vzm30(device) + return device:get_model() == "VZM30-SN" +end + +-- Sends a generic configure for Inovelli devices (all models): +-- - device:configure +-- - send OTA ImageNotify +-- - bind PRIVATE cluster for button presses +-- - read metering/electrical measurement divisors/multipliers +function M.base_device_configure(driver, device, private_cluster_id, mfg_code) + device:configure() + -- OTA Image Notify (generic for all devices) + local PAYLOAD_TYPE = 0x00 + local QUERY_JITTER = 100 + local IMAGE_TYPE = 0xFFFF + local NEW_VERSION = 0xFFFFFFFF + device:send(OTAUpgrade.commands.ImageNotify(device, PAYLOAD_TYPE, QUERY_JITTER, mfg_code, IMAGE_TYPE, NEW_VERSION)) + + -- Bind for button presses on manufacturer private cluster + device:send(device_management.build_bind_request(device, private_cluster_id, driver.environment_info.hub_zigbee_eui, 2)) + + -- Read divisors/multipliers for power/energy reporting + -- Set default divisor to 1000 for VZM32-SN and VZM30-SN. In initial firmware the divisor is incorrectly set to 100. + if M.is_vzm32(device) or M.is_vzm30(device) then + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + else + device:send(clusters.SimpleMetering.attributes.Divisor:read(device)) + end + device:send(clusters.SimpleMetering.attributes.Multiplier:read(device)) + device:send(clusters.ElectricalMeasurement.attributes.ACPowerDivisor:read(device)) + device:send(clusters.ElectricalMeasurement.attributes.ACPowerMultiplier:read(device)) + + for _, component in pairs(device.profile.components) do + if component.id ~= "main" then + device:emit_component_event( + component, + capabilities.button.supportedButtonValues( + M.supported_button_values[component.id], + { visibility = { displayed = false } } + ) + ) + device:emit_component_event( + component, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + end + end +end + +return M \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua new file mode 100644 index 0000000000..97cd33e821 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua @@ -0,0 +1,426 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" +local st_device = require "st.device" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local inovelli_common = require "inovelli.common" + +-- Load VZM32-only dependencies (handlers will check device type) +local OccupancySensing = clusters.OccupancySensing + +local LATEST_CLOCK_SET_TIMESTAMP = "latest_clock_set_timestamp" + +local PRIVATE_CLUSTER_ID = 0xFC31 +local PRIVATE_CLUSTER_MMWAVE_ID = 0xFC32 +local PRIVATE_CMD_NOTIF_ID = 0x01 +local PRIVATE_CMD_ENERGY_RESET_ID = 0x02 +local PRIVATE_CMD_SCENE_ID = 0x00 +local PRIVATE_CMD_MMWAVE_ID = 0x00 +local MFG_CODE = 0x122F + +-- Base preferences shared by all models +local base_preference_map = { + parameter258 = {parameter_number = 258, size = data_types.Boolean, cluster = PRIVATE_CLUSTER_ID}, + parameter52 = {parameter_number = 52, size = data_types.Boolean, cluster = PRIVATE_CLUSTER_ID}, + parameter1 = {parameter_number = 1, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter2 = {parameter_number = 2, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter3 = {parameter_number = 3, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter4 = {parameter_number = 4, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter15 = {parameter_number = 15, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter95 = {parameter_number = 95, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter96 = {parameter_number = 96, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter97 = {parameter_number = 97, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter98 = {parameter_number = 98, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, +} + +-- Model-specific overrides/additions +local model_preference_overrides = { + ["VZM30-SN"] = { + parameter11 = {parameter_number = 11, size = data_types.Boolean, cluster = PRIVATE_CLUSTER_ID}, + parameter22 = {parameter_number = 22, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + }, + ["VZM31-SN"] = { + parameter9 = {parameter_number = 9, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter10 = {parameter_number = 10, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter11 = {parameter_number = 11, size = data_types.Boolean, cluster = PRIVATE_CLUSTER_ID}, + parameter17 = {parameter_number = 17, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter22 = {parameter_number = 22, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + }, + ["VZM32-SN"] = { + parameter9 = {parameter_number = 9, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter10 = {parameter_number = 10, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter34 = {parameter_number = 34, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter101 = {parameter_number = 101, size = data_types.Int16, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter102 = {parameter_number = 102, size = data_types.Int16, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter103 = {parameter_number = 103, size = data_types.Int16, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter104 = {parameter_number = 104, size = data_types.Int16, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter105 = {parameter_number = 105, size = data_types.Int16, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter106 = {parameter_number = 106, size = data_types.Int16, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter110 = {parameter_number = 110, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_ID}, + parameter111 = {parameter_number = 111, size = data_types.Uint32, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter112 = {parameter_number = 112, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter113 = {parameter_number = 113, size = data_types.Uint8, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter114 = {parameter_number = 114, size = data_types.Uint32, cluster = PRIVATE_CLUSTER_MMWAVE_ID}, + parameter115 = {parameter_number = 115, size = data_types.Uint32, cluster = PRIVATE_CLUSTER_ID}, + } +} + +local function get_preference_map_for_device(device) + -- shallow copy base + local merged = {} + for k, v in pairs(base_preference_map) do merged[k] = v end + -- merge model-specific + local model = device and device:get_model() or nil + local override = model and model_preference_overrides[model] or nil + if override then + for k, v in pairs(override) do merged[k] = v end + end + return merged +end + +local preferences_to_numeric_value = function(new_value) + local numeric = tonumber(new_value) + if numeric == nil then + numeric = new_value and 1 or 0 + end + return numeric +end + +local preferences_calculate_parameter = function(new_value, type, number) + if number == "parameter9" or number == "parameter10" or number == "parameter13" or number == "parameter14" or number == "parameter15" or number == "parameter55" or number == "parameter56" then + if new_value == 101 then + return 255 + else + return utils.round(new_value / 100 * 254) + end + else + return new_value + end +end + +local function to_boolean(value) + if value == 0 or value == "0" then + return false + else + return true + end +end + +local map_key_attribute_to_capability = { + [0x00] = capabilities.button.button.pushed, + [0x01] = capabilities.button.button.held, + [0x02] = capabilities.button.button.down_hold, + [0x03] = capabilities.button.button.pushed_2x, + [0x04] = capabilities.button.button.pushed_3x, + [0x05] = capabilities.button.button.pushed_4x, + [0x06] = capabilities.button.button.pushed_5x, +} + +local function button_to_component(buttonId) + if buttonId > 0 then + return string.format("button%d", buttonId) + end +end + +local function scene_handler(driver, device, zb_rx) + local bytes = zb_rx.body.zcl_body.body_bytes + local button_number = bytes:byte(1) + local capability_attribute = map_key_attribute_to_capability[bytes:byte(2)] + local additional_fields = { state_change = true } + local event = capability_attribute and capability_attribute(additional_fields) or nil + local comp_name = button_to_component(button_number) + local comp = device.profile.components[comp_name] + if comp ~= nil and event ~= nil then + -- Check if the event is in the supportedButtonValues before emitting + -- This ensures backward compatibility with devices installed with previous driver versions + local expected_values = inovelli_common.supported_button_values[comp_name] + local supportedEvents = device:get_latest_state( + comp_name, + capabilities.button.ID, + capabilities.button.supportedButtonValues.NAME, + {capabilities.button.button.pushed.NAME} -- default fallback for older devices + ) + + -- Check if supportedButtonValues needs to be updated + -- This handles devices installed with previous driver versions that don't have + -- the updated supportedButtonValues attribute. If the current value only contains + -- "pushed" (the fallback), update it to the full list. + local needs_update = false + if expected_values then + -- Check if current supportedEvents is exactly the fallback (only "pushed") + -- This indicates the state was never set and we're using the fallback value + if #supportedEvents == 1 and supportedEvents[1] == capabilities.button.button.pushed.NAME then + needs_update = true + end + + if needs_update then + device:emit_component_event( + comp, + capabilities.button.supportedButtonValues( + expected_values, + { visibility = { displayed = false } } + ) + ) + supportedEvents = expected_values -- Update local reference for event check + end + end + + -- Check if the event is supported + local event_supported = false + for _, event_name in pairs(supportedEvents) do + if event.value.value == event_name then + event_supported = true + break + end + end + + if event_supported then + device:emit_component_event(comp, event) + end + end +end + +local function add_child(driver,parent,profile,child_type) + local child_metadata = { + type = "EDGE_CHILD", + label = string.format("%s %s", parent.label, child_type:gsub("(%l)(%w*)", function(a,b) return string.upper(a)..b end)), + profile = profile, + parent_device_id = parent.id, + parent_assigned_child_key = child_type, + vendor_provided_label = string.format("%s %s", parent.label, child_type:gsub("(%l)(%w*)", function(a,b) return string.upper(a)..b end)) + } + driver:try_create_device(child_metadata) +end + +local function info_changed(driver, device, event, args) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + local time_diff = 3 + local last_clock_set_time = device:get_field(LATEST_CLOCK_SET_TIMESTAMP) + if last_clock_set_time ~= nil then time_diff = os.difftime(os.time(), last_clock_set_time) end + device:set_field(LATEST_CLOCK_SET_TIMESTAMP, os.time(), {persist = true}) + if time_diff > 2 then + local preferences = get_preference_map_for_device(device) + if args.old_st_store.preferences["notificationChild"] ~= device.preferences.notificationChild and args.old_st_store.preferences["notificationChild"] == false and device.preferences.notificationChild == true then + if not device:get_child_by_parent_assigned_key('notification') then + add_child(driver,device,'rgbw-bulb-2700K-6500K','notification') + end + end + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences and preferences[id] then + local new_parameter_value = preferences_calculate_parameter(preferences_to_numeric_value(device.preferences[id]), preferences[id].size, id) + if(preferences[id].size == data_types.Boolean) then + new_parameter_value = to_boolean(new_parameter_value) + end + if id == "parameter111" then + device:send(cluster_base.build_manufacturer_specific_command( + device, + PRIVATE_CLUSTER_MMWAVE_ID, + PRIVATE_CMD_MMWAVE_ID, + MFG_CODE, + utils.serialize_int(new_parameter_value,1,false,false))) + else + device:send(cluster_base.write_manufacturer_specific_attribute(device, preferences[id].cluster, preferences[id].parameter_number, MFG_CODE, preferences[id].size, new_parameter_value)) + end + end + end + end + end +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:refresh() + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.colorTemperature.colorTemperature(6500)) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local function device_configure(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + inovelli_common.base_device_configure(driver, device, PRIVATE_CLUSTER_ID, MFG_CODE) + else + device:configure() + end +end + +local function huePercentToValue(value) + if value <= 2 then return 0 + elseif value >= 98 then return 255 + else return utils.round(value / 100 * 255) end +end + +local function getNotificationValue(device, parent, value) + local notificationValue = 0 + local level = device:get_latest_state("main", capabilities.switchLevel.ID, capabilities.switchLevel.level.NAME) or 100 + local color = utils.round(device:get_latest_state("main", capabilities.colorControl.ID, capabilities.colorControl.hue.NAME) or 100) + local effect = (parent and parent.preferences.notificationType) or 1 + notificationValue = notificationValue + (effect*16777216) + notificationValue = notificationValue + (huePercentToValue(value or color)*65536) + notificationValue = notificationValue + (level*256) + notificationValue = notificationValue + (255*1) + return notificationValue +end + +local function on_handler(driver, device, command) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(clusters.OnOff.server.commands.On(device)) + else + device:emit_event(capabilities.switch.switch("on")) + local dev = device:get_parent_device() + if dev == nil then + return + end + local send_configuration = function() + dev:send(cluster_base.build_manufacturer_specific_command( + dev, + PRIVATE_CLUSTER_ID, + PRIVATE_CMD_NOTIF_ID, + MFG_CODE, + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) + end + device.thread:call_with_delay(1,send_configuration) + end + end + + local function off_handler(driver, device, command) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(clusters.OnOff.server.commands.Off(device)) + else + device:emit_event(capabilities.switch.switch("off")) + local dev = device:get_parent_device() + if dev == nil then + return + end + local send_configuration = function() + dev:send(cluster_base.build_manufacturer_specific_command( + dev, + PRIVATE_CLUSTER_ID, + PRIVATE_CMD_NOTIF_ID, + MFG_CODE, + utils.serialize_int(0,4,false,false))) + end + device.thread:call_with_delay(1,send_configuration) + end + end + +local function switch_level_handler(driver, device, command) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(clusters.Level.server.commands.MoveToLevelWithOnOff(device, math.floor(command.args.level/100.0 * 254), command.args.rate or 0xFFFF)) + else + device:emit_event(capabilities.switchLevel.level(command.args.level)) + device:emit_event(capabilities.switch.switch(command.args.level ~= 0 and "on" or "off")) + local dev = device:get_parent_device() + if dev == nil then + return + end + local send_configuration = function() + dev:send(cluster_base.build_manufacturer_specific_command( + dev, + PRIVATE_CLUSTER_ID, + PRIVATE_CMD_NOTIF_ID, + MFG_CODE, + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) + end + device.thread:call_with_delay(1,send_configuration) + end + end + +local function set_color_temperature(driver, device, command) + device:emit_event(capabilities.colorControl.hue(100)) + device:emit_event(capabilities.colorTemperature.colorTemperature(command.args.temperature)) + device:emit_event(capabilities.switch.switch("on")) + local dev = device:get_parent_device() + if dev == nil then + return + end + local send_configuration = function() + dev:send(cluster_base.build_manufacturer_specific_command( + dev, + PRIVATE_CLUSTER_ID, + PRIVATE_CMD_NOTIF_ID, + MFG_CODE, + utils.serialize_int(getNotificationValue(device, dev, 100),4,false,false))) + end + device.thread:call_with_delay(1,send_configuration) + end + + local function set_color(driver, device, command) + device:emit_event(capabilities.colorControl.hue(command.args.color.hue)) + device:emit_event(capabilities.colorControl.saturation(command.args.color.saturation)) + device:emit_event(capabilities.switch.switch("on")) + local dev = device:get_parent_device() + if dev == nil then + return + end + local send_configuration = function() + dev:send(cluster_base.build_manufacturer_specific_command( + dev, + PRIVATE_CLUSTER_ID, + PRIVATE_CMD_NOTIF_ID, + MFG_CODE, + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) + end + device.thread:call_with_delay(1,send_configuration) + end + +local function occupancy_attr_handler(driver, device, occupancy, zb_rx) + device:emit_event(occupancy.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) +end + +local function handle_resetEnergyMeter(self, device) + device:send(cluster_base.build_manufacturer_specific_command(device, PRIVATE_CLUSTER_ID, PRIVATE_CMD_ENERGY_RESET_ID, MFG_CODE, utils.serialize_int(0,1,false,false))) + device:send(clusters.SimpleMetering.attributes.CurrentSummationDelivered:read(device)) + device:send(clusters.ElectricalMeasurement.attributes.ActivePower:read(device)) +end + +local inovelli = { + NAME = "Inovelli Zigbee Switch", + lifecycle_handlers = { + doConfigure = device_configure, + infoChanged = info_changed, + added = device_added, + }, + zigbee_handlers = { + attr = { + [OccupancySensing.ID] = { + [OccupancySensing.attributes.Occupancy.ID] = occupancy_attr_handler + }, + }, + cluster = { + [PRIVATE_CLUSTER_ID] = { + [PRIVATE_CMD_SCENE_ID] = scene_handler, + } + } + }, + sub_drivers = require("inovelli.sub_drivers"), + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = on_handler, + [capabilities.switch.commands.off.NAME] = off_handler, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = switch_level_handler + }, + [capabilities.colorControl.ID] = { + [capabilities.colorControl.commands.setColor.NAME] = set_color + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temperature + }, + [capabilities.energyMeter.ID] = { + [capabilities.energyMeter.commands.resetEnergyMeter.NAME] = handle_resetEnergyMeter, + } + }, + can_handle = require("inovelli.can_handle"), +} + +return inovelli diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/sub_drivers.lua new file mode 100644 index 0000000000..2fc65221be --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("inovelli.vzm30-sn"), + lazy_load("inovelli.vzm32-sn") +} diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/can_handle.lua new file mode 100644 index 0000000000..9d1b285b45 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(opts, driver, device) + local INOVELLI_VZM30_SN_FINGERPRINTS = { + { mfr = "Inovelli", model = "VZM30-SN" }, + } + for _, fp in ipairs(INOVELLI_VZM30_SN_FINGERPRINTS) do + if device:get_manufacturer() == fp.mfr and device:get_model() == fp.model then + local sub_driver = require("inovelli.vzm30-sn") + return true, sub_driver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/init.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/init.lua new file mode 100644 index 0000000000..6ad3ec758d --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm30-sn/init.lua @@ -0,0 +1,53 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local st_device = require "st.device" +local inovelli_common = require "inovelli.common" + +local TemperatureMeasurement = clusters.TemperatureMeasurement +local RelativeHumidity = clusters.RelativeHumidity + +local PRIVATE_CLUSTER_ID = 0xFC31 +local MFG_CODE = 0x122F + +local function configure_temperature_reporting(device) + local min_temp_change = 50 -- 0.5°C in 0.01°C units + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 3600, min_temp_change)) +end + +local function configure_humidity_reporting(device) + local min_humidity_change = 50 -- 0.5% in 0.01% units + device:send(RelativeHumidity.attributes.MeasuredValue:configure_reporting(device, 30, 3600, min_humidity_change)) +end + +local function device_configure(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + inovelli_common.base_device_configure(driver, device, PRIVATE_CLUSTER_ID, MFG_CODE) + configure_temperature_reporting(device) + configure_humidity_reporting(device) + else + device:configure() + end +end + +local vzm30_sn = { + NAME = "Inovelli VZM30-SN Zigbee Switch", + can_handle = require("inovelli.vzm30-sn.can_handle"), + lifecycle_handlers = { + doConfigure = device_configure, + }, +} + +return vzm30_sn \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/can_handle.lua new file mode 100644 index 0000000000..aa0004b193 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(opts, driver, device) + local INOVELLI_VZM32_SN_FINGERPRINTS = { + { mfr = "Inovelli", model = "VZM32-SN" }, + } + for _, fp in ipairs(INOVELLI_VZM32_SN_FINGERPRINTS) do + if device:get_manufacturer() == fp.mfr and device:get_model() == fp.model then + local sub_driver = require("inovelli.vzm32-sn") + return true, sub_driver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/init.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/init.lua new file mode 100644 index 0000000000..4e0151aeb6 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/vzm32-sn/init.lua @@ -0,0 +1,68 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local st_device = require "st.device" +local device_management = require "st.zigbee.device_management" +local inovelli_common = require "inovelli.common" + +local OccupancySensing = clusters.OccupancySensing + +local PRIVATE_CLUSTER_ID = 0xFC31 +local MFG_CODE = 0x122F + + +local function configure_illuminance_reporting(device) + local min_lux_change = 15 + local value = math.floor(10000 * math.log(min_lux_change, 10) + 1) + device:send(clusters.IlluminanceMeasurement.attributes.MeasuredValue:configure_reporting(device, 10, 600, value)) +end + +local function refresh_handler(driver, device, command) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:refresh() + device:send(OccupancySensing.attributes.Occupancy:read(device)) + else + device:refresh() + end +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + refresh_handler(driver, device, {}) + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.colorTemperature.colorTemperature(6500)) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local function device_configure(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + inovelli_common.base_device_configure(driver, device, PRIVATE_CLUSTER_ID, MFG_CODE) + device:send(device_management.build_bind_request(device, OccupancySensing.ID, driver.environment_info.hub_zigbee_eui)) + configure_illuminance_reporting(device) + else + device:configure() + end +end + +local vzm32_sn = { + NAME = "Inovelli VZM32-SN mmWave Dimmer", + can_handle = require("inovelli.vzm32-sn.can_handle"), + lifecycle_handlers = { + added = device_added, + doConfigure = device_configure, + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler, + } + } +} + +return vzm32_sn \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/jasco/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/jasco/can_handle.lua new file mode 100644 index 0000000000..c97ff700c8 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/jasco/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local JASCO_SWTICH_FINGERPRINTS = { + { mfr = "Jasco Products", model = "43095" }, + { mfr = "Jasco Products", model = "43132" }, + { mfr = "Jasco Products", model = "43078" } +} + + for _, fingerprint in ipairs(JASCO_SWTICH_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("jasco") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/jasco/init.lua b/drivers/SmartThings/zigbee-switch/src/jasco/init.lua index 6c90115a22..a21059dfb4 100644 --- a/drivers/SmartThings/zigbee-switch/src/jasco/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/jasco/init.lua @@ -1,38 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local SimpleMetering = clusters.SimpleMetering local constants = require "st.zigbee.constants" -local JASCO_SWTICH_FINGERPRINTS = { - { mfr = "Jasco Products", model = "43095" }, - { mfr = "Jasco Products", model = "43132" }, - { mfr = "Jasco Products", model = "43078" } -} - -local is_jasco_switch = function(opts, driver, device) - for _, fingerprint in ipairs(JASCO_SWTICH_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("jasco") - return true, subdriver - end - end - return false -end - local device_added = function(self, device) local customEnergyDivisor = 10000 device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, customEnergyDivisor, {persist = true}) @@ -63,7 +36,7 @@ local jasco_switch = { added = device_added, doConfigure = do_configure, }, - can_handle = is_jasco_switch + can_handle = require("jasco.can_handle"), } return jasco_switch diff --git a/drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua new file mode 100644 index 0000000000..51a344c104 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) +local FINGERPRINTS = { + { mfr = "LAISIAO", model = "yuba" }, + { mfr = "LAISIAO", model = "DG60GCM-04-2904W" }, +} + + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("laisiao") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/laisiao/init.lua b/drivers/SmartThings/zigbee-switch/src/laisiao/init.lua new file mode 100755 index 0000000000..8fad192232 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/laisiao/init.lua @@ -0,0 +1,59 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local configurations = require "configurations" + +local function component_to_endpoint(device, component_id) + if component_id == "main" then + return device.fingerprinted_endpoint_id + else + local ep_num = component_id:match("switch(%d)") + return ep_num and tonumber(ep_num) or device.fingerprinted_endpoint_id + end +end + +local function endpoint_to_component(device, ep) + if ep == device.fingerprinted_endpoint_id then + return "main" + else + return string.format("switch%d", ep) + end +end + +local device_init = function(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local function on_handler(driver, device, command) + local attr = capabilities.switch.switch + if command.component == "main" then + -- The main component is set to on by the device and cannot be set to on itself. It can only trigger off + device:emit_event_for_endpoint(device.fingerprinted_endpoint_id, attr.on()) + device.thread:call_with_delay(1, function(d) + device:emit_event_for_endpoint(device.fingerprinted_endpoint_id, attr.off()) + end) + else + device:send_to_component(command.component, zcl_clusters.OnOff.server.commands.On(device)) + end +end + +local laisiao_bath_heater = { + NAME = "Zigbee Laisiao Bathroom Heater", + supported_capabilities = { + capabilities.switch, + }, + lifecycle_handlers = { + init = configurations.reconfig_wrapper(device_init), + }, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = on_handler + } + }, + can_handle = require("laisiao.can_handle"), +} + +return laisiao_bath_heater diff --git a/drivers/SmartThings/zigbee-switch/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-switch/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/device_added.lua b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/device_added.lua new file mode 100644 index 0000000000..a241ea506c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/device_added.lua @@ -0,0 +1,50 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local find_child = require "lifecycle_handlers.find_child" + +local function is_mcd_device(device) + local components = device.profile.components + if type(components) == "table" then + local component_count = 0 + for _, component in pairs(components) do + component_count = component_count + 1 + end + return component_count >= 2 + end +end + +return function(driver, device, event) + local clusters = require "st.zigbee.zcl.clusters" + local ZLL_PROFILE_ID = 0xC05E + local device_lib = require "st.device" + local version = require "version" + + local main_endpoint = device:get_endpoint(clusters.OnOff.ID) + if is_mcd_device(device) == false and device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + for _, ep in ipairs(device.zigbee_endpoints) do + if ep.id ~= main_endpoint then + if device:supports_server_cluster(clusters.OnOff.ID, ep.id) then + device:set_find_child(find_child) + if find_child(device, ep.id) == nil then + local name = string.format("%s %d", device.label, ep.id) + local child_profile = "basic-switch" + driver:try_create_device( + { + type = "EDGE_CHILD", + label = name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", ep.id), + vendor_provided_label = name + } + ) + end + end + end + end + end + if version.api > 15 and device:get_profile_id() == ZLL_PROFILE_ID then + device:refresh() + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua new file mode 100644 index 0000000000..4058a953bb --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua @@ -0,0 +1,27 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +return function(self, device) + local ZLL_PROFILE_ID = 0xC05E + local version = require "version" + if version.api < 16 or (version.api > 15 and device:get_profile_id() ~= ZLL_PROFILE_ID) then + device:refresh() + end + device:configure() + + -- Additional one time configuration + if device:supports_capability(capabilities.energyMeter) or device:supports_capability(capabilities.powerMeter) then + local clusters = require "st.zigbee.zcl.clusters" + -- Divisor and multipler for EnergyMeter + device:send(clusters.SimpleMetering.attributes.Divisor:read(device)) + device:send(clusters.SimpleMetering.attributes.Multiplier:read(device)) + end + + if device:supports_capability(capabilities.colorTemperature) then + local clusters = require "st.zigbee.zcl.clusters" + -- min and max for color temperature + device:send(clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:read(device)) + device:send(clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:read(device)) + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/find_child.lua b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/find_child.lua new file mode 100644 index 0000000000..31b1640619 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/find_child.lua @@ -0,0 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end diff --git a/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/info_changed.lua b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/info_changed.lua new file mode 100644 index 0000000000..bd15064b5e --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/info_changed.lua @@ -0,0 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(self, device, event, args) + local preferences = require "preferences" + preferences.update_preferences(self, device, args) +end diff --git a/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/can_handle.lua new file mode 100644 index 0000000000..80cd735e93 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local FINGERPRINTS = require "multi-switch-no-master.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model and (device:get_manufacturer() == nil or device:get_manufacturer() == fingerprint.mfr) then + local subdriver = require("multi-switch-no-master") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/fingerprints.lua new file mode 100644 index 0000000000..48dd5ac246 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/fingerprints.lua @@ -0,0 +1,47 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "DAWON_DNS", model = "PM-S240-ZB", children = 1 }, + { mfr = "DAWON_DNS", model = "PM-S240R-ZB", children = 1 }, + { mfr = "DAWON_DNS", model = "PM-S250-ZB", children = 1 }, + { mfr = "DAWON_DNS", model = "PM-S340-ZB", children = 2 }, + { mfr = "DAWON_DNS", model = "PM-S340R-ZB", children = 2 }, + { mfr = "DAWON_DNS", model = "PM-S350-ZB", children = 2 }, + { mfr = "DAWON_DNS", model = "ST-S250-ZB", children = 1 }, + { mfr = "DAWON_DNS", model = "ST-S350-ZB", children = 2 }, + { mfr = "ORVIBO", model = "074b3ffba5a045b7afd94c47079dd553", children = 1 }, + { mfr = "ORVIBO", model = "9f76c9f31b4c4a499e3aca0977ac4494", children = 2 }, + { mfr = "REXENSE", model = "HY0002", children = 1 }, + { mfr = "REXENSE", model = "HY0003", children = 2 }, + { mfr = "REX", model = "HY0096", children = 1 }, + { mfr = "REX", model = "HY0097", children = 2 }, + { mfr = "HEIMAN", model = "HS2SW2L-EFR-3.0", children = 1 }, + { mfr = "HEIMAN", model = "HS2SW3L-EFR-3.0", children = 2 }, + { mfr = "HEIMAN", model = "HS6SW2A-W-EF-3.0", children = 1 }, + { mfr = "HEIMAN", model = "HS6SW3A-W-EF-3.0", children = 2 }, + { mfr = "eWeLink", model = "ZB-SW02", children = 1 }, + { mfr = "eWeLink", model = "ZB-SW03", children = 2 }, + { mfr = "eWeLink", model = "ZB-SW04", children = 3 }, + { mfr = "SMARTvill", model = "SLA01", children = 0 }, + { mfr = "SMARTvill", model = "SLA02", children = 1 }, + { mfr = "SMARTvill", model = "SLA03", children = 2 }, + { mfr = "SMARTvill", model = "SLA04", children = 3 }, + { mfr = "SMARTvill", model = "SLA05", children = 4 }, + { mfr = "SMARTvill", model = "SLA06", children = 5 }, + { mfr = "ShinaSystem", model = "SBM300Z2", children = 1 }, + { mfr = "ShinaSystem", model = "SBM300Z3", children = 2 }, + { mfr = "ShinaSystem", model = "SBM300Z4", children = 3 }, + { mfr = "ShinaSystem", model = "SBM300Z5", children = 4 }, + { mfr = "ShinaSystem", model = "SBM300Z6", children = 5 }, + { mfr = "ShinaSystem", model = "SQM300Z2", children = 1 }, + { mfr = "ShinaSystem", model = "SQM300Z3", children = 2 }, + { mfr = "ShinaSystem", model = "SQM300Z4", children = 3 }, + { mfr = "ShinaSystem", model = "SQM300Z6", children = 5 }, + { model = "E220-KR2N0Z0-HA", children = 1 }, + { model = "E220-KR3N0Z0-HA", children = 2 }, + { model = "E220-KR4N0Z0-HA", children = 3 }, + { model = "E220-KR5N0Z0-HA", children = 4 }, + { model = "E220-KR6N0Z0-HA", children = 5 }, + { mfr = "JNL", model = "Y-K003-001", children = 2 } +} diff --git a/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua index 45a8e538af..bac6368ad2 100644 --- a/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua @@ -1,70 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local st_device = require "st.device" local utils = require "st.utils" - -local MULTI_SWITCH_NO_MASTER_FINGERPRINTS = { - { mfr = "DAWON_DNS", model = "PM-S240-ZB", children = 1 }, - { mfr = "DAWON_DNS", model = "PM-S240R-ZB", children = 1 }, - { mfr = "DAWON_DNS", model = "PM-S250-ZB", children = 1 }, - { mfr = "DAWON_DNS", model = "PM-S340-ZB", children = 2 }, - { mfr = "DAWON_DNS", model = "PM-S340R-ZB", children = 2 }, - { mfr = "DAWON_DNS", model = "PM-S350-ZB", children = 2 }, - { mfr = "DAWON_DNS", model = "ST-S250-ZB", children = 1 }, - { mfr = "DAWON_DNS", model = "ST-S350-ZB", children = 2 }, - { mfr = "ORVIBO", model = "074b3ffba5a045b7afd94c47079dd553", children = 1 }, - { mfr = "ORVIBO", model = "9f76c9f31b4c4a499e3aca0977ac4494", children = 2 }, - { mfr = "REXENSE", model = "HY0002", children = 1 }, - { mfr = "REXENSE", model = "HY0003", children = 2 }, - { mfr = "REX", model = "HY0096", children = 1 }, - { mfr = "REX", model = "HY0097", children = 2 }, - { mfr = "HEIMAN", model = "HS2SW2L-EFR-3.0", children = 1 }, - { mfr = "HEIMAN", model = "HS2SW3L-EFR-3.0", children = 2 }, - { mfr = "HEIMAN", model = "HS6SW2A-W-EF-3.0", children = 1 }, - { mfr = "HEIMAN", model = "HS6SW3A-W-EF-3.0", children = 2 }, - { mfr = "eWeLink", model = "ZB-SW02", children = 1 }, - { mfr = "eWeLink", model = "ZB-SW03", children = 2 }, - { mfr = "eWeLink", model = "ZB-SW04", children = 3 }, - { mfr = "SMARTvill", model = "SLA02", children = 1 }, - { mfr = "SMARTvill", model = "SLA03", children = 2 }, - { mfr = "SMARTvill", model = "SLA04", children = 3 }, - { mfr = "SMARTvill", model = "SLA05", children = 4 }, - { mfr = "SMARTvill", model = "SLA06", children = 5 }, - { mfr = "ShinaSystem", model = "SBM300Z2", children = 1 }, - { mfr = "ShinaSystem", model = "SBM300Z3", children = 2 }, - { mfr = "ShinaSystem", model = "SBM300Z4", children = 3 }, - { mfr = "ShinaSystem", model = "SBM300Z5", children = 4 }, - { mfr = "ShinaSystem", model = "SBM300Z6", children = 5 }, - { model = "E220-KR2N0Z0-HA", children = 1 }, - { model = "E220-KR3N0Z0-HA", children = 2 }, - { model = "E220-KR4N0Z0-HA", children = 3 }, - { model = "E220-KR5N0Z0-HA", children = 4 }, - { model = "E220-KR6N0Z0-HA", children = 5 } -} - -local function is_multi_switch_no_master(opts, driver, device) - for _, fingerprint in ipairs(MULTI_SWITCH_NO_MASTER_FINGERPRINTS) do - if device:get_model() == fingerprint.model and (device:get_manufacturer() == nil or device:get_manufacturer() == fingerprint.mfr) then - local subdriver = require("multi-switch-no-master") - return true, subdriver - end - end - return false -end +local configurations = require "configurations" local function get_children_amount(device) - for _, fingerprint in ipairs(MULTI_SWITCH_NO_MASTER_FINGERPRINTS) do + for _, fingerprint in ipairs(require("multi-switch-no-master.fingerprints")) do if device:get_model() == fingerprint.model then return fingerprint.children end @@ -108,11 +49,10 @@ end local multi_switch_no_master = { NAME = "multi switch no master", lifecycle_handlers = { - init = device_init, + init = configurations.reconfig_wrapper(device_init), added = device_added }, - can_handle = is_multi_switch_no_master + can_handle = require("multi-switch-no-master.can_handle"), } return multi_switch_no_master - diff --git a/drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/can_handle.lua new file mode 100644 index 0000000000..bc394d4fa0 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local st_device = require "st.device" + + if device.network_type ~= st_device.NETWORK_TYPE_ZIGBEE and device.network_type ~= st_device.NETWORK_TYPE_CHILD then + return true, require("non_zigbee_devices") + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/init.lua b/drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/init.lua new file mode 100644 index 0000000000..63876e658a --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/non_zigbee_devices/init.lua @@ -0,0 +1,39 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- This is a patch for the zigbee-switch driver to fix https://smartthings.atlassian.net/browse/CHAD-16558 +-- Several hubs were found that had zigbee switch drivers hosting zwave devices. +-- This patch works around it until hubcore 0.59 is released with +-- https://smartthings.atlassian.net/browse/CHAD-16552 + +local log = require "log" + + +local function device_added(driver, device, event) + log.info(string.format("Non zigbee device added: %s", device)) +end + +local function device_init(driver, device, event) + log.info(string.format("Non zigbee device init: %s", device)) +end + +local function do_configure(driver, device) + log.info(string.format("Non zigbee do configure: %s", device)) +end + +local function info_changed(driver, device, event, args) + log.info(string.format("Non zigbee infoChanged: %s", device)) +end + +local non_zigbee_devices = { + NAME = "non zigbee devices filter", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + infoChanged = info_changed + }, + can_handle = require("non_zigbee_devices.can_handle"), +} + +return non_zigbee_devices diff --git a/drivers/SmartThings/zigbee-switch/src/preferences.lua b/drivers/SmartThings/zigbee-switch/src/preferences.lua index 64c003eb56..0e9c8f5b87 100644 --- a/drivers/SmartThings/zigbee-switch/src/preferences.lua +++ b/drivers/SmartThings/zigbee-switch/src/preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -18,7 +7,10 @@ local data_types = require "st.zigbee.data_types" local devices = { AQARA_LIGHT = { - MATCHING_MATRIX = { mfr = "LUMI", model = "lumi.light.acn004" }, + MATCHING_MATRIX = { + { mfr = "LUMI", model = "lumi.light.acn004" }, + { mfr = "LUMI", model = "lumi.light.cwacn1" } + }, PARAMETERS = { ["stse.restorePowerState"] = function(device, value) return cluster_base.write_manufacturer_specific_attribute(device, 0xFCC0, @@ -39,7 +31,7 @@ local devices = { } }, AQARA_LIGHT_BULB = { - MATCHING_MATRIX = { mfr = "Aqara", model = "lumi.light.acn014" }, + MATCHING_MATRIX = {{ mfr = "Aqara", model = "lumi.light.acn014" }}, PARAMETERS = { ["stse.restorePowerState"] = function(device, value) return cluster_base.write_manufacturer_specific_attribute(device, 0xFCC0, @@ -54,7 +46,19 @@ preferences.update_preferences = function(driver, device, args) local prefs = preferences.get_device_parameters(device) if prefs ~= nil then for id, value in pairs(device.preferences) do - if not (args and args.old_st_store) or (args.old_st_store.preferences[id] ~= value and prefs and prefs[id]) then + if not (args and args.old_st_store and args.old_st_store.preferences) or (args.old_st_store.preferences[id] ~= value and prefs and prefs[id]) then + local message = prefs[id](device, value) + device:send(message) + end + end + end +end + +preferences.sync_preferences = function(driver, device) + local prefs = preferences.get_device_parameters(device) + if prefs ~= nil then + for id, value in pairs(device.preferences) do + if prefs and prefs[id] then local message = prefs[id](device, value) device:send(message) end @@ -63,12 +67,17 @@ preferences.update_preferences = function(driver, device, args) end preferences.get_device_parameters = function(zigbee_device) + local mfr = zigbee_device:get_manufacturer() + local model = zigbee_device:get_model() + for _, device in pairs(devices) do - if zigbee_device:get_manufacturer() == device.MATCHING_MATRIX.mfr and - zigbee_device:get_model() == device.MATCHING_MATRIX.model then - return device.PARAMETERS + for _, fp in ipairs(device.MATCHING_MATRIX) do + if fp.mfr == mfr and fp.model == model then + return device.PARAMETERS + end end end + return nil end diff --git a/drivers/SmartThings/zigbee-switch/src/rexense/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/rexense/can_handle.lua new file mode 100644 index 0000000000..bcdc666417 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/rexense/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) +local ZIGBEE_METERING_PLUG_FINGERPRINTS = { + { mfr = "REXENSE", model = "HY0105" } -- HONYAR Outlet" +} + for _, fingerprint in ipairs(ZIGBEE_METERING_PLUG_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("rexense") + return true, subdriver + end + end + + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/rexense/init.lua b/drivers/SmartThings/zigbee-switch/src/rexense/init.lua index d0d457928e..4b16afe3b0 100644 --- a/drivers/SmartThings/zigbee-switch/src/rexense/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/rexense/init.lua @@ -1,26 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local OnOff = zcl_clusters.OnOff -local ZIGBEE_METERING_PLUG_FINGERPRINTS = { - { mfr = "REXENSE", model = "HY0105" } -- HONYAR Outlet" -} - local function switch_on_handler(driver, device, command) device:send_to_component(command.component, OnOff.server.commands.On(device)) device:send(OnOff.server.commands.On(device):to_endpoint(0x02)) @@ -31,16 +16,6 @@ local function switch_off_handler(driver, device, command) device:send(OnOff.server.commands.Off(device):to_endpoint(0x02)) end -local function is_zigbee_metering_plug(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_METERING_PLUG_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("rexense") - return true, subdriver - end - end - - return false -end local zigbee_metering_plug = { NAME = "zigbee metering plug", @@ -50,7 +25,7 @@ local zigbee_metering_plug = { [capabilities.switch.commands.off.NAME] = switch_off_handler } }, - can_handle = is_zigbee_metering_plug + can_handle = require("rexense.can_handle"), } return zigbee_metering_plug diff --git a/drivers/SmartThings/zigbee-switch/src/rgb-bulb/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/rgb-bulb/can_handle.lua new file mode 100644 index 0000000000..e52cf32dcd --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/rgb-bulb/can_handle.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) +local RGB_BULB_FINGERPRINTS = { + ["OSRAM"] = { + ["Gardenspot RGB"] = true, + ["LIGHTIFY Gardenspot RGB"] = true + }, + ["LEDVANCE"] = { + ["Outdoor Accent RGB"] = true + } +} + + + local can_handle = (RGB_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] + if can_handle then + local subdriver = require("rgb-bulb") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/rgb-bulb/init.lua b/drivers/SmartThings/zigbee-switch/src/rgb-bulb/init.lua index ce0f9976db..867b069bfe 100644 --- a/drivers/SmartThings/zigbee-switch/src/rgb-bulb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/rgb-bulb/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -19,25 +8,6 @@ local OnOff = clusters.OnOff local Level = clusters.Level local ColorControl = clusters.ColorControl -local RGB_BULB_FINGERPRINTS = { - ["OSRAM"] = { - ["Gardenspot RGB"] = true, - ["LIGHTIFY Gardenspot RGB"] = true - }, - ["LEDVANCE"] = { - ["Outdoor Accent RGB"] = true - } -} - -local function can_handle_rgb_bulb(opts, driver, device) - local can_handle = (RGB_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] - if can_handle then - local subdriver = require("rgb-bulb") - return true, subdriver - else - return false - end -end local function do_refresh(driver, device) local attributes = { @@ -66,7 +36,7 @@ local rgb_bulb = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_rgb_bulb + can_handle = require("rgb-bulb.can_handle"), } return rgb_bulb diff --git a/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/can_handle.lua new file mode 100644 index 0000000000..724bb6266a --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local RGBW_BULB_FINGERPRINTS = require "rgbw-bulb.fingerprints" + local can_handle = (RGBW_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] + if can_handle then + local subdriver = require("rgbw-bulb") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/fingerprints.lua new file mode 100644 index 0000000000..c8eedcfec8 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/fingerprints.lua @@ -0,0 +1,74 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + ["Samsung Electronics"] = { + ["SAMSUNG-ITM-Z-002"] = true + }, + ["Juno"] = { + ["ABL-LIGHT-Z-201"] = true + }, + ["AduroSmart Eria"] = { + ["AD-RGBW3001"] = true + }, + ["Aurora"] = { + ["RGBCXStrip50AU"] = true, + ["RGBGU10Bulb50AU"] = true, + ["RGBBulb51AU"] = true + }, + ["CWD"] = { + ["ZB.A806Ergbw-A001"] = true, + ["ZB.A806Brgbw-A001"] = true, + ["ZB.M350rgbw-A001"] = true + }, + ["innr"] = { + ["RB 285 C"] = true, + ["BY 285 C"] = true, + ["RB 250 C"] = true, + ["RS 230 C"] = true, + ["AE 280 C"] = true + }, + ["MLI"] = { + ["ZBT-ExtendedColor"] = true + }, + ["OSRAM"] = { + ["LIGHTIFY Flex RGBW"] = true, + ["Flex RGBW"] = true, + ["LIGHTIFY A19 RGBW"] = true, + ["LIGHTIFY BR RGBW"] = true, + ["LIGHTIFY RT RGBW"] = true, + ["LIGHTIFY FLEX OUTDOOR RGBW"] = true + }, + ["LEDVANCE"] = { + ["RT HO RGBW"] = true, + ["A19 RGBW"] = true, + ["FLEX Outdoor RGBW"] = true, + ["FLEX RGBW"] = true, + ["BR30 RGBW"] = true, + ["RT RGBW"] = true, + ["Outdoor Pathway RGBW"] = true, + ["Flex RGBW Pro"] = true + }, + ["LEEDARSON LIGHTING"] = { + ["5ZB-A806ST-Q1G"] = true + }, + ["sengled"] = { + ["E11-N1EA"] = true, + ["E12-N1E"] = true, + ["E21-N1EA"] = true, + ["E1G-G8E"] = true, + ["E11-U3E"] = true, + ["E11-U2E"] = true, + ["E1F-N5E"] = true, + ["E23-N13"] = true + }, + ["Neuhaus Lighting Group"] = { + ["ZBT-ExtendedColor"] = true + }, + ["Ajaxonline"] = { + ["AJ-RGBCCT 5 in 1"] = true + }, + ["Ajax online Ltd"] = { + ["AJ_ZB30_GU10"] = true + } +} diff --git a/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/init.lua b/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/init.lua index fb3a1a32f2..cfa2c81855 100644 --- a/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/rgbw-bulb/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,88 +9,6 @@ local OnOff = clusters.OnOff local Level = clusters.Level local ColorControl = clusters.ColorControl -local RGBW_BULB_FINGERPRINTS = { - ["Samsung Electronics"] = { - ["SAMSUNG-ITM-Z-002"] = true - }, - ["Juno"] = { - ["ABL-LIGHT-Z-201"] = true - }, - ["AduroSmart Eria"] = { - ["AD-RGBW3001"] = true - }, - ["Aurora"] = { - ["RGBCXStrip50AU"] = true, - ["RGBGU10Bulb50AU"] = true, - ["RGBBulb51AU"] = true - }, - ["CWD"] = { - ["ZB.A806Ergbw-A001"] = true, - ["ZB.A806Brgbw-A001"] = true, - ["ZB.M350rgbw-A001"] = true - }, - ["innr"] = { - ["RB 285 C"] = true, - ["BY 285 C"] = true, - ["RB 250 C"] = true, - ["RS 230 C"] = true, - ["AE 280 C"] = true - }, - ["MLI"] = { - ["ZBT-ExtendedColor"] = true - }, - ["OSRAM"] = { - ["LIGHTIFY Flex RGBW"] = true, - ["Flex RGBW"] = true, - ["LIGHTIFY A19 RGBW"] = true, - ["LIGHTIFY BR RGBW"] = true, - ["LIGHTIFY RT RGBW"] = true, - ["LIGHTIFY FLEX OUTDOOR RGBW"] = true - }, - ["LEDVANCE"] = { - ["RT HO RGBW"] = true, - ["A19 RGBW"] = true, - ["FLEX Outdoor RGBW"] = true, - ["FLEX RGBW"] = true, - ["BR30 RGBW"] = true, - ["RT RGBW"] = true, - ["Outdoor Pathway RGBW"] = true, - ["Flex RGBW Pro"] = true - }, - ["LEEDARSON LIGHTING"] = { - ["5ZB-A806ST-Q1G"] = true - }, - ["sengled"] = { - ["E11-N1EA"] = true, - ["E12-N1E"] = true, - ["E21-N1EA"] = true, - ["E1G-G8E"] = true, - ["E11-U3E"] = true, - ["E11-U2E"] = true, - ["E1F-N5E"] = true, - ["E23-N13"] = true - }, - ["Neuhaus Lighting Group"] = { - ["ZBT-ExtendedColor"] = true - }, - ["Ajaxonline"] = { - ["AJ-RGBCCT 5 in 1"] = true - }, - ["Ajax online Ltd"] = { - ["AJ_ZB30_GU10"] = true - } -} - -local function can_handle_rgbw_bulb(opts, driver, device) - local can_handle = (RGBW_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] - if can_handle then - local subdriver = require("rgbw-bulb") - return true, subdriver - else - return false - end -end - local function do_refresh(driver, device) local attributes = { OnOff.attributes.OnOff, @@ -149,7 +56,7 @@ local rgbw_bulb = { doConfigure = do_configure, added = do_added }, - can_handle = can_handle_rgbw_bulb + can_handle = require("rgbw-bulb.can_handle"), } return rgbw_bulb diff --git a/drivers/SmartThings/zigbee-switch/src/robb/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/robb/can_handle.lua new file mode 100644 index 0000000000..480bc83ab5 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/robb/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) +local ROBB_DIMMER_FINGERPRINTS = { + { mfr = "ROBB smarrt", model = "ROB_200-011-0" }, + { mfr = "ROBB smarrt", model = "ROB_200-014-0" } +} + for _, fingerprint in ipairs(ROBB_DIMMER_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("robb") + return true, subdriver + end + end + return false +end + diff --git a/drivers/SmartThings/zigbee-switch/src/robb/init.lua b/drivers/SmartThings/zigbee-switch/src/robb/init.lua index 8aaf62f69e..aa2487c87a 100644 --- a/drivers/SmartThings/zigbee-switch/src/robb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/robb/init.lua @@ -1,53 +1,22 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local constants = require "st.zigbee.constants" local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" - -local constants = require "st.zigbee.constants" - +local configurations = require "configurations" local SimpleMetering = zcl_clusters.SimpleMetering -local ElectricalMeasurement = zcl_clusters.ElectricalMeasurement -local ROBB_DIMMER_FINGERPRINTS = { - { mfr = "ROBB smarrt", model = "ROB_200-011-0" }, - { mfr = "ROBB smarrt", model = "ROB_200-014-0" } -} -local function is_robb_dimmer(opts, driver, device) - for _, fingerprint in ipairs(ROBB_DIMMER_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("robb") - return true, subdriver - end - end - return false -end - -local function energy_meter_handler(driver, device, value, zb_rx) - local raw_value = value.value - local multiplier = device:get_field(constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 - local divisor = device:get_field(constants.SIMPLE_METERING_DIVISOR_KEY) or 1000000 - - raw_value = raw_value * multiplier / divisor - device:emit_event(capabilities.energyMeter.energy({ value = raw_value, unit = "kWh" })) +local do_init = function(driver, device) + device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000000, {persist = true}) + device:set_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10, {persist = true}) end local function power_meter_handler(driver, device, value, zb_rx) local raw_value = value.value local multiplier = device:get_field(constants.ELECTRICAL_MEASUREMENT_MULTIPLIER_KEY) or 1 - local divisor = device:get_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY) or 10 + local divisor = 10 raw_value = raw_value * multiplier / divisor device:emit_event(capabilities.powerMeter.power({ value = raw_value, unit = "W" })) @@ -58,15 +27,14 @@ local robb_dimmer_handler = { zigbee_handlers = { attr = { [SimpleMetering.ID] = { - [SimpleMetering.attributes.InstantaneousDemand.ID] = power_meter_handler, - [SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_meter_handler - }, - [ElectricalMeasurement.ID] = { - [ElectricalMeasurement.attributes.ActivePower.ID] = power_meter_handler + [SimpleMetering.attributes.InstantaneousDemand.ID] = power_meter_handler } } }, - can_handle = is_robb_dimmer + lifecycle_handlers = { + init = configurations.reconfig_wrapper(do_init) + }, + can_handle = require("robb.can_handle"), } return robb_dimmer_handler diff --git a/drivers/SmartThings/zigbee-switch/src/sinope-dimmer/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/sinope-dimmer/can_handle.lua new file mode 100644 index 0000000000..3a7233e6b9 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/sinope-dimmer/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local can_handle = device:get_manufacturer() == "Sinope Technologies" and device:get_model() == "DM2500ZB" + if can_handle then + local subdriver = require("sinope-dimmer") + return true, subdriver + else + return false + end + end diff --git a/drivers/SmartThings/zigbee-switch/src/sinope-dimmer/init.lua b/drivers/SmartThings/zigbee-switch/src/sinope-dimmer/init.lua index 981df6ff5e..d97c873467 100644 --- a/drivers/SmartThings/zigbee-switch/src/sinope-dimmer/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/sinope-dimmer/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cluster_base = require "st.zigbee.cluster_base" @@ -104,15 +93,7 @@ local zigbee_sinope_dimmer = { lifecycle_handlers = { infoChanged = info_changed }, - can_handle = function(opts, driver, device, ...) - local can_handle = device:get_manufacturer() == "Sinope Technologies" and device:get_model() == "DM2500ZB" - if can_handle then - local subdriver = require("sinope-dimmer") - return true, subdriver - else - return false - end - end + can_handle = require("sinope-dimmer.can_handle"), } return zigbee_sinope_dimmer diff --git a/drivers/SmartThings/zigbee-switch/src/sinope/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/sinope/can_handle.lua new file mode 100644 index 0000000000..624baee756 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/sinope/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local can_handle = device:get_manufacturer() == "Sinope Technologies" and device:get_model() == "SW2500ZB" + if can_handle then + local subdriver = require("sinope") + return true, subdriver + else + return false + end + end diff --git a/drivers/SmartThings/zigbee-switch/src/sinope/init.lua b/drivers/SmartThings/zigbee-switch/src/sinope/init.lua index d3e34513a5..af0300a377 100644 --- a/drivers/SmartThings/zigbee-switch/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/sinope/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" @@ -41,15 +30,7 @@ local zigbee_sinope_switch = { lifecycle_handlers = { infoChanged = info_changed }, - can_handle = function(opts, driver, device, ...) - local can_handle = device:get_manufacturer() == "Sinope Technologies" and device:get_model() == "SW2500ZB" - if can_handle then - local subdriver = require("sinope") - return true, subdriver - else - return false - end - end + can_handle = require("sinope.can_handle"), } return zigbee_sinope_switch diff --git a/drivers/SmartThings/zigbee-switch/src/stateless_handlers/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/can_handle.lua new file mode 100644 index 0000000000..845bd33156 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +return function(opts, driver, device) + local can_handle = device:supports_capability(capabilities.statelessColorTemperatureStep) + or device:supports_capability(capabilities.statelessSwitchLevelStep) + if can_handle then + local subdriver = require("stateless_handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua new file mode 100644 index 0000000000..07adfe0e84 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua @@ -0,0 +1,97 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local constants = require "st.zigbee.constants" +local clusters = require "st.zigbee.zcl.clusters" +local switch_utils = require "switch_utils" + +-- These values are the mired versions of the config bounds in the default profile (e.g. color-temp-bulb) +local DEFAULT_MIRED_MAX_BOUND = 370 -- 2700 Kelvin (Mireds are the inverse of Kelvin) +local DEFAULT_MIRED_MIN_BOUND = 154 -- 6500 Kelvin (Mireds are the inverse of Kelvin) + +-- Transition Time: The time that shall be taken to perform the step change, in units of 1/10ths of a second. +-- Specific fields can store custom transition times for stateless capabilities +local SWITCH_LEVEL_STEP_TRANSITION_TIME = "__switch_level_step_transition_time" +local COLOR_TEMP_STEP_TRANSITION_TIME = "__color_temp_step_transition_time" +local DEFAULT_STEP_TRANSITION_TIME = 3 -- 0.3 seconds + +-- Options Mask & Override: Indicates which options are being overridden by the Level/ColorControl cluster commands +local OPTIONS_MASK = 0x01 -- default: The `ExecuteIfOff` option is overriden +local IGNORE_COMMAND_IF_OFF = 0x00 -- default: the command will not be executed if the device is off + +-- Indicates whether a delayed refresh for ZLL devices is in progress, to prevent multiple refreshes in a quick series of step commands +local IS_REFRESH_CALLBACK_QUEUED = "__is_refresh_callback_queued" +-- Stores a timer object, which is required to cancel a timer early +local REFRESH_CALLBACK_TIMER = "__refresh_callback_timer" + +-- Note: These commands' native handlers do not match the driver's ZLL behavior 1-1. +-- Instead, they will queue a 2s timer and read refresh for each command, in all cases. +local function trigger_delayed_refresh_if_zll(device) + if device:get_profile_id() ~= constants.ZLL_PROFILE_ID then + return + end + + -- If a refresh callback is already queued, cancel it and create a new one with the updated time + if device:get_field(IS_REFRESH_CALLBACK_QUEUED) then + device.thread:cancel_timer(device:get_field(REFRESH_CALLBACK_TIMER)) + end + local delay_s = 2 + local new_timer = device.thread:call_with_delay(delay_s, function() + device:refresh() + device:set_field(IS_REFRESH_CALLBACK_QUEUED, nil) + end) + device:set_field(REFRESH_CALLBACK_TIMER, new_timer) + device:set_field(IS_REFRESH_CALLBACK_QUEUED, true) +end + +local function step_color_temperature_by_percent_handler(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_percent_change = cmd.args and cmd.args.stepSize or 0 + if step_percent_change == 0 then return end + local transition_time = device:get_field(COLOR_TEMP_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME + -- Reminder, stepSize > 0 == Kelvin UP == Mireds DOWN. stepSize < 0 == Kelvin DOWN == Mireds UP + local step_mode = (step_percent_change > 0) and clusters.ColorControl.types.CcStepMode.DOWN or clusters.ColorControl.types.CcStepMode.UP + -- note: the field containing the color temp bounds will be associated with a parent device + local field_device = device:get_parent_device() or device + local min_mireds = field_device:get_field(switch_utils.MIRED_MIN_BOUND) + local max_mireds = field_device:get_field(switch_utils.MIRED_MAX_BOUND) + -- since colorTemperatureRange is only set after both custom bounds are, use defaults if any custom bound is missing + if not (min_mireds and max_mireds) then + min_mireds = DEFAULT_MIRED_MIN_BOUND + max_mireds = DEFAULT_MIRED_MAX_BOUND + end + local step_size_in_mireds = st_utils.round((max_mireds - min_mireds) * (math.abs(step_percent_change)/100.0)) + device:send(clusters.ColorControl.server.commands.StepColorTemperature(device, step_mode, step_size_in_mireds, transition_time, min_mireds, max_mireds, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF)) + trigger_delayed_refresh_if_zll(device) +end + +local function step_level_handler(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_size = st_utils.round((cmd.args and cmd.args.stepSize or 0)/100.0 * 254) + if step_size == 0 then return end + local transition_time = device:get_field(SWITCH_LEVEL_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME + local step_mode = (step_size > 0) and clusters.Level.types.MoveStepMode.UP or clusters.Level.types.MoveStepMode.DOWN + device:send(clusters.Level.server.commands.Step(device, step_mode, math.abs(step_size), transition_time, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF)) + trigger_delayed_refresh_if_zll(device) +end + +local stateless_handlers = { + NAME = "Zigbee Stateless Step Handlers", + capability_handlers = { + [capabilities.statelessColorTemperatureStep.ID] = { + [capabilities.statelessColorTemperatureStep.commands.stepColorTemperatureByPercent.NAME] = step_color_temperature_by_percent_handler, + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = step_level_handler, + }, + }, + can_handle = require("stateless_handlers.can_handle") +} + +return stateless_handlers diff --git a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua new file mode 100644 index 0000000000..60390d8074 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua @@ -0,0 +1,42 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local version = require "version" + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("non_zigbee_devices"), + lazy_load_if_possible("hanssem"), + lazy_load_if_possible("aqara"), + lazy_load_if_possible("aqara-light"), + lazy_load_if_possible("ezex"), + lazy_load_if_possible("rexense"), + lazy_load_if_possible("sinope"), + lazy_load_if_possible("sinope-dimmer"), + lazy_load_if_possible("zigbee-dimmer-power-energy"), + lazy_load_if_possible("zigbee-metering-plug-power-consumption-report"), + lazy_load_if_possible("jasco"), + lazy_load_if_possible("multi-switch-no-master"), + lazy_load_if_possible("zigbee-dual-metering-switch"), + lazy_load_if_possible("rgb-bulb"), + lazy_load_if_possible("zigbee-dimming-light"), + lazy_load_if_possible("white-color-temp-bulb"), + lazy_load_if_possible("rgbw-bulb"), + (version.api < 16) and lazy_load_if_possible("zll-dimmer-bulb") or nil, + lazy_load_if_possible("ikea-xy-color-bulb"), + lazy_load_if_possible("zll-polling"), + lazy_load_if_possible("zigbee-switch-power"), + lazy_load_if_possible("ge-link-bulb"), + lazy_load_if_possible("bad_on_off_data_type"), + lazy_load_if_possible("robb"), + lazy_load_if_possible("wallhero"), + lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN + lazy_load_if_possible("laisiao"), + lazy_load_if_possible("tuya-multi"), + 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("firstled-io"), + lazy_load_if_possible("firstled-light") +} diff --git a/drivers/SmartThings/zigbee-switch/src/switch_utils.lua b/drivers/SmartThings/zigbee-switch/src/switch_utils.lua new file mode 100644 index 0000000000..d30ada0588 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/switch_utils.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" + +local switch_utils = {} + +switch_utils.MIRED_MAX_BOUND = "__max_mired_bound" +switch_utils.MIRED_MIN_BOUND = "__min_mired_bound" + +switch_utils.MIREDS_CONVERSION_CONSTANT = 1000000 + +switch_utils.convert_mired_to_kelvin = function(mired) + return st_utils.round(switch_utils.MIREDS_CONVERSION_CONSTANT / mired) +end + +switch_utils.emit_event_if_latest_state_missing = function(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +return switch_utils diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua index 5366464120..8826ca535d 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -22,6 +11,13 @@ local ElectricalMeasurement = clusters.ElectricalMeasurement local SimpleMetering = clusters.SimpleMetering local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local messages = require "st.zigbee.messages" +local config_reporting_response = require "st.zigbee.zcl.global_commands.configure_reporting_response" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local data_types = require "st.zigbee.data_types" +local Status = require "st.zigbee.generated.types.ZclStatus" + local zigbee_bulb_all_caps = { components = { @@ -29,8 +25,10 @@ local zigbee_bulb_all_caps = { capabilities = { [capabilities.switch.ID] = { id = capabilities.switch.ID }, [capabilities.switchLevel.ID] = { id = capabilities.switchLevel.ID }, + [capabilities.statelessSwitchLevelStep.ID] = { id = capabilities.statelessSwitchLevelStep.ID }, [capabilities.colorControl.ID] = { id = capabilities.colorControl.ID }, [capabilities.colorTemperature.ID] = { id = capabilities.colorTemperature.ID }, + [capabilities.statelessColorTemperatureStep.ID] = { id = capabilities.statelessColorTemperatureStep.ID }, [capabilities.powerMeter.ID] = { id = capabilities.powerMeter.ID }, [capabilities.energyMeter.ID] = { id = capabilities.energyMeter.ID }, [capabilities.refresh.ID] = { id = capabilities.refresh.ID }, @@ -43,12 +41,41 @@ local mock_device = test.mock_device.build_test_zigbee_device({ profile = zigbee zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + mock_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) +local function build_config_response_msg(device, cluster, global_status, attribute, attr_status) + local addr_header = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local config_response_body + if global_status ~= nil then + config_response_body = config_reporting_response.ConfigureReportingResponse({}, global_status) + else + local individual_record = config_reporting_response.ConfigureReportingResponseRecord(attr_status, 0x01, attribute) + config_response_body = config_reporting_response.ConfigureReportingResponse({individual_record}, nil) + end + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(config_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = config_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + + test.register_message_test( "Reported level should be handled", { @@ -65,7 +92,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(83)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -82,7 +120,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -99,7 +148,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -111,6 +171,14 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57, 0 } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_cmd_id = "setLevel" } + } + }, { channel = "zigbee", direction = "send", @@ -118,6 +186,9 @@ test.register_message_test( math.floor(57 * 0xFE / 100), 0) } } + }, + { + min_api_version = 17 } ) @@ -134,7 +205,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -152,6 +234,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27000.0, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -168,7 +253,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.colorControl.hue(0)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + } } + }, + { + min_api_version = 19 } ) @@ -185,8 +281,321 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + } + } + }, + { + min_api_version = 19 + } +) + +local DEFAULT_MIRED_MAX = 370 +local DEFAULT_MIRED_MIN = 154 +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 + +test.register_message_test( + "Step ColorTemperature command test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 43, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 90 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 194, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.UP, 108, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Step Level command test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.DOWN, 127, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 100 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 254, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) } } + }, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "lifecycle configure event should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentHue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentSaturation:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + ColorControl.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 0x0010) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 0x0010) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 0x0010) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -194,6 +603,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) @@ -264,6 +677,14 @@ test.register_coroutine_test( mock_device.id, ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 0x0010) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + }) test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, @@ -272,7 +693,7 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 1, 3600, 5) + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -286,8 +707,16 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 1, 3600, 5) + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) @@ -304,34 +733,146 @@ test.register_coroutine_test( mock_device.id, SimpleMetering.attributes.Divisor:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 20 + } ) +-- test.register_coroutine_test( +-- "health check coroutine", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) +-- test.wait_for_events() + +-- test.mock_time.advance_time(10000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) +-- test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + +-- end, +-- { +-- test_init = function() +-- mock_device:set_field("_configuration_version", 1, {persist = true}) +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) test.register_coroutine_test( - "health check coroutine", + "configuration version below 1", function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 600, 5)}) + test.mock_time.advance_time(5*60 + 1) test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, Status.SUCCESS)}) + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) - test.mock_time.advance_time(10000) - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) +test.register_coroutine_test( + "configuration version below 1 config response not success", + function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, Status.UNSUPPORTED_ATTRIBUTE)}) + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, Status.UNSUPPORTED_ATTRIBUTE)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == nil) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) +test.register_coroutine_test( + "configuration version below 1 individual config response records ElectricalMeasurement", + function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, nil, ElectricalMeasurement.attributes.ActivePower.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) end, { test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end + -- no op to override auto device add on startup + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "configuration version below 1 individual config response records SimpleMetering", + function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + assert(mock_device:get_field("_configuration_version") == nil) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.mock_time.advance_time(5*60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, nil, SimpleMetering.attributes.InstantaneousDemand.ID, Status.SUCCESS)}) + test.wait_for_events() + assert(mock_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to override auto device add on startup + end, + min_api_version = 17 } ) @@ -340,6 +881,7 @@ test.register_coroutine_test( function() test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setColor") test.socket.zigbee:__expect_send( { mock_device.id, @@ -365,7 +907,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) -- This tests that our code responsible for handling conversion errors from Kelvin<->Mireds works as expected @@ -374,11 +919,26 @@ test.register_coroutine_test( function() test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800}}}) + mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") test.socket.zigbee:__expect_send({mock_device.id, ColorControl.server.commands.MoveToColorTemperature(mock_device, 556, 0x0000)}) test.socket.zigbee:__expect_send({mock_device.id, OnOff.server.commands.On(mock_device)}) test.wait_for_events() test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device, 556)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800))) + mock_device:expect_native_attr_handler_registration("colorTemperature", "colorTemperature") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Color temperature range report test", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({mock_device.id, clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_attr_report(mock_device, 370)}) + test.socket.zigbee:__queue_receive({mock_device.id, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_attr_report(mock_device, 153)}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2703, maximum = 6536}))) end ) @@ -400,7 +960,10 @@ test.register_coroutine_test( --- offset should be reset by a reading under the previous offset test.socket.zigbee:__queue_receive({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 14)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 14.0, unit = "kWh" }))) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua index f3b86453dd..cc3e86d218 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -18,6 +7,7 @@ local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" local OnOff = clusters.OnOff local Level = clusters.Level @@ -52,6 +42,22 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes and refresh device", function() @@ -61,12 +67,59 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send( { mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID - , MFG_CODE, data_types.Uint8, 1) + ColorControl.commands.MoveToColorTemperature(mock_device, 200, 0) } ) - test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 0) }) - test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 0) }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send( { mock_device.id, @@ -104,13 +157,28 @@ test.register_coroutine_test( OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -120,6 +188,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = { 200 } } }) + mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") local temp_in_mired = math.floor(1000000 / 200) test.socket.zigbee:__expect_send( { @@ -133,7 +202,10 @@ test.register_coroutine_test( ColorControl.commands.MoveToColorTemperature(mock_device, temp_in_mired, 0x0000) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -145,7 +217,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua index a9517ab626..a80bf1dcfc 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -18,6 +7,7 @@ local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" local OnOff = clusters.OnOff local Level = clusters.Level @@ -33,6 +23,7 @@ local TURN_OFF_INDICATOR_ATTRIBUTE_ID = 0x0203 local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("aqara-light.yml"), + preferences = { ["stse.lightFadeInTimeInSec"] = 0, ["stse.lightFadeOutTimeInSec"] = 0 }, fingerprinted_endpoint_id = 0x01, zigbee_endpoints = { [1] = { @@ -53,21 +44,101 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes and refresh device", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 0) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 0) }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColorTemperature(mock_device, 200, 0) + } + ) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send( { mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID - , MFG_CODE, data_types.Uint8, 1) + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) } ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) + + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 0) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 0) }) + test.socket.zigbee:__expect_send( { mock_device.id, @@ -109,9 +180,12 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -121,6 +195,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = { 200 } } }) + mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") local temp_in_mired = math.floor(1000000 / 200) test.socket.zigbee:__expect_send( { @@ -134,7 +209,10 @@ test.register_coroutine_test( ColorControl.commands.MoveToColorTemperature(mock_device, temp_in_mired, 0x0000) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -146,7 +224,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -158,7 +239,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, TURN_OFF_INDICATOR_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -168,7 +252,10 @@ test.register_coroutine_test( preferences = { ["stse.lightFadeInTimeInSec"] = 1 } })) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 10) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -178,7 +265,45 @@ test.register_coroutine_test( preferences = { ["stse.lightFadeOutTimeInSec"] = 1 } })) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 10) }) - end + end, + { + min_api_version = 17 + } +) + +local mock_device_cwacn1 = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-light.yml"), + preferences = { ["stse.lightFadeInTimeInSec"] = 0, ["stse.lightFadeOutTimeInSec"] = 0 }, + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.light.cwacn1", + server_clusters = { 0x0006, 0x0008, 0x0300 } + } + } + } +) + +test.register_coroutine_test( + "Handle added lifecycle for lumi.light.cwacn1 model (colorTemperatureRange max = 6500)", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_cwacn1.id, "added" }) + + test.socket.zigbee:__expect_send({ + mock_device_cwacn1.id, cluster_base.write_manufacturer_specific_attribute(mock_device_cwacn1, PRIVATE_CLUSTER_ID, + PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_device_cwacn1:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6500 }))) + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device_cwacn1) + end, + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua index 1388994d7f..6e8ddba455 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -70,10 +59,10 @@ local mock_standard = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) - test.mock_device.add_test_device(mock_standard) - zigbee_test_utils.init_noop_health_check_timer() -end + mock_standard:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_standard)end test.set_test_init_function(test_init) @@ -87,7 +76,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -126,7 +118,10 @@ test.register_coroutine_test( SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -142,7 +137,10 @@ test.register_coroutine_test( AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -156,7 +154,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -170,7 +171,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -178,9 +182,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -188,9 +196,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -209,7 +221,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -241,7 +256,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -253,7 +271,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -265,7 +286,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, MAX_POWER_ATTRIBUTE_ID, MFG_CODE, data_types.SinglePrecisionFloat, SinglePrecisionFloat(0, 6, 0.5625)) }) - end + end, + { + min_api_version = 17 + } ) -- with standard cluster @@ -291,7 +315,10 @@ test.register_coroutine_test( ElectricalMeasurement.attributes.ActivePower:read(mock_standard) }) test.socket.zigbee:__expect_send({ mock_standard.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_standard) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -314,7 +341,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_standard:generate_test_message("main", capabilities.powerMeter.power({ value = 10.0, unit = "W" })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -336,7 +366,10 @@ test.register_coroutine_test( mock_standard:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 10 })) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua index 67464099cd..9476f41345 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -71,10 +60,10 @@ local mock_standard = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) - test.mock_device.add_test_device(mock_standard) - zigbee_test_utils.init_noop_health_check_timer() -end + mock_standard:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_standard)end test.set_test_init_function(test_init) @@ -88,7 +77,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -127,7 +119,10 @@ test.register_coroutine_test( SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -143,7 +138,10 @@ test.register_coroutine_test( AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -157,7 +155,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -171,7 +172,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -179,9 +183,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -189,9 +197,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -210,7 +222,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -242,7 +257,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -254,7 +272,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -266,7 +287,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, MAX_POWER_ATTRIBUTE_ID, MFG_CODE, data_types.SinglePrecisionFloat, SinglePrecisionFloat(0, 6, 0.5625)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -278,7 +302,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_TURN_OFF_INDICATOR_LIGHT_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) -- with standard cluster @@ -304,7 +331,10 @@ test.register_coroutine_test( ElectricalMeasurement.attributes.ActivePower:read(mock_standard) }) test.socket.zigbee:__expect_send({ mock_standard.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_standard) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -327,7 +357,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_standard:generate_test_message("main", capabilities.powerMeter.power({ value = 10.0, unit = "W" })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -349,7 +382,10 @@ test.register_coroutine_test( mock_standard:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 10 })) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua index d891030fd3..4779073041 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -51,9 +40,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -67,7 +54,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -81,7 +71,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -95,7 +88,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -103,9 +99,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -113,9 +113,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -134,7 +138,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -158,7 +165,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -170,7 +180,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -182,7 +195,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua new file mode 100644 index 0000000000..8d3135b463 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua @@ -0,0 +1,153 @@ +-- Copyright 2025 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 zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local OnOff = clusters.OnOff + +local PRIVATE_CLUSTER_ID = 0xFCC0 +local MFG_CODE = 0x115F +local RESTORE_POWER_STATE_ATTRIBUTE_ID = 0x0201 +local ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID = 0x000A + + +local PRIVATE_MODE = "PRIVATE_MODE" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-switch-module-no-power.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.switch.l0agl1", + server_clusters = { 0x0006 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Lifecycle - added test", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh device", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) +end, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test( + "Reported on status should be handled", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x01) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reported off status should be handled", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x01) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle restorePowerState in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.restorePowerState"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle electricSwitchType in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.electricSwitchType"] = 'rocker' } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua new file mode 100644 index 0000000000..4e7ffaa38a --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua @@ -0,0 +1,345 @@ +-- Copyright 2025 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 zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local OnOff = clusters.OnOff + +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local MFG_CODE = 0x115F +local RESTORE_POWER_STATE_ATTRIBUTE_ID = 0x0201 +local CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID = 0x0200 +local WIRELESS_SWITCH_CLUSTER_ID = 0x0012 +local WIRELESS_SWITCH_ATTRIBUTE_ID = 0x0055 +local WIRELESS_SWITCH_PUSHED_VALUE = 1 +local BUTTON_1_ENDPOINT = 0x29 +local BUTTON_2_ENDPOINT = 0x2A + +local PRIVATE_MODE = "PRIVATE_MODE" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + label = "Aqara Smart Wall Switch H1 EU (No Neutral, Double Rocker) 1", + profile = t_utils.get_profile_definition("aqara-switch-no-power.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.switch.l2aeu1", + server_clusters = { 0x0006 } + } + } + } +) + +local mock_base_device = test.mock_device.build_test_zigbee_device( + { + label = "Aqara Smart Wall Switch H1 EU (No Neutral, Double Rocker) 1", + profile = t_utils.get_profile_definition("aqara-switch-no-power.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.switch.l2aeu1", + server_clusters = { 0x0006 } + } + } + } +) + +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("aqara-switch-child.yml"), + device_network_id = string.format("%04X:%02X", mock_device:get_short_address(), 2), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_base_device) + test.mock_device.add_test_device(mock_child) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Lifecycle - added test", + function() + -- The initial switch event should be send during the device's first time onboarding + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, + data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + -- Avoid sending the initial switch event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, + data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Lifecycle - added test", + function() + -- The initial switch event should be send during the device's first time onboarding + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.numberOfButtons({ value = 1 }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + -- Avoid sending the initial switch event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.numberOfButtons({ value = 1 }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Lifecycle - added test", + function() + -- The initial switch event should be send during the device's first time onboarding + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_base_device.id, "added" }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Aqara Smart Wall Switch H1 EU (No Neutral, Double Rocker) 2", + profile = "aqara-switch-child", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "02" + }) + test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_base_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_base_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, + data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh device", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reported on status should be handled : parent device", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x01) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reported on status should be handled : child device", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) }) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.on())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reported off status should be handled by parent device", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x01) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reported off status should be handled by child device", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x02) }) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability on command should be handled : parent device", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability on command should be handled : child device", + function() + test.socket.capability:__queue_receive({ mock_child.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "on") + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability off command should be handled : parent device", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability off command should be handled : child device", + function() + test.socket.capability:__queue_receive({ mock_child.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "off") + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Wireless button pushed report should be correctly handled : parent device", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, WIRELESS_SWITCH_CLUSTER_ID, { + { WIRELESS_SWITCH_ATTRIBUTE_ID, data_types.Uint16.ID, WIRELESS_SWITCH_PUSHED_VALUE } + }, MFG_CODE):from_endpoint(BUTTON_1_ENDPOINT) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Wireless button pushed report should be correctly handled : child device", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, WIRELESS_SWITCH_CLUSTER_ID, { + { WIRELESS_SWITCH_ATTRIBUTE_ID, data_types.Uint16.ID, WIRELESS_SWITCH_PUSHED_VALUE } + }, MFG_CODE):from_endpoint(BUTTON_2_ENDPOINT) + }) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 17 + } +) + + + +test.register_coroutine_test( + "Handle restorePowerState in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.restorePowerState"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle changeToWirelessSwitch in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.changeToWirelessSwitch"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua index d206ac8c29..b6eb4a320e 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -95,7 +84,10 @@ test.register_coroutine_test( { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -108,7 +100,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,7 +119,10 @@ test.register_coroutine_test( AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -138,7 +136,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -152,7 +153,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +170,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -180,7 +187,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -188,9 +198,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -198,9 +212,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_child.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -208,9 +226,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -218,9 +240,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_child.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -234,7 +260,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -248,7 +277,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -267,7 +299,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -291,7 +326,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -303,7 +341,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -315,7 +356,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua index 0f029d95bf..a0a7e9b924 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -68,7 +57,11 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + mock_device:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -77,7 +70,11 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.on())) - end + mock_device:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -86,7 +83,11 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) - end + mock_device:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -95,7 +96,11 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x02) }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.off())) - end + mock_device:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -103,9 +108,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -113,9 +122,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_child.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -123,9 +136,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -133,9 +150,13 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_child.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -149,7 +170,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -163,7 +187,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -175,7 +202,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua index 910b36a87f..3adf489318 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -35,6 +24,7 @@ local mock_device = test.mock_device.build_test_zigbee_device({ }) local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) end @@ -47,8 +37,13 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -59,11 +54,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.commands.On(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -75,11 +81,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.commands.Off(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -104,7 +121,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 60.0, unit = "W" })) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua b/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua index 69ee7f043a..36ac4d5fa0 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua @@ -1,17 +1,5 @@ - --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -53,6 +41,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua b/drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua new file mode 100644 index 0000000000..189f64715a --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua @@ -0,0 +1,52 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local dkjson = require 'dkjson' + +-- This test attempts to add a zwave device to this zigbee switch driver +-- Once the monkey-patch is removed with hubcore 59 is released with: +-- https://smartthings.atlassian.net/browse/CHAD-16552 +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("on-off-level.yml"), +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +-- Just validating that the driver doesn't crash is enough to validate +-- that the work-around is effective in ignoring the incorrect device kind +test.register_coroutine_test("zwave_device_handled", function() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", dkjson.encode(mock_device.raw_st_data) }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability command for incorrect protocol", + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua index e971201f85..66af249b0c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -75,7 +64,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -83,7 +75,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -93,7 +88,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -104,7 +102,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua index 0dee55ebf4..d1bebb68d5 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -49,6 +38,9 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) @@ -80,12 +72,135 @@ test.register_coroutine_test( ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 20 + } ) test.register_message_test( @@ -122,7 +237,9 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17, + max_api_version = 19 } ) @@ -150,7 +267,10 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -169,7 +289,10 @@ test.register_coroutine_test( command } ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua index 6998b03d85..f28bb56e37 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua @@ -1,24 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurementCluster = clusters.ElectricalMeasurement local OnOffCluster = clusters.OnOff local LevelCluster = clusters.Level local SimpleMeteringCluster = clusters.SimpleMetering local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("switch-dimmer-power-energy.yml"), @@ -27,18 +18,122 @@ local mock_device = test.mock_device.build_test_zigbee_device({ id = 1, manufacturer = "Jasco Products", model = "43082", - server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0008, 0x0702, 0x0B05 }, + server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0008, 0x0702, 0x0B04 }, client_clusters = { 0x000A, 0x0019 } } } }) +zigbee_test_utils.prepare_zigbee_env_info() + local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) end test.set_test_init_function(test_init) +test.register_coroutine_test( + "lifecycle configure event should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurementCluster.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOffCluster.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + LevelCluster.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMeteringCluster.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurementCluster.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurementCluster.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurementCluster.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + LevelCluster.attributes.CurrentLevel:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMeteringCluster.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMeteringCluster.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMeteringCluster.attributes.Multiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMeteringCluster.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurementCluster.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurementCluster.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurementCluster.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + LevelCluster.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMeteringCluster.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMeteringCluster.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + test.register_message_test( "Capability command On should be handled", { @@ -47,11 +142,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOffCluster.server.commands.On(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -63,11 +169,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOffCluster.server.commands.Off(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -79,6 +196,14 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57, 0 } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_cmd_id = "setLevel" } + } + }, { channel = "zigbee", direction = "send", @@ -87,6 +212,9 @@ test.register_message_test( LevelCluster.server.commands.MoveToLevelWithOnOff(mock_device, math.floor(57 * 254 / 100)) } } + }, + { + min_api_version = 17 } ) @@ -105,7 +233,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(57)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -122,6 +261,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 33.3, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -138,6 +280,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.5555, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_firstled_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_light.lua new file mode 100644 index 0000000000..05a04a06ad --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_light.lua @@ -0,0 +1,106 @@ +-- 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 cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local PRIVATE_CLUSTER_ID = 0xFC00 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("light-color-temp-time-restore.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "FIRSTLED", + model = "DC2DC12MiV1", + server_clusters = { 0x0006, 0x0008, 0x0300 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +-- ====================== Preferences ====================== +test.register_coroutine_test("infoChanged - outputMode 0", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { outputMode = "0" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0000), + data_types.validate_or_build_type(0, data_types.Uint8, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnMode 0", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { powerOnMode = "0" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0001), + data_types.validate_or_build_type(0, data_types.Uint8, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - ledDriveCurrent 100", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { ledDriveCurrent = "100" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0002), + data_types.validate_or_build_type(100, data_types.Uint16, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - dimTransitionTime 2000", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { dimTransitionTime = "2000" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0003), + data_types.validate_or_build_type(2000, data_types.Uint16, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - colorTempTransitionTime 2000", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { colorTempTransitionTime = "2000" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0004), + data_types.validate_or_build_type(2000, data_types.Uint16, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.run_registered_tests() 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..a9bc2ac8fe --- /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 = 20 + } +) + +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 = 20 + } +) + +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 = 20 + } +) + +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 = 20 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 1", 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 = 20 + } +) + +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 = 20 + } +) + +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 = 20 + } +) + +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 = 20 + } +) + +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 = 20 + } + ) +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 = 20 + } + ) +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/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua new file mode 100644 index 0000000000..b410872075 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -0,0 +1,698 @@ +-- Copyright 2025 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local messages = require "st.zigbee.messages" +local constants = require "st.zigbee.constants" +local zdo_messages = require "st.zigbee.zdo" +local bind_request = require "st.zigbee.zdo.bind_request" +local unbind_request = require "frient-IO.unbind_request" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local zcl_messages = require "st.zigbee.zcl" +local Status = require "st.zigbee.generated.types.ZclStatus" +local device_management = require "st.zigbee.device_management" +local configuration_map = require "configurations" +local switch_defaults = require "st.zigbee.defaults.switch_defaults" +local mock_devices_api = require "integration_test.mock_devices_api" + +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +local Switch = capabilities.switch + +local ZIGBEE_ENDPOINTS = { + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75, +} + +local INPUT_CONFIGS = { + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_1, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_2, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_3, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_4, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, +} + +local DEVELCO_MFG_CODE = 0x1015 +local ON_TIME_ATTR = 0x8000 +local OFF_WAIT_ATTR = 0x8001 + +local function sanitize_timing(value) + local v = tonumber(value) or 0 + if v < 0 then + v = 0 + elseif v > 0xFFFF then + v = 0xFFFF + end + return math.tointeger(v) or 0 +end + +local function to_deciseconds(value) + return math.floor(sanitize_timing(value) * 10) +end + +local function build_client_mfg_write(device, endpoint, attr_id, value) + local msg = cluster_base.write_manufacturer_specific_attribute( + device, + BasicInput.ID, + attr_id, + DEVELCO_MFG_CODE, + data_types.Uint16, + value + ) + msg.body.zcl_header.frame_ctrl:set_direction_client() + msg.tx_options = data_types.Uint16(0) + return msg:to_endpoint(endpoint) +end + +local function build_basic_input_polarity_write(device, endpoint, enabled) + local polarity_value = data_types.validate_or_build_type( + enabled and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload" + ) + local msg = cluster_base.write_attribute( + device, + data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + polarity_value + ) + msg.tx_options = data_types.Uint16(0) + return msg:to_endpoint(endpoint) +end + +local function build_bind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + bind_request.BindRequest.ID + ) + local bind_body = bind_request.BindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + bind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = bind_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg +end + +local function build_unbind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + unbind_request.UNBIND_REQUEST_CLUSTER_ID + ) + local unbind_body = unbind_request.UnbindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + unbind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = unbind_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg +end + +local function build_default_response_msg(device, endpoint, command_id) + local addr_header = messages.AddressHeader( + device:get_short_address(), + endpoint, + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + constants.HA_PROFILE_ID, + OnOff.ID + ) + local response_body = default_response.DefaultResponse(command_id, Status.SUCCESS) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = response_body + }) + return messages.ZigbeeMessageRx({ address_header = addr_header, body = message_body }) +end + +local function build_output_timing(device, child, suffix) + local on_pref + local off_pref + if child.preferences.configOnTime ~= nil or child.preferences.configOffWaitTime ~= nil then + on_pref = child.preferences.configOnTime or 0 + off_pref = child.preferences.configOffWaitTime or 0 + else + on_pref = device.preferences["configOnTime" .. suffix] or 0 + off_pref = device.preferences["configOffWaitTime" .. suffix] or 0 + end + return to_deciseconds(on_pref), to_deciseconds(off_pref) +end + +local function copy_table(source) + local result = {} + for key, value in pairs(source) do + result[key] = value + end + return result +end + +local parent_preference_state = {} + +local mock_parent_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("switch-4inputs-2outputs.yml"), + fingerprinted_endpoint_id = ZIGBEE_ENDPOINTS.INPUT_1, + label = "frient IO Module", + zigbee_endpoints = { + [ZIGBEE_ENDPOINTS.INPUT_1] = { + id = ZIGBEE_ENDPOINTS.INPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_2] = { + id = ZIGBEE_ENDPOINTS.INPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_3] = { + id = ZIGBEE_ENDPOINTS.INPUT_3, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_4] = { + id = ZIGBEE_ENDPOINTS.INPUT_4, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_1] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_2] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + }, +}) + + function mock_parent_device:get_model() + return "IOMZB-110" + end + + function mock_parent_device:get_manufacturer() + return "frient A/S" + end + + function mock_parent_device:supports_server_cluster(cluster_id, endpoint_id) + local function endpoint_supports(ep) + if not ep or not ep.server_clusters then return false end + for _, server_cluster in ipairs(ep.server_clusters) do + if server_cluster == cluster_id then + return true + end + end + return false + end + + if endpoint_id ~= nil then + return endpoint_supports(self.zigbee_endpoints[endpoint_id]) + end + + for _, endpoint in pairs(self.zigbee_endpoints) do + if endpoint_supports(endpoint) then + return true + end + end + return false + end + +local mock_output_child_1 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-1", + label = "frient IO Module Output 1", + vendor_provided_label = "Output 1", +}) + +local mock_output_child_2 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-2", + label = "frient IO Module Output 2", + vendor_provided_label = "Output 2", +}) + +local function reset_preferences() + mock_parent_device.preferences.reversePolarity1 = false + mock_parent_device.preferences.reversePolarity2 = false + mock_parent_device.preferences.reversePolarity3 = false + mock_parent_device.preferences.reversePolarity4 = false + + mock_parent_device.preferences.controlOutput11 = false + mock_parent_device.preferences.controlOutput21 = false + mock_parent_device.preferences.controlOutput12 = false + mock_parent_device.preferences.controlOutput22 = false + mock_parent_device.preferences.controlOutput13 = false + mock_parent_device.preferences.controlOutput23 = false + mock_parent_device.preferences.controlOutput14 = false + mock_parent_device.preferences.controlOutput24 = false + + mock_parent_device.preferences.configOnTime1 = 3 + mock_parent_device.preferences.configOffWaitTime1 = 4 + mock_parent_device.preferences.configOnTime2 = 7 + mock_parent_device.preferences.configOffWaitTime2 = 8 + + mock_output_child_1.preferences.configOnTime = 5 + mock_output_child_1.preferences.configOffWaitTime = 6 + mock_output_child_2.preferences.configOnTime = 0 + mock_output_child_2.preferences.configOffWaitTime = 0 + + parent_preference_state = copy_table(mock_parent_device.preferences) + + local field_keys = { + "frient_io_native_70", + "frient_io_native_71", + "frient_io_native_72", + "frient_io_native_73", + "frient_io_native_74", + "frient_io_native_75", + } + + for _, key in ipairs(field_keys) do + mock_parent_device:set_field(key, nil, { persist = true }) + end + + mock_output_child_1:set_field("frient_io_native_74", nil, { persist = true }) + mock_output_child_2:set_field("frient_io_native_75", nil, { persist = true }) +end + +local function queue_child_info_changed(child, preferences) + local raw = rawget(child, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(preferences) do + raw.preferences[key] = value + end + end + test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ preferences = preferences })) +end + +local function queue_parent_info_changed(preferences) + local full_preferences = copy_table(parent_preference_state) + for key, value in pairs(preferences) do + full_preferences[key] = value + end + parent_preference_state = copy_table(full_preferences) + + local raw = rawget(mock_parent_device, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(full_preferences) do + raw.preferences[key] = value + end + end + + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ preferences = full_preferences }) + ) +end + +local function register_initial_config_expectations() + if test.socket.zigbee and test.socket.zigbee.__set_channel_ordering then + test.socket.zigbee:__set_channel_ordering("relaxed") + end + if test.socket.devices and test.socket.devices.__set_channel_ordering then + test.socket.devices:__set_channel_ordering("relaxed") + end + + local function register_device_configure_expectations() + local configuration = configuration_map.get_device_configuration(mock_parent_device) or {} + local configs_by_cluster = {} + local function add_attribute_config(attribute) + if attribute.configurable ~= false then + configs_by_cluster[attribute.cluster] = configs_by_cluster[attribute.cluster] or {} + table.insert(configs_by_cluster[attribute.cluster], attribute) + end + end + + for _, attribute in ipairs(configuration) do + add_attribute_config(attribute) + end + + local default_configs = switch_defaults.attribute_configurations or {} + for _, attribute in ipairs(default_configs) do + add_attribute_config(attribute) + end + + local cluster_ids = {} + for cluster_id in pairs(configs_by_cluster) do + cluster_ids[#cluster_ids + 1] = cluster_id + end + table.sort(cluster_ids) + + local endpoint_ids = {} + for endpoint_id in pairs(mock_parent_device.zigbee_endpoints) do + endpoint_ids[#endpoint_ids + 1] = endpoint_id + end + table.sort(endpoint_ids) + + for _, cluster_id in ipairs(cluster_ids) do + local attr_configs = configs_by_cluster[cluster_id] + table.sort(attr_configs, function(a, b) + return a.attribute < b.attribute + end) + for _, endpoint_id in ipairs(endpoint_ids) do + local endpoint = mock_parent_device.zigbee_endpoints[endpoint_id] + if endpoint and mock_parent_device:supports_server_cluster(cluster_id, endpoint.id) then + local bind_cmd = device_management.build_bind_request( + mock_parent_device, + cluster_id, + zigbee_test_utils.mock_hub_eui, + endpoint.id + ):to_endpoint(endpoint.id) + bind_cmd.tx_options = data_types.Uint16(0) + test.socket.zigbee:__expect_send({ mock_parent_device.id, bind_cmd }) + for _, attr_config in ipairs(attr_configs) do + local config_cmd = device_management.attr_config(mock_parent_device, attr_config):to_endpoint(endpoint.id) + config_cmd.tx_options = data_types.Uint16(0) + test.socket.zigbee:__expect_send({ mock_parent_device.id, config_cmd }) + end + end + end + end + end + + register_device_configure_expectations() + + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local on2, off2 = build_output_timing(mock_parent_device, mock_output_child_2, "2") + + local function enqueue_output_timing_writes() + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, on1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, off1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, ON_TIME_ATTR, on2) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, OFF_WAIT_ATTR, off2) }) + end + + -- Device init issues one set of manufacturer-specific writes per output during startup + enqueue_output_timing_writes() + + for _, config in ipairs(INPUT_CONFIGS) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, config.endpoint, false) }) + for _, output_ep in ipairs(config.binds) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, config.endpoint, output_ep) }) + end + end +end + +local function expect_init_sequence() + mock_devices_api.__expect_update_device( + mock_parent_device.id, + { deviceId = mock_parent_device.id, provisioningState = "PROVISIONED" } + ) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "doConfigure" }) +end + +local function expect_switch_registration(device) + test.socket.devices:__expect_send({ + "register_native_capability_attr_handler", + { device_uuid = device.id, capability_id = "switch", capability_attr_id = "switch" }, + }) +end + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + reset_preferences() + register_initial_config_expectations() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_output_child_1) + test.mock_device.add_test_device(mock_output_child_2) + zigbee_test_utils.init_noop_health_check_timer() + --register_initial_config_expectations() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Init configures outputs and routes attribute reports", + function() + expect_init_sequence() + test.wait_for_events() + + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1), + }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + expect_switch_registration(mock_output_child_1) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, false):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2), + }) + test.socket.capability:__expect_send(mock_output_child_2:generate_test_message("main", Switch.switch.off())) + expect_switch_registration(mock_output_child_2) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + BasicInput.attributes.PresentValue:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.INPUT_3), + }) + test.socket.capability:__expect_send(mock_parent_device:generate_test_message("input3", Switch.switch.on())) + + test.wait_for_events() + + local child1_native = mock_output_child_1:get_field("frient_io_native_74") + assert(child1_native, "expected Output 1 child to register native switch handler") + local child2_native = mock_output_child_2:get_field("frient_io_native_75") + assert(child2_native, "expected Output 2 child to register native switch handler") + local parent_native = mock_parent_device:get_field("frient_io_native_72") + assert(parent_native, "expected parent device to register native switch handler for input 3") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Default responses update state and trigger reads", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + + local on_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.On.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, on_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + + local timed_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.OnWithTimedOff.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, timed_response }) + local read_msg = cluster_base.read_attribute( + mock_parent_device, + data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID) + ) + read_msg.tx_options = data_types.Uint16(0) + read_msg = read_msg:to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, read_msg }) + + local off_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.Off.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, off_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.off())) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Switch commands drive the correct Zigbee commands", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local timed_on = OnOff.server.commands.OnWithTimedOff( + mock_parent_device, + data_types.Uint8(0), + data_types.Uint16(on1), + data_types.Uint16(off1) + ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local direct_off_output1 = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off_output1 }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local direct_on = OnOff.server.commands.On(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local direct_off = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output1", command = "on", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output2", command = "off", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Child preference changes send manufacturer writes", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + queue_child_info_changed(mock_output_child_1, { configOnTime = 12, configOffWaitTime = 13 }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, to_deciseconds(12)), + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, to_deciseconds(13)), + }) + + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Parent preference changes manage polarity and binds", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = true, + controlOutput21 = true, + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() + + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = false, + controlOutput21 = true, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + test.wait_for_events() + + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = true, + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() + + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = false, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua new file mode 100644 index 0000000000..2a86e1ed19 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua @@ -0,0 +1,364 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local constants = require "st.zigbee.constants" + +local OnOff = clusters.OnOff +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local Alarms = clusters.Alarms + +local AlarmCmd = require "st.zigbee.generated.zcl_clusters.Alarms.client.commands.Alarm" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local test = require "integration_test" +local base64 = require "base64" + +local POWER_FAILURE_ALARM_CODE = 0x03 + + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("frient-switch-power-energy-voltage.yml"), + zigbee_endpoints = { + [0x02] = { + id = 0x02, + manufacturer = "frient A/S", + model = "SPLZB-132", + server_clusters = { SimpleMetering.ID, ElectricalMeasurement.ID, Alarms.ID, OnOff.ID }, + client_clusters = {} + }, + }} +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains())) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Voltage divisor, multiplier, and summation should be handled ", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + ElectricalMeasurement.attributes.ACVoltageMultiplier:build_test_read_attr_response(mock_device, 10) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + ElectricalMeasurement.attributes.ACVoltageDivisor:build_test_read_attr_response(mock_device, 1) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, 20) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.voltageMeasurement.voltage({ value = 200.0, unit = "V" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test("Current divisor, multiplier, summation should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + ElectricalMeasurement.attributes.ACCurrentMultiplier:build_test_read_attr_response(mock_device, 10) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + ElectricalMeasurement.attributes.ACCurrentDivisor:build_test_read_attr_response(mock_device, 1) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, 20) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.currentMeasurement.current({ value = 200.0, unit = "A" })) + }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test("Refresh command should read all necessary attributes", function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "refresh", + component = "main", + command = "refresh", + args = {} + } + }) + test.socket.zigbee:__expect_send( + {mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device)} + ) + test.socket.zigbee:__expect_send( + {mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device)} + ) + test.socket.zigbee:__expect_send( + {mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) } + ) + test.socket.zigbee:__expect_send( + {mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) } + ) + test.socket.zigbee:__expect_send( + {mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) } + ) + test.socket.zigbee:__expect_send( + {mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + ) +end, +{ + min_api_version = 17 +} +) + +test.register_message_test( + "Handle switch ON report", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle switch OFF report", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:build_test_attr_report(mock_device, 0x0A) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, 27) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2.7, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "CurrentSummationDelivered Report should be handled.", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 0x3E8) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 27) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.027, unit = "kWh" })) + + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "device_init should set fields and emmit powerSource if supported", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + assert(mock_device:get_field(constants.SIMPLE_METERING_DIVISOR_KEY) == 1000) + assert(mock_device:get_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY) == 1000) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test("doConfigure should send bind request, read attributes and configure reporting", function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({mock_device.id, zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID, + 0x02 + )}) + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 0x0001, 0xA8C0,0x0001)}) + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 0x0001, 0xA8C0,0x0001)}) + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 0x0005, 0x0E10,0x0001)}) + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 0x0005, 0x0E10,0x0001)}) + test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 0x0005, 0x0E10,0x0001)}) + + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACVoltageMultiplier:read(mock_device )}) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACVoltageDivisor:read(mock_device )}) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:read(mock_device )}) + + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACCurrentMultiplier:read(mock_device )}) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACCurrentDivisor:read(mock_device )}) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:read(mock_device )}) + + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device )}) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device )}) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device )}) + + test.socket.zigbee:__expect_send({mock_device.id, zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID, + 0x02 + )}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 0x0005, 0x0E10,1)}) + test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 0x0005, 0x0E10,1)}) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device )}) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device )}) + + test.socket.zigbee:__expect_send({mock_device.id, zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID, + 0x02 + )}) + test.socket.zigbee:__expect_send({mock_device.id, OnOff.attributes.OnOff:configure_reporting(mock_device, 0x0000, 0x012C)}) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device )}) + + test.socket.zigbee:__expect_send({mock_device.id , zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + Alarms.ID, + 0x02 + )}) + test.socket.zigbee:__expect_send({mock_device.id, Alarms.attributes.AlarmCount:configure_reporting(mock_device, 1, 0x0E10, 1)}) + test.socket.zigbee:__expect_send({mock_device.id, Alarms.attributes.AlarmCount:read(mock_device)}) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test( + "Alarm report should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + test.socket.zigbee:__queue_receive({ mock_device.id, AlarmCmd.build_test_rx(mock_device,POWER_FAILURE_ALARM_CODE,SimpleMetering.ID)}) + + test.mock_time.advance_time(2) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown())) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua index cdcd49c616..d35bbf4f35 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,7 +45,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -70,7 +62,10 @@ test.register_coroutine_test( } test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 50) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -82,7 +77,10 @@ test.register_coroutine_test( } } test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -112,7 +110,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -146,7 +147,50 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle infoChanged when dimOnOff changes from 1 to 0 should write transition time 0", + function() + -- First: change dimOnOff from default 0 to 1 (triggers write with dimRate=20) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 1 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 20) }) + test.wait_for_events() + -- Now: change dimOnOff from 1 to 0 (driver old=1, new=0 -> writes 0) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 0 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 0) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle infoChanged when dimRate changes while dimOnOff is 1 should write new dimRate", + function() + -- First: change dimOnOff from default 0 to 1 (triggers write with dimRate=20) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 1 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 20) }) + test.wait_for_events() + -- Now: change dimRate while dimOnOff stays 1 (driver old={dimOnOff=1,dimRate=20}, new={dimOnOff=1,dimRate=50}) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 1, dimRate = 50 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 50) }) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua index 6018e57b62..7982403b8c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -106,9 +95,7 @@ local function test_init() test.mock_device.add_test_device(mock_second_child) test.mock_device.add_test_device(mock_third_child) test.mock_device.add_test_device(mock_fourth_child) - test.mock_device.add_test_device(mock_fifth_child) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_fifth_child)end test.set_test_init_function(test_init) @@ -130,7 +117,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -152,7 +150,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_first_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -174,7 +183,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_second_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -196,7 +216,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_third_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -218,7 +249,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fourth_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fourth_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -240,7 +282,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fifth_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fifth_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -262,7 +315,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -284,7 +348,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_first_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -306,7 +381,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_second_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -328,7 +414,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_third_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -350,7 +447,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fourth_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fourth_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -372,7 +480,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fifth_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fifth_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -389,11 +508,22 @@ test.register_message_test( direction = "receive", message = { mock_parent_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 17 } ) @@ -405,11 +535,22 @@ test.register_message_test( direction = "receive", message = { mock_first_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) @@ -421,11 +562,22 @@ test.register_message_test( direction = "receive", message = { mock_second_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 17 } ) @@ -437,11 +589,22 @@ test.register_message_test( direction = "receive", message = { mock_third_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 17 } ) @@ -453,11 +616,22 @@ test.register_message_test( direction = "receive", message = { mock_fourth_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_fourth_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x05) } } + }, + { + min_api_version = 17 } ) @@ -469,11 +643,22 @@ test.register_message_test( direction = "receive", message = { mock_fifth_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_fifth_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x06) } } + }, + { + min_api_version = 17 } ) @@ -485,11 +670,22 @@ test.register_message_test( direction = "receive", message = { mock_parent_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 17 } ) @@ -501,11 +697,22 @@ test.register_message_test( direction = "receive", message = { mock_first_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) @@ -517,11 +724,22 @@ test.register_message_test( direction = "receive", message = { mock_second_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 17 } ) @@ -533,11 +751,22 @@ test.register_message_test( direction = "receive", message = { mock_third_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 17 } ) @@ -549,11 +778,22 @@ test.register_message_test( direction = "receive", message = { mock_fourth_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_fourth_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x05) } } + }, + { + min_api_version = 17 } ) @@ -565,11 +805,22 @@ test.register_message_test( direction = "receive", message = { mock_fifth_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_fifth_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x06) } } + }, + { + min_api_version = 17 } ) @@ -617,7 +868,11 @@ test.register_coroutine_test( mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(0x01) }) - end + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua new file mode 100644 index 0000000000..b2b7019dd0 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua @@ -0,0 +1,563 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" +local OTAUpgrade = require("st.zigbee.zcl.clusters").OTAUpgrade +local device_management = require "st.zigbee.device_management" +local zigbee_constants = require "st.zigbee.constants" + +local OnOff = clusters.OnOff +local Level = clusters.Level +local TemperatureMeasurement = clusters.TemperatureMeasurement +local RelativeHumidity = clusters.RelativeHumidity + +-- Inovelli VZM30-SN device identifiers +local INOVELLI_MANUFACTURER_ID = "Inovelli" +local INOVELLI_VZM30_SN_MODEL = "VZM30-SN" + +-- Device endpoints with supported clusters +local inovelli_vzm30_sn_endpoints = { + [1] = { + id = 1, + manufacturer = INOVELLI_MANUFACTURER_ID, + model = INOVELLI_VZM30_SN_MODEL, + server_clusters = {0x0006, 0x0008, 0x0300, 0x0402, 0x0405} -- OnOff, Level, ColorControl, TemperatureMeasurement, RelativeHumidity + } +} + +local mock_inovelli_vzm30_sn = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm30-sn.yml"), + zigbee_endpoints = inovelli_vzm30_sn_endpoints, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzm30_sn) +end +test.set_test_init_function(test_init) + +local supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} + } + +-- Test device initialization +test.register_message_test( + "Device should initialize properly on added lifecycle event", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_inovelli_vzm30_sn.id, "added" }, + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + Level.attributes.CurrentLevel:read(mock_inovelli_vzm30_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + OnOff.attributes.OnOff:read(mock_inovelli_vzm30_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_inovelli_vzm30_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + RelativeHumidity.attributes.MeasuredValue:read(mock_inovelli_vzm30_sn) + } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test refresh capability +test.register_message_test( + "Refresh capability should send read commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm30_sn.id, + { capability = "refresh", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm30_sn.id, OnOff.attributes.OnOff:read(mock_inovelli_vzm30_sn) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm30_sn.id, Level.attributes.CurrentLevel:read(mock_inovelli_vzm30_sn) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm30_sn.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_inovelli_vzm30_sn) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm30_sn.id, RelativeHumidity.attributes.MeasuredValue:read(mock_inovelli_vzm30_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch on command +test.register_message_test( + "Switch on command should send OnOff On command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm30_sn.id, + { capability = "switch", command = "on", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm30_sn.id, clusters.OnOff.server.commands.On(mock_inovelli_vzm30_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch off command +test.register_message_test( + "Switch off command should send OnOff Off command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm30_sn.id, + { capability = "switch", command = "off", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm30_sn.id, clusters.OnOff.server.commands.Off(mock_inovelli_vzm30_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch level command +test.register_message_test( + "Switch level command should send Level MoveToLevelWithOnOff command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm30_sn.id, + { capability = "switchLevel", command = "setLevel", args = { 50 } } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + clusters.Level.server.commands.MoveToLevelWithOnOff(mock_inovelli_vzm30_sn, math.floor(50/100.0 * 254), 0xFFFF) + } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Build test message for Inovelli private cluster button press +local function build_inovelli_button_message(device, button_number, key_attribute) + local messages = require "st.zigbee.messages" + local zcl_messages = require "st.zigbee.zcl" + local zb_const = require "st.zigbee.constants" + local data_types = require "st.zigbee.data_types" + local frameCtrl = require "st.zigbee.zcl.frame_ctrl" + + -- Combine button_number and key_attribute into a single value + -- button_number in lower byte, key_attribute in upper byte + local combined_value = (key_attribute * 256) + button_number + + -- Create the command body using serialize_int + local command_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ + frame_ctrl = frameCtrl(0x15), -- Manufacturer specific, client to server + mfg_code = data_types.Uint16(0x122F), -- Inovelli manufacturer code + seqno = data_types.Uint8(0x6D), + cmd = data_types.ZCLCommandId(0x00) -- Scene command + }), + zcl_body = data_types.Uint16(combined_value) + }) + + local addrh = messages.AddressHeader( + device:get_short_address(), + 0x02, -- src_endpoint from real device log + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + 0xFC31 -- PRIVATE_CLUSTER_ID + ) + + return messages.ZigbeeMessageRx({ + address_header = addrh, + body = command_body + }) +end + +-- Test button1 pushed +test.register_message_test( + "Button1 pushed should emit button event and update supportedButtonValues", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_inovelli_vzm30_sn.id, build_inovelli_button_message(mock_inovelli_vzm30_sn, 0x01, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm30_sn:generate_test_message( + "button1", + capabilities.button.supportedButtonValues( + supported_button_values["button1"], + { visibility = { displayed = false } } + ) + ) + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm30_sn:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test button2 pressed 4 times +test.register_message_test( + "Button2 pressed 4 times should emit button event and update supportedButtonValues", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_inovelli_vzm30_sn.id, build_inovelli_button_message(mock_inovelli_vzm30_sn, 0x02, 0x05) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm30_sn:generate_test_message( + "button2", + capabilities.button.supportedButtonValues( + supported_button_values["button2"], + { visibility = { displayed = false } } + ) + ) + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm30_sn:generate_test_message("button2", capabilities.button.button.pushed_4x({ state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test consecutive button events - supportedButtonValues should only be sent on first event +test.register_coroutine_test( + "Consecutive button events should only send supportedButtonValues on first event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + + -- First button event: button1 pushed - should send supportedButtonValues + button event + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm30_sn.id, + build_inovelli_button_message(mock_inovelli_vzm30_sn, 0x01, 0x00) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm30_sn:generate_test_message( + "button1", + capabilities.button.supportedButtonValues( + supported_button_values["button1"], + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzm30_sn:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + ) + + -- Second button event: button1 pushed_2x - should only send button event, NOT supportedButtonValues + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm30_sn.id, + build_inovelli_button_message(mock_inovelli_vzm30_sn, 0x01, 0x03) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm30_sn:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +-- Test temperature measurement +test.register_message_test( + "Temperature measurement should emit temperature events", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_inovelli_vzm30_sn.id, + TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_inovelli_vzm30_sn, 2500) + } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 25.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) + +-- Test humidity measurement +test.register_message_test( + "Humidity measurement should emit humidity events", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_inovelli_vzm30_sn.id, + RelativeHumidity.attributes.MeasuredValue:build_test_attr_report(mock_inovelli_vzm30_sn, 6500) + } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity(65)) + } + }, + { + min_api_version = 17 + } +) + +-- Test power meter from ElectricalMeasurement +test.register_coroutine_test( + "Power meter from ElectricalMeasurement should emit power events", + function() + -- Set the divisor field (default handlers use 10 if not set, but we can set it for consistency) + -- The default handler will use 10 if ELECTRICAL_MEASUREMENT_DIVISOR_KEY is not set + -- Since the test expects 2000 -> 200.0 W, that means divisor of 10 is being used + mock_inovelli_vzm30_sn:set_field(zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10, {persist = true}) + + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm30_sn.id, + clusters.ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_inovelli_vzm30_sn, 2000) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.powerMeter.power({value = 200.0, unit = "W"})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test energy meter +test.register_coroutine_test( + "Energy meter should emit energy events", + function() + -- Set the divisor field as the device does during configuration + -- For VZM30-SN, the divisor is set to 1000 (like VZM32-SN) + mock_inovelli_vzm30_sn:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm30_sn.id, + clusters.SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_inovelli_vzm30_sn, 212) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.energyMeter.energy({value = 0.212, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test energy meter reset command +test.register_message_test( + "Energy meter reset command should send reset commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm30_sn.id, + { capability = "energyMeter", command = "resetEnergyMeter", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + cluster_base.build_manufacturer_specific_command( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x02, -- PRIVATE_CMD_ENERGY_RESET_ID + 0x122F, -- MFG_CODE + utils.serialize_int(0, 1, false, false) + ) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + clusters.SimpleMetering.attributes.CurrentSummationDelivered:read(mock_inovelli_vzm30_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm30_sn.id, + clusters.ElectricalMeasurement.attributes.ActivePower:read(mock_inovelli_vzm30_sn) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "doConfigure runs base + VZM30 extras", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_inovelli_vzm30_sn.id, "doConfigure" }) + + -- Button capability messages from base_device_configure + for _, component in pairs(mock_inovelli_vzm30_sn.profile.components) do + if component.id ~= "main" then + test.socket.capability:__expect_send( + mock_inovelli_vzm30_sn:generate_test_message( + component.id, + capabilities.button.supportedButtonValues( + supported_button_values[component.id], + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzm30_sn:generate_test_message( + component.id, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + ) + end + end + + -- device:configure() sends bind requests and configure reporting (default handler) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, require("integration_test.zigbee_test_utils").build_bind_request(mock_inovelli_vzm30_sn, require("integration_test.zigbee_test_utils").mock_hub_eui, clusters.Level.ID) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, clusters.Level.attributes.CurrentLevel:configure_reporting(mock_inovelli_vzm30_sn, 1, 3600, 1) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, device_management.build_bind_request(mock_inovelli_vzm30_sn, RelativeHumidity.ID, require("integration_test.zigbee_test_utils").mock_hub_eui) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, RelativeHumidity.attributes.MeasuredValue:configure_reporting(mock_inovelli_vzm30_sn, 30, 3600, 100) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, require("integration_test.zigbee_test_utils").build_bind_request(mock_inovelli_vzm30_sn, require("integration_test.zigbee_test_utils").mock_hub_eui, clusters.OnOff.ID) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, clusters.OnOff.attributes.OnOff:configure_reporting(mock_inovelli_vzm30_sn, 0, 300) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, device_management.build_bind_request(mock_inovelli_vzm30_sn, TemperatureMeasurement.ID, require("integration_test.zigbee_test_utils").mock_hub_eui) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_inovelli_vzm30_sn, 30, 600, 100) }) + + -- base_device_configure sends OTA ImageNotify and private cluster bind + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, OTAUpgrade.commands.ImageNotify(mock_inovelli_vzm30_sn, 0x00, 100, 0x122F, 0xFFFF, 0xFFFFFFFF) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, device_management.build_bind_request(mock_inovelli_vzm30_sn, 0xFC31, require("integration_test.zigbee_test_utils").mock_hub_eui, 2) }) + + -- Read divisors/multipliers + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, clusters.SimpleMetering.attributes.Multiplier:read(mock_inovelli_vzm30_sn) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, clusters.ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_inovelli_vzm30_sn) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, clusters.ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_inovelli_vzm30_sn) }) + + -- VZM30-specific: temperature and humidity reporting configuration + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_inovelli_vzm30_sn, 30, 3600, 50) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, RelativeHumidity.attributes.MeasuredValue:configure_reporting(mock_inovelli_vzm30_sn, 30, 3600, 50) }) + + mock_inovelli_vzm30_sn:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua new file mode 100644 index 0000000000..3c839f571f --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua @@ -0,0 +1,372 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" + +-- Device endpoints with supported clusters +local inovelli_vzm30_sn_endpoints = { + [1] = { + id = 1, + manufacturer = "Inovelli", + model = "VZM30-SN", + server_clusters = {0x0006, 0x0008, 0x0300} -- OnOff, Level, ColorControl + } +} + +local mock_parent_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm30-sn.yml"), + zigbee_endpoints = inovelli_vzm30_sn_endpoints, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("rgbw-bulb.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "notification" +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +-- Test child device initialization +test.register_message_test( + "Child device should initialize with default color values", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "added" }, + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.hue(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(6500)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switchLevel.level(100)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test child device switch on command +test.register_coroutine_test( + "Child device switch on should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "on", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device switch off command +test.register_coroutine_test( + "Child device switch off should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(0, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device level command +test.register_coroutine_test( + "Child device level command should emit events and send configuration to parent", + function() + local level = math.random(1, 99) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local effect = 1 -- Default notificationType + local color = 100 -- Default color for child devices (since device starts with no hue state) + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) -- Use the actual level from command + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switchLevel", command = "setLevel", args = { level } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switchLevel.level(level)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device color command +test.register_coroutine_test( + "Child device color command should emit events and send configuration to parent", + function() + local color = math.random(0, 100) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorControl", command = "setColor", args = {{ hue = color, saturation = 100 }} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(color)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device color temperature command +test.register_coroutine_test( + "Child device color temperature command should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorTemperature", command = "setColorTemperature", args = { 3000 } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(3000)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua new file mode 100644 index 0000000000..6c6d633d70 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua @@ -0,0 +1,234 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" + +-- Device endpoints with supported clusters +local inovelli_vzm30_sn_endpoints = { + [1] = { + id = 1, + manufacturer = "Inovelli", + model = "VZM30-SN", + server_clusters = {0x0006, 0x0008} -- OnOff, Level + } +} + +local mock_inovelli_vzm30_sn = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm30-sn.yml"), + zigbee_endpoints = inovelli_vzm30_sn_endpoints, + fingerprinted_endpoint_id = 0x01, + label = "Inovelli VZM30-SN" +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzm30_sn) +end +test.set_test_init_function(test_init) + +-- Test parameter1 preference change +test.register_coroutine_test( + "parameter1 preference should send configuration command", + function() + local new_param_value = 50 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {parameter1 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm30_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 1, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter9 preference change +test.register_coroutine_test( + "parameter15 preference should send configuration command", + function() + local new_param_value = 10 + local expected_value = utils.round(new_param_value / 100 * 254) + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {parameter15 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm30_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 15, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + expected_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter52 preference change +test.register_coroutine_test( + "parameter52 preference should send configuration command", + function() + local new_param_value = true + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {parameter52 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm30_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 52, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter258 preference change +test.register_coroutine_test( + "parameter258 preference should send configuration command", + function() + local new_param_value = false + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {parameter258 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm30_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 258, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter11 preference change (VZM30-only, same as VZM31) +test.register_coroutine_test( + "parameter11 preference should send configuration command", + function() + local new_param_value = true + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {parameter11 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm30_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 11, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter17 preference change (VZM30-only, same as VZM31) +test.register_coroutine_test( + "parameter95 preference should send configuration command", + function() + local new_param_value = 64 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {parameter95 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm30_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 95, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter22 preference change (VZM30-only, same as VZM31) +test.register_coroutine_test( + "parameter22 preference should send configuration command", + function() + local new_param_value = 2 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {parameter22 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm30_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm30_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 22, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test notificationChild preference change +test.register_coroutine_test( + "notificationChild preference should create child device when enabled", + function() + mock_inovelli_vzm30_sn:expect_device_create({ + type = "EDGE_CHILD", + label = "Inovelli VZM30-SN Notification", + profile = "rgbw-bulb-2700K-6500K", + parent_device_id = mock_inovelli_vzm30_sn.id, + parent_assigned_child_key = "notification" + }) + + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {notificationChild = true}})) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua new file mode 100644 index 0000000000..6c0da0b158 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua @@ -0,0 +1,440 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" +local OTAUpgrade = require("st.zigbee.zcl.clusters").OTAUpgrade +local device_management = require "st.zigbee.device_management" +local zigbee_constants = require "st.zigbee.constants" + +-- Inovelli VZM31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = "Inovelli" +local INOVELLI_VZM31_SN_MODEL = "VZM31-SN" + +-- Device endpoints with supported clusters +local inovelli_vzm31_sn_endpoints = { + [1] = { + id = 1, + manufacturer = INOVELLI_MANUFACTURER_ID, + model = INOVELLI_VZM31_SN_MODEL, + server_clusters = {0x0006, 0x0008, 0x0300} -- OnOff, Level, ColorControl + } +} + +local mock_inovelli_vzm31_sn = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm31-sn.yml"), + zigbee_endpoints = inovelli_vzm31_sn_endpoints, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzm31_sn) +end +test.set_test_init_function(test_init) + +local supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} +} + +-- Test device initialization +test.register_message_test( + "Device should initialize properly on added lifecycle event", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_inovelli_vzm31_sn.id, "added" }, + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm31_sn.id, + clusters.Level.attributes.CurrentLevel:read(mock_inovelli_vzm31_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm31_sn.id, + clusters.OnOff.attributes.OnOff:read(mock_inovelli_vzm31_sn) + } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch on command +test.register_message_test( + "Switch on command should send OnOff On command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm31_sn.id, + { capability = "switch", command = "on", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm31_sn.id, clusters.OnOff.server.commands.On(mock_inovelli_vzm31_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch off command +test.register_message_test( + "Switch off command should send OnOff Off command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm31_sn.id, + { capability = "switch", command = "off", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm31_sn.id, clusters.OnOff.server.commands.Off(mock_inovelli_vzm31_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch level command +test.register_message_test( + "Switch level command should send Level MoveToLevelWithOnOff command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm31_sn.id, + { capability = "switchLevel", command = "setLevel", args = { 50 } } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm31_sn.id, + clusters.Level.server.commands.MoveToLevelWithOnOff(mock_inovelli_vzm31_sn, math.floor(50/100.0 * 254), 0xFFFF) + } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Build test message for Inovelli private cluster button press +local function build_inovelli_button_message(device, button_number, key_attribute) + local messages = require "st.zigbee.messages" + local zcl_messages = require "st.zigbee.zcl" + local zb_const = require "st.zigbee.constants" + local data_types = require "st.zigbee.data_types" + local frameCtrl = require "st.zigbee.zcl.frame_ctrl" + + -- Combine button_number and key_attribute into a single value + -- button_number in lower byte, key_attribute in upper byte + local combined_value = (key_attribute * 256) + button_number + + -- Create the command body using serialize_int + local command_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ + frame_ctrl = frameCtrl(0x15), -- Manufacturer specific, client to server + mfg_code = data_types.Uint16(0x122F), -- Inovelli manufacturer code + seqno = data_types.Uint8(0x6D), + cmd = data_types.ZCLCommandId(0x00) -- Scene command + }), + zcl_body = data_types.Uint16(combined_value) + }) + + local addrh = messages.AddressHeader( + device:get_short_address(), + 0x02, -- src_endpoint from real device log + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + 0xFC31 -- PRIVATE_CLUSTER_ID + ) + + return messages.ZigbeeMessageRx({ + address_header = addrh, + body = command_body + }) +end + +-- Test button1 pushed +test.register_message_test( + "Button1 pushed should emit button event and update supportedButtonValues", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_inovelli_vzm31_sn.id, build_inovelli_button_message(mock_inovelli_vzm31_sn, 0x01, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm31_sn:generate_test_message( + "button1", + capabilities.button.supportedButtonValues( + supported_button_values["button1"], + { visibility = { displayed = false } } + ) + ) + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm31_sn:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test button2 pressed 4 times +test.register_message_test( + "Button2 pressed 4 times should emit button event and update supportedButtonValues", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_inovelli_vzm31_sn.id, build_inovelli_button_message(mock_inovelli_vzm31_sn, 0x02, 0x05) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm31_sn:generate_test_message( + "button2", + capabilities.button.supportedButtonValues( + supported_button_values["button2"], + { visibility = { displayed = false } } + ) + ) + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm31_sn:generate_test_message("button2", capabilities.button.button.pushed_4x({ state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test consecutive button events - supportedButtonValues should only be sent on first event +test.register_coroutine_test( + "Consecutive button events should only send supportedButtonValues on first event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + + -- First button event: button1 pushed - should send supportedButtonValues + button event + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm31_sn.id, + build_inovelli_button_message(mock_inovelli_vzm31_sn, 0x01, 0x00) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm31_sn:generate_test_message( + "button1", + capabilities.button.supportedButtonValues( + supported_button_values["button1"], + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzm31_sn:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + ) + + -- Second button event: button1 pushed_2x - should only send button event, NOT supportedButtonValues + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm31_sn.id, + build_inovelli_button_message(mock_inovelli_vzm31_sn, 0x01, 0x03) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm31_sn:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +-- Test power meter from ElectricalMeasurement +test.register_coroutine_test( + "Power meter from ElectricalMeasurement should emit power events", + function() + -- Set the divisor field (default handlers use 10 if not set, but we can set it for consistency) + -- The default handler will use 10 if ELECTRICAL_MEASUREMENT_DIVISOR_KEY is not set + -- Since the test expects 2000 -> 200.0 W, that means divisor of 10 is being used + mock_inovelli_vzm31_sn:set_field(zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10, {persist = true}) + + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm31_sn.id, + clusters.ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_inovelli_vzm31_sn, 2000) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm31_sn:generate_test_message("main", capabilities.powerMeter.power({value = 200.0, unit = "W"})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test energy meter +test.register_coroutine_test( + "Energy meter should emit energy events", + function() + -- Set the divisor field as the device reads during configuration + -- For VZM31, the divisor is read from the device, but for testing we need to set it + -- The test expects 50000 -> 500.0 kWh, which means divisor of 100 + mock_inovelli_vzm31_sn:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 100, {persist = true}) + + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm31_sn.id, + clusters.SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_inovelli_vzm31_sn, 50000) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm31_sn:generate_test_message("main", capabilities.energyMeter.energy({value = 500.0, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test energy meter reset command +test.register_message_test( + "Energy meter reset command should send reset commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm31_sn.id, + { capability = "energyMeter", command = "resetEnergyMeter", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm31_sn.id, + cluster_base.build_manufacturer_specific_command( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x02, -- PRIVATE_CMD_ENERGY_RESET_ID + 0x122F, -- MFG_CODE + utils.serialize_int(0, 1, false, false) + ) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm31_sn.id, + clusters.SimpleMetering.attributes.CurrentSummationDelivered:read(mock_inovelli_vzm31_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm31_sn.id, + clusters.ElectricalMeasurement.attributes.ActivePower:read(mock_inovelli_vzm31_sn) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "doConfigure runs base config (VZM31)", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_inovelli_vzm31_sn.id, "doConfigure" }) + + -- Button capability messages from base_device_configure + for _, component in pairs(mock_inovelli_vzm31_sn.profile.components) do + if component.id ~= "main" then + test.socket.capability:__expect_send( + mock_inovelli_vzm31_sn:generate_test_message( + component.id, + capabilities.button.supportedButtonValues( + supported_button_values[component.id], + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzm31_sn:generate_test_message( + component.id, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + ) + end + end + + -- device:configure() sends bind requests and configure reporting (default handler) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, require("integration_test.zigbee_test_utils").build_bind_request(mock_inovelli_vzm31_sn, require("integration_test.zigbee_test_utils").mock_hub_eui, clusters.OnOff.ID) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, clusters.OnOff.attributes.OnOff:configure_reporting(mock_inovelli_vzm31_sn, 0, 300) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, require("integration_test.zigbee_test_utils").build_bind_request(mock_inovelli_vzm31_sn, require("integration_test.zigbee_test_utils").mock_hub_eui, clusters.Level.ID) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, clusters.Level.attributes.CurrentLevel:configure_reporting(mock_inovelli_vzm31_sn, 1, 3600, 1) }) + + -- base_device_configure sends OTA ImageNotify and private cluster bind + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, OTAUpgrade.commands.ImageNotify(mock_inovelli_vzm31_sn, 0x00, 100, 0x122F, 0xFFFF, 0xFFFFFFFF) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, device_management.build_bind_request(mock_inovelli_vzm31_sn, 0xFC31, require("integration_test.zigbee_test_utils").mock_hub_eui, 2) }) + + -- Read divisors/multipliers + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, clusters.SimpleMetering.attributes.Divisor:read(mock_inovelli_vzm31_sn) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, clusters.SimpleMetering.attributes.Multiplier:read(mock_inovelli_vzm31_sn) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, clusters.ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_inovelli_vzm31_sn) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, clusters.ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_inovelli_vzm31_sn) }) + + mock_inovelli_vzm31_sn:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua new file mode 100644 index 0000000000..fd7f718ff9 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua @@ -0,0 +1,362 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" + +-- Device endpoints with supported clusters +local inovelli_vzm31_sn_endpoints = { + [1] = { + id = 1, + manufacturer = "Inovelli", + model = "VZM31-SN", + server_clusters = {0x0006, 0x0008, 0x0300} -- OnOff, Level, ColorControl + } +} + +local mock_parent_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm31-sn.yml"), + zigbee_endpoints = inovelli_vzm31_sn_endpoints, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("rgbw-bulb.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "notification" +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +-- Test child device initialization +test.register_message_test( + "Child device should initialize with default color values", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "added" }, + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.hue(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(6500)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switchLevel.level(100)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test child device switch on command +test.register_coroutine_test( + "Child device switch on should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "on", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device switch off command +test.register_coroutine_test( + "Child device switch off should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(0, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device level command +test.register_coroutine_test( + "Child device level command should emit events and send configuration to parent", + function() + local level = math.random(1, 99) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local effect = 1 -- Default notificationType + local color = 100 -- Default color for child devices (since device starts with no hue state) + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) -- Use the actual level from command + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switchLevel", command = "setLevel", args = { level } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switchLevel.level(level)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device color command +test.register_coroutine_test( + "Child device color command should emit events and send configuration to parent", + function() + local color = math.random(0, 100) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorControl", command = "setColor", args = {{ hue = color, saturation = 100 }} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(color)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device color temperature command +test.register_coroutine_test( + "Child device color temperature command should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorTemperature", command = "setColorTemperature", args = { 3000 } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(3000)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua new file mode 100644 index 0000000000..3823d4e010 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua @@ -0,0 +1,224 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" + +-- Device endpoints with supported clusters +local inovelli_vzm31_sn_endpoints = { + [1] = { + id = 1, + manufacturer = "Inovelli", + model = "VZM31-SN", + server_clusters = {0x0006, 0x0008} -- OnOff, Level + } +} + +local mock_inovelli_vzm31_sn = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm31-sn.yml"), + zigbee_endpoints = inovelli_vzm31_sn_endpoints, + fingerprinted_endpoint_id = 0x01, + label = "Inovelli VZM31-SN" +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzm31_sn) +end +test.set_test_init_function(test_init) + +-- Test parameter1 preference change +test.register_coroutine_test( + "parameter1 preference should send configuration command", + function() + local new_param_value = 50 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {parameter1 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm31_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 1, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter9 preference change +test.register_coroutine_test( + "parameter9 preference should send configuration command", + function() + local new_param_value = 10 + local expected_value = utils.round(new_param_value / 100 * 254) + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {parameter9 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm31_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 9, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + expected_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter52 preference change +test.register_coroutine_test( + "parameter52 preference should send configuration command", + function() + local new_param_value = true + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {parameter52 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm31_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 52, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter258 preference change +test.register_coroutine_test( + "parameter258 preference should send configuration command", + function() + local new_param_value = false + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {parameter258 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm31_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 258, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter11 preference change (VZM31-only) +test.register_coroutine_test( + "parameter11 preference should send configuration command", + function() + local new_param_value = true + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {parameter11 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm31_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 11, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter17 preference change (VZM31-only) +test.register_coroutine_test( + "parameter17 preference should send configuration command", + function() + local new_param_value = 5 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {parameter17 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm31_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 17, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter22 preference change (VZM31-only) +test.register_coroutine_test( + "parameter22 preference should send configuration command", + function() + local new_param_value = 2 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {parameter22 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm31_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm31_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 22, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test notificationChild preference change +test.register_coroutine_test( + "notificationChild preference should create child device when enabled", + function() + mock_inovelli_vzm31_sn:expect_device_create({ + type = "EDGE_CHILD", + label = "Inovelli VZM31-SN Notification", + profile = "rgbw-bulb-2700K-6500K", + parent_device_id = mock_inovelli_vzm31_sn.id, + parent_assigned_child_key = "notification" + }) + + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {notificationChild = true}})) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua new file mode 100644 index 0000000000..ee21044bea --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua @@ -0,0 +1,557 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" +local OTAUpgrade = require("st.zigbee.zcl.clusters").OTAUpgrade +local device_management = require "st.zigbee.device_management" +local zigbee_constants = require "st.zigbee.constants" + +local OnOff = clusters.OnOff +local Level = clusters.Level + +-- Inovelli VZM32-SN device identifiers +local INOVELLI_MANUFACTURER_ID = "Inovelli" +local INOVELLI_VZM32_SN_MODEL = "VZM32-SN" + +-- Device endpoints with supported clusters +local inovelli_vzm32_sn_endpoints = { + [1] = { + id = 1, + manufacturer = INOVELLI_MANUFACTURER_ID, + model = INOVELLI_VZM32_SN_MODEL, + server_clusters = {0x0006, 0x0008, 0x0300, 0x0406} -- OnOff, Level, ColorControl, OccupancySensing + } +} + +local mock_inovelli_vzm32_sn = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm32-sn.yml"), + zigbee_endpoints = inovelli_vzm32_sn_endpoints, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzm32_sn) +end +test.set_test_init_function(test_init) + +local supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} +} + +-- Test device initialization +test.register_message_test( + "Device should initialize properly on added lifecycle event", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_inovelli_vzm32_sn.id, "added" }, + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm32_sn.id, + clusters.Level.attributes.CurrentLevel:read(mock_inovelli_vzm32_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm32_sn.id, + clusters.OnOff.attributes.OnOff:read(mock_inovelli_vzm32_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm32_sn.id, + clusters.OccupancySensing.attributes.Occupancy:read(mock_inovelli_vzm32_sn) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test refresh capability +test.register_message_test( + "Refresh capability should send read commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + { capability = "refresh", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm32_sn.id, OnOff.attributes.OnOff:read(mock_inovelli_vzm32_sn) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm32_sn.id, Level.attributes.CurrentLevel:read(mock_inovelli_vzm32_sn) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm32_sn.id, clusters.OccupancySensing.attributes.Occupancy:read(mock_inovelli_vzm32_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch on command +test.register_message_test( + "Switch on command should send OnOff On command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + { capability = "switch", command = "on", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm32_sn.id, clusters.OnOff.server.commands.On(mock_inovelli_vzm32_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch off command +test.register_message_test( + "Switch off command should send OnOff Off command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + { capability = "switch", command = "off", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_inovelli_vzm32_sn.id, clusters.OnOff.server.commands.Off(mock_inovelli_vzm32_sn) } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test switch level command +test.register_message_test( + "Switch level command should send Level MoveToLevelWithOnOff command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + { capability = "switchLevel", command = "setLevel", args = { 50 } } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm32_sn.id, + clusters.Level.server.commands.MoveToLevelWithOnOff(mock_inovelli_vzm32_sn, math.floor(50/100.0 * 254), 0xFFFF) + } + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Build test message for Inovelli private cluster button press +local function build_inovelli_button_message(device, button_number, key_attribute) + local messages = require "st.zigbee.messages" + local zcl_messages = require "st.zigbee.zcl" + local zb_const = require "st.zigbee.constants" + local data_types = require "st.zigbee.data_types" + local frameCtrl = require "st.zigbee.zcl.frame_ctrl" + + -- Combine button_number and key_attribute into a single value + -- button_number in lower byte, key_attribute in upper byte + local combined_value = (key_attribute * 256) + button_number + + -- Create the command body using serialize_int + local command_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ + frame_ctrl = frameCtrl(0x15), -- Manufacturer specific, client to server + mfg_code = data_types.Uint16(0x122F), -- Inovelli manufacturer code + seqno = data_types.Uint8(0x6D), + cmd = data_types.ZCLCommandId(0x00) -- Scene command + }), + zcl_body = data_types.Uint16(combined_value) + }) + + local addrh = messages.AddressHeader( + device:get_short_address(), + 0x02, -- src_endpoint from real device log + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + 0xFC31 -- PRIVATE_CLUSTER_ID + ) + + return messages.ZigbeeMessageRx({ + address_header = addrh, + body = command_body + }) +end + +-- Test button1 pushed +test.register_message_test( + "Button1 pushed should emit button event and update supportedButtonValues", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_inovelli_vzm32_sn.id, build_inovelli_button_message(mock_inovelli_vzm32_sn, 0x01, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm32_sn:generate_test_message( + "button1", + capabilities.button.supportedButtonValues( + supported_button_values["button1"], + { visibility = { displayed = false } } + ) + ) + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm32_sn:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test button2 pressed 4 times +test.register_message_test( + "Button2 pressed 4 times should emit button event and update supportedButtonValues", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_inovelli_vzm32_sn.id, build_inovelli_button_message(mock_inovelli_vzm32_sn, 0x02, 0x05) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm32_sn:generate_test_message( + "button2", + capabilities.button.supportedButtonValues( + supported_button_values["button2"], + { visibility = { displayed = false } } + ) + ) + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm32_sn:generate_test_message("button2", capabilities.button.button.pushed_4x({ state_change = true })) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test consecutive button events - supportedButtonValues should only be sent on first event +test.register_coroutine_test( + "Consecutive button events should only send supportedButtonValues on first event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + + -- First button event: button1 pushed - should send supportedButtonValues + button event + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm32_sn.id, + build_inovelli_button_message(mock_inovelli_vzm32_sn, 0x01, 0x00) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm32_sn:generate_test_message( + "button1", + capabilities.button.supportedButtonValues( + supported_button_values["button1"], + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzm32_sn:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + ) + + -- Second button event: button1 pushed_2x - should only send button event, NOT supportedButtonValues + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm32_sn.id, + build_inovelli_button_message(mock_inovelli_vzm32_sn, 0x01, 0x03) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm32_sn:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) + ) + end, + { + min_api_version = 17 + } +) + +-- Test illuminance measurement +test.register_message_test( + "Illuminance measurement should emit illuminance events", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_inovelli_vzm32_sn, 11271) + } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({value = 13})) + } + }, + { + min_api_version = 17 + } +) + +-- Test motion sensor active +test.register_message_test( + "Motion sensor active should emit motion active event", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_attr_report(mock_inovelli_vzm32_sn, 0x01) + } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } +) + +-- Test motion sensor inactive +test.register_message_test( + "Motion sensor inactive should emit motion inactive event", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_attr_report(mock_inovelli_vzm32_sn, 0x00) + } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 17 + } +) + +-- Test power meter from ElectricalMeasurement +test.register_coroutine_test( + "Power meter from ElectricalMeasurement should emit power events", + function() + -- Set the divisor field (default handlers use 10 if not set, but we can set it for consistency) + -- The default handler will use 10 if ELECTRICAL_MEASUREMENT_DIVISOR_KEY is not set + -- Since the test expects 2000 -> 200.0 W, that means divisor of 10 is being used + -- For VZM32, the actual device reads ACPowerDivisor, but default is 10 + mock_inovelli_vzm32_sn:set_field(zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10, {persist = true}) + + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm32_sn.id, + clusters.ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_inovelli_vzm32_sn, 2000) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.powerMeter.power({value = 200.0, unit = "W"})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test energy meter +test.register_coroutine_test( + "Energy meter should emit energy events", + function() + -- Set the divisor field as the device does during configuration + mock_inovelli_vzm32_sn:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + + test.socket.zigbee:__queue_receive({ + mock_inovelli_vzm32_sn.id, + clusters.SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_inovelli_vzm32_sn, 212) + }) + test.socket.capability:__expect_send( + mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.energyMeter.energy({value = 0.212, unit = "kWh"})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test energy meter reset command +test.register_message_test( + "Energy meter reset command should send reset commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzm32_sn.id, + { capability = "energyMeter", command = "resetEnergyMeter", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm32_sn.id, + cluster_base.build_manufacturer_specific_command( + mock_inovelli_vzm32_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x02, -- PRIVATE_CMD_ENERGY_RESET_ID + 0x122F, -- MFG_CODE + utils.serialize_int(0, 1, false, false) + ) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm32_sn.id, + clusters.SimpleMetering.attributes.CurrentSummationDelivered:read(mock_inovelli_vzm32_sn) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_inovelli_vzm32_sn.id, + clusters.ElectricalMeasurement.attributes.ActivePower:read(mock_inovelli_vzm32_sn) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "doConfigure runs base + VZM32 extras", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_inovelli_vzm32_sn.id, "doConfigure" }) + + -- Button capability messages from base_device_configure + for _, component in pairs(mock_inovelli_vzm32_sn.profile.components) do + if component.id ~= "main" then + test.socket.capability:__expect_send( + mock_inovelli_vzm32_sn:generate_test_message( + component.id, + capabilities.button.supportedButtonValues( + supported_button_values[component.id], + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzm32_sn:generate_test_message( + component.id, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + ) + end + end + + -- device:configure() sends bind requests and configure reporting (default handler) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, require("integration_test.zigbee_test_utils").build_bind_request(mock_inovelli_vzm32_sn, require("integration_test.zigbee_test_utils").mock_hub_eui, clusters.OnOff.ID) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, clusters.OnOff.attributes.OnOff:configure_reporting(mock_inovelli_vzm32_sn, 0, 300) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, require("integration_test.zigbee_test_utils").build_bind_request(mock_inovelli_vzm32_sn, require("integration_test.zigbee_test_utils").mock_hub_eui, clusters.Level.ID) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, clusters.Level.attributes.CurrentLevel:configure_reporting(mock_inovelli_vzm32_sn, 1, 3600, 1) }) + + -- base_device_configure sends OTA ImageNotify and private cluster bind + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, OTAUpgrade.commands.ImageNotify(mock_inovelli_vzm32_sn, 0x00, 100, 0x122F, 0xFFFF, 0xFFFFFFFF) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, device_management.build_bind_request(mock_inovelli_vzm32_sn, 0xFC31, require("integration_test.zigbee_test_utils").mock_hub_eui, 2) }) + + -- Read divisors/multipliers + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, clusters.SimpleMetering.attributes.Multiplier:read(mock_inovelli_vzm32_sn) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, clusters.ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_inovelli_vzm32_sn) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, clusters.ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_inovelli_vzm32_sn) }) + + -- VZM32-specific: occupancy and illuminance reporting configuration + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, device_management.build_bind_request(mock_inovelli_vzm32_sn, clusters.OccupancySensing.ID, require("integration_test.zigbee_test_utils").mock_hub_eui) }) + test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, clusters.IlluminanceMeasurement.attributes.MeasuredValue:configure_reporting(mock_inovelli_vzm32_sn, 10, 600, 11761) }) + + mock_inovelli_vzm32_sn:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua new file mode 100644 index 0000000000..7173463e16 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua @@ -0,0 +1,361 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" + +-- Device endpoints with supported clusters +local inovelli_vzm32_sn_endpoints = { + [1] = { + id = 1, + manufacturer = "Inovelli", + model = "VZM32-SN", + server_clusters = {0x0006, 0x0008, 0x0300} -- OnOff, Level, ColorControl + } +} + +local mock_parent_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm32-sn.yml"), + zigbee_endpoints = inovelli_vzm32_sn_endpoints, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("rgbw-bulb.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "notification" +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +-- Test child device initialization +test.register_message_test( + "Child device should initialize with default color values", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "added" }, + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.hue(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(6500)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switchLevel.level(100)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test child device switch on command +test.register_coroutine_test( + "Child device switch on should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "on", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device switch off command +test.register_coroutine_test( + "Child device switch off should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(0, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device level command +test.register_coroutine_test( + "Child device level command should emit events and send configuration to parent", + function() + local level = math.random(1, 99) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local effect = 1 -- Default notificationType + local color = 100 -- Default color for child devices (since device starts with no hue state) + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) -- Use the actual level from command + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switchLevel", command = "setLevel", args = { level } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switchLevel.level(level)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device color command +test.register_coroutine_test( + "Child device color command should emit events and send configuration to parent", + function() + local color = math.random(0, 100) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorControl", command = "setColor", args = {{ hue = color, saturation = 100 }} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(color)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test child device color temperature command +test.register_coroutine_test( + "Child device color temperature command should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorTemperature", command = "setColorTemperature", args = { 3000 } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(3000)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + cluster_base.build_manufacturer_specific_command( + mock_parent_device, + 0xFC31, -- PRIVATE_CLUSTER_ID + 0x01, -- PRIVATE_CMD_NOTIF_ID + 0x122F, -- MFG_CODE + utils.serialize_int(notificationValue, 4, false, false) + ) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua new file mode 100644 index 0000000000..9939926ff3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua @@ -0,0 +1,182 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local utils = require "st.utils" + +-- Device endpoints with supported clusters +local inovelli_vzm32_sn_endpoints = { + [1] = { + id = 1, + manufacturer = "Inovelli", + model = "VZM32-SN", + server_clusters = {0x0006, 0x0008} -- OnOff, Level + } +} + +local mock_inovelli_vzm32_sn = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("inovelli-vzm32-sn.yml"), + zigbee_endpoints = inovelli_vzm32_sn_endpoints, + fingerprinted_endpoint_id = 0x01, + label = "Inovelli VZM32-SN" +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzm32_sn) +end +test.set_test_init_function(test_init) + +-- Test parameter1 preference change +test.register_coroutine_test( + "parameter1 preference should send configuration command", + function() + local new_param_value = 50 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm32_sn:generate_info_changed({preferences = {parameter1 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm32_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm32_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 1, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter9 preference change +test.register_coroutine_test( + "parameter9 preference should send configuration command", + function() + local new_param_value = 10 + local expected_value = utils.round(new_param_value / 100 * 254) + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm32_sn:generate_info_changed({preferences = {parameter9 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm32_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm32_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 9, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Uint8, + expected_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter52 preference change +test.register_coroutine_test( + "parameter52 preference should send configuration command", + function() + local new_param_value = true + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm32_sn:generate_info_changed({preferences = {parameter52 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm32_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm32_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 52, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test parameter258 preference change +test.register_coroutine_test( + "parameter258 preference should send configuration command", + function() + local new_param_value = false + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm32_sn:generate_info_changed({preferences = {parameter258 = new_param_value}})) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm32_sn.id, + cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm32_sn, + 0xFC31, -- PRIVATE_CLUSTER_ID + 258, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Boolean, + new_param_value + ) + }) + end, + { + min_api_version = 17 + } +) + +-- Test notificationChild preference change +test.register_coroutine_test( + "notificationChild preference should create child device when enabled", + function() + mock_inovelli_vzm32_sn:expect_device_create({ + type = "EDGE_CHILD", + label = "Inovelli VZM32-SN Notification", + profile = "rgbw-bulb-2700K-6500K", + parent_device_id = mock_inovelli_vzm32_sn.id, + parent_assigned_child_key = "notification" + }) + + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm32_sn:generate_info_changed({preferences = {notificationChild = true}})) + end, + { + min_api_version = 17 + } +) + +-- Test parameter101 preference change +test.register_coroutine_test( + "parameter101 preference should send configuration command", + function() + local new_param_value = 200 + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm32_sn:generate_info_changed({preferences = {parameter101 = new_param_value}})) + + local expected_command = cluster_base.write_manufacturer_specific_attribute( + mock_inovelli_vzm32_sn, + 0xFC32, -- PRIVATE_CLUSTER_ID + 101, -- parameter_number + 0x122F, -- MFG_CODE + data_types.Int16, + new_param_value + ) + + print("=== DEBUG: Expected command ===") + print("Command type:", type(expected_command)) + print("Command:", expected_command) + + test.socket.zigbee:__expect_send({ + mock_inovelli_vzm32_sn.id, + expected_command + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua index 0f0eec609b..6d1b78e806 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua @@ -1,38 +1,33 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local OnOffCluster = clusters.OnOff local SimpleMeteringCluster = clusters.SimpleMetering +local ElectricalMeasurementCluster = clusters.ElectricalMeasurement local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("switch-power-energy.yml"), + fingerprinted_endpoint_id = 0x01, zigbee_endpoints = { [1] = { id = 1, manufacturer = "Jasco Products", model = "43078", - server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0702, 0x0B05 }, + server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0702, 0x0B04 }, client_clusters = { 0x000A, 0x0019 } } } }) +zigbee_test_utils.prepare_zigbee_env_info() + local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) end @@ -46,11 +41,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOffCluster.server.commands.On(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -62,11 +68,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOffCluster.server.commands.Off(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -83,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 33.3, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -104,6 +124,38 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.5555, unit = "kWh" })) } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doConfigure lifecycle should call refresh and configure on device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + -- device:refresh() reads + test.socket.zigbee:__expect_send({ mock_device.id, OnOffCluster.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.InstantaneousDemand:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.CurrentSummationDelivered:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerDivisor:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerMultiplier:read(mock_device) }) + -- device:configure() bind requests and configure_reporting + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOffCluster.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMeteringCluster.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurementCluster.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua b/drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua new file mode 100644 index 0000000000..5c7df6e762 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua @@ -0,0 +1,574 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local OnOff = clusters.OnOff + +local common_switch_profile_def = t_utils.get_profile_definition("switch-smart-bath-heater-laisiao.yml") + +local mock_device = test.mock_device.build_test_zigbee_device( + { + label = "Laisiao Bathroom Heater", + profile = common_switch_profile_def, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LAISIAO", + model = "yuba", + server_clusters = { 0x0006 } + } + }, + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Reported on status should be handled: on ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch2", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch3", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 4", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x04) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch4", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 5", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x05) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch5", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 6", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x06) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch6", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 7", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x07) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch7", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 8", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x08) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch8", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + + + +test.register_message_test( + "Reported off status should be handled: off ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch2", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch3", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 4", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x04) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch4", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 5", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x05) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch5", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 6", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x06) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch6", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 7", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x07) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch7", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 8", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x08) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("switch8", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch2 Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch2", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch3 Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch3", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x03) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch4 Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch4", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x04) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch5 Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch5", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x05) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch6 Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch6", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x06) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch7 Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch7", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x07) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch8 Capability on command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch8", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x08) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component main Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x01) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch2 Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch2", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch3 Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch3", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x03) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch4 Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch4", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x04) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch5 Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch5", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x05) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch6 Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch6", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x06) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch7 Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch7", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x07) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component switch8 Capability off command should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "switch8", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x08) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "component main Capability on command emits on then off after delay", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.switch.switch.on()) + ) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.switch.switch.off()) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_ledvance_metering_plug.lua b/drivers/SmartThings/zigbee-switch/src/test/test_ledvance_metering_plug.lua new file mode 100644 index 0000000000..ebdcae468e --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_ledvance_metering_plug.lua @@ -0,0 +1,107 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local zigbee_constants = require "st.zigbee.constants" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("switch-power-energy.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LEDVANCE", + model = "PLUG COMPACT EU EM T", + server_clusters = { 0x0006, 0x0702 } + } + } + } +) + +local mock_device_eu_em_t = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("switch-power-energy.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LEDVANCE", + model = "PLUG EU EM T", + server_clusters = { 0x0006, 0x0702 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_eu_em_t) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device init should set default multiplier and divisor only when not already set", + function() + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == nil) + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 1) + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 100) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device init should preserve device-reported multiplier and divisor", + function() + mock_device:set_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY, 5, {persist = true}) + mock_device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 5) + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 1000) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device init should set default multiplier and divisor only when not already set - PLUG EU EM T", + function() + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == nil) + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil) + test.socket.device_lifecycle:__queue_receive({ mock_device_eu_em_t.id, "init" }) + test.wait_for_events() + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 1) + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 100) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device init should preserve device-reported multiplier and divisor - PLUG EU EM T", + function() + mock_device_eu_em_t:set_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY, 5, {persist = true}) + mock_device_eu_em_t:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + test.socket.device_lifecycle:__queue_receive({ mock_device_eu_em_t.id, "init" }) + test.wait_for_events() + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 5) + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 1000) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua index 5b09fdd871..b6b5adade5 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -30,9 +19,7 @@ local mock_simple_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_simple_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_simple_device)end test.set_test_init_function(test_init) @@ -50,6 +37,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch2", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) test.register_message_test( @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch3", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -100,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -116,6 +115,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, OnOff.server.commands.On(mock_simple_device):to_endpoint(0x03) } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua index 07822a048f..2ec4c6eb86 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -22,34 +11,46 @@ local t_utils = require "integration_test.utils" local profile = t_utils.get_profile_definition("basic-switch.yml") local mock_base_device = test.mock_device.build_test_zigbee_device( - { - label = "Dawon Switch 1", - profile = profile, - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "DAWON_DNS", - model = "PM-S340R-ZB", - server_clusters = { 0x0006 } - } + { + label = "Dawon Switch 1", + profile = profile, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "DAWON_DNS", + model = "PM-S340R-ZB", + server_clusters = { 0x0006 } }, - fingerprinted_endpoint_id = 0x01 - } + [2] = { + id = 2, + manufacturer = "DAWON_DNS", + model = "PM-S340R-ZB", + server_clusters = { 0x0006 } + }, + [3] = { + id = 3, + manufacturer = "DAWON_DNS", + model = "PM-S340R-ZB", + server_clusters = { 0x0006 } + } + }, + fingerprinted_endpoint_id = 0x01 + } ) local mock_parent_device = test.mock_device.build_test_zigbee_device( - { - profile = profile, - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "DAWON_DNS", - model = "PM-S340R-ZB", - server_clusters = { 0x0006 } - } - }, - fingerprinted_endpoint_id = 0x01 - } + { + profile = profile, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "DAWON_DNS", + model = "PM-S340R-ZB", + server_clusters = { 0x0006 } + } + }, + fingerprinted_endpoint_id = 0x01 + } ) local mock_first_child = test.mock_device.build_test_child_device({ @@ -71,264 +72,489 @@ local function test_init() test.mock_device.add_test_device(mock_base_device) test.mock_device.add_test_device(mock_parent_device) test.mock_device.add_test_device(mock_first_child) - test.mock_device.add_test_device(mock_second_child) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_second_child)end test.set_test_init_function(test_init) test.register_message_test( - "Reported on off status should be handled by parent device: on", + "Reported on off status should be handled by parent device: on", + { { - { - channel = "device_lifecycle", - direction = "receive", - message = { mock_parent_device.id, "init" } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_parent_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, - true) :from_endpoint(0x01) } - }, - { - channel = "capability", - direction = "send", - message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + true) :from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } - } + }, + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Reported on off status should be handled by first child device: on", + "Reported on off status should be handled by first child device: on", + { { - { - channel = "device_lifecycle", - direction = "receive", - message = { mock_first_child.id, "init" } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_first_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, - true) :from_endpoint(0x02) } - }, - { - channel = "capability", - direction = "send", - message = mock_first_child:generate_test_message("main", capabilities.switch.switch.on()) + channel = "device_lifecycle", + direction = "receive", + message = { mock_first_child.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_first_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + true) :from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_first_child:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } - } + }, + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Reported on off status should be handled by first child device: on", + "Reported on off status should be handled by first child device: on", + { { - { - channel = "device_lifecycle", - direction = "receive", - message = { mock_second_child.id, "init" } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_second_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, - true) :from_endpoint(0x02) } - }, - { - channel = "capability", - direction = "send", - message = mock_second_child:generate_test_message("main", capabilities.switch.switch.on()) + channel = "device_lifecycle", + direction = "receive", + message = { mock_second_child.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_second_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + true) :from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_second_child:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } - } + }, + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Reported on off status should be handled by parent device: off", + "Reported on off status should be handled by parent device: off", + { { - { - channel = "device_lifecycle", - direction = "receive", - message = { mock_parent_device.id, "init" } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_parent_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, - false) :from_endpoint(0x00) } - }, - { - channel = "capability", - direction = "send", - message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.off()) + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + false) :from_endpoint(0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.off()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } - } + }, + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Reported on off status should be handled by first child device: off", + "Reported on off status should be handled by first child device: off", + { { - { - channel = "device_lifecycle", - direction = "receive", - message = { mock_first_child.id, "init" } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_first_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, - false) :from_endpoint(0x01) } - }, - { - channel = "capability", - direction = "send", - message = mock_first_child:generate_test_message("main", capabilities.switch.switch.off()) + channel = "device_lifecycle", + direction = "receive", + message = { mock_first_child.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_first_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + false) :from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_first_child:generate_test_message("main", capabilities.switch.switch.off()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } - } + }, + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Reported on off status should be handled by first second device: off", + "Reported on off status should be handled by first second device: off", + { { - { - channel = "device_lifecycle", - direction = "receive", - message = { mock_second_child.id, "init" } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_second_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, - false) :from_endpoint(0x02) } - }, - { - channel = "capability", - direction = "send", - message = mock_second_child:generate_test_message("main", capabilities.switch.switch.off()) + channel = "device_lifecycle", + direction = "receive", + message = { mock_second_child.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_second_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + false) :from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_second_child:generate_test_message("main", capabilities.switch.switch.off()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } - } + }, + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability on command switch on should be handled : parent device", + "Capability on command switch on should be handled : parent device", + { { - { - channel = "capability", - direction = "receive", - message = { mock_parent_device.id, { capability = "switch", component = "main", command = "on", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device) } + channel = "capability", + direction = "receive", + message = { mock_parent_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "on" } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device) } } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability on command switch on should be handled : first child device", + "Capability on command switch on should be handled : first child device", + { { - { - channel = "capability", - direction = "receive", - message = { mock_first_child.id, { capability = "switch", component = "main", command = "on", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } + channel = "capability", + direction = "receive", + message = { mock_first_child.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "on" } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability on command switch on should be handled : second child device", + "Capability on command switch on should be handled : second child device", + { { - { - channel = "capability", - direction = "receive", - message = { mock_second_child.id, { capability = "switch", component = "main", command = "on", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } + channel = "capability", + direction = "receive", + message = { mock_second_child.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "on" } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability off command switch on should be handled : parent device", + "Capability off command switch on should be handled : parent device", + { { - { - channel = "capability", - direction = "receive", - message = { mock_parent_device.id, { capability = "switch", component = "main", command = "off", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device) } + channel = "capability", + direction = "receive", + message = { mock_parent_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "off" } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device) } } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability off command switch on should be handled : first child device", + "Capability off command switch on should be handled : first child device", + { { - { - channel = "capability", - direction = "receive", - message = { mock_first_child.id, { capability = "switch", component = "main", command = "off", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } + channel = "capability", + direction = "receive", + message = { mock_first_child.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "off" } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 + } ) test.register_message_test( - "Capability off command switch on should be handled : second child device", + "Capability off command switch on should be handled : second child device", + { { - { - channel = "capability", - direction = "receive", - message = { mock_second_child.id, { capability = "switch", component = "main", command = "off", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } + channel = "capability", + direction = "receive", + message = { mock_second_child.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "off" } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "added lifecycle event should create children in parent device and configure them", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_base_device.id, "added" }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Dawon Switch 2", + profile = "basic-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "02" + }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Dawon Switch 3", + profile = "basic-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "03" + }) + test.socket.zigbee:__expect_send({ + mock_base_device.id, + OnOff.attributes.OnOff:read(mock_base_device) + }) + test.socket.zigbee:__expect_send({ + mock_base_device.id, + OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(2) + }) + test.socket.zigbee:__expect_send({ + mock_base_device.id, + OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(3) + }) + test.socket.device_lifecycle:__queue_receive({ mock_base_device.id, "doConfigure"}) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:configure_reporting(mock_base_device, + 0, + 300) + }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:configure_reporting(mock_base_device, + 0, + 300, + 2):to_endpoint(2) + }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:configure_reporting(mock_base_device, + 0, + 300, + 3):to_endpoint(3) + }) + test.socket.zigbee:__expect_send({ + mock_base_device.id, + zigbee_test_utils.build_bind_request(mock_base_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_base_device.id, + zigbee_test_utils.build_bind_request(mock_base_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID, + 2 + ):to_endpoint(2) + }) + test.socket.zigbee:__expect_send({ + mock_base_device.id, + zigbee_test_utils.build_bind_request(mock_base_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID, + 3 + ):to_endpoint(3) + }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device) }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(2) }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(3) }) + mock_base_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +local mock_non_mns_device = test.mock_device.build_test_zigbee_device( + { + label = "Generic Switch 1", + profile = profile, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "GenericMfr", + model = "GenericModel-DualSwitch", + server_clusters = { 0x0006 } + }, + [2] = { + id = 2, + manufacturer = "GenericMfr", + model = "GenericModel-DualSwitch", + server_clusters = { 0x0006 } + } + }, + fingerprinted_endpoint_id = 0x01 + } ) test.register_coroutine_test( - "added lifecycle event should create children in parent device", - function() - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_base_device.id, "added" }) - mock_base_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Dawon Switch 2", - profile = "basic-switch", - parent_device_id = mock_base_device.id, - parent_assigned_child_key = "02" - }) - mock_base_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Dawon Switch 3", - profile = "basic-switch", - parent_device_id = mock_base_device.id, - parent_assigned_child_key = "03" - }) - test.socket.zigbee:__expect_send({ - mock_base_device.id, - OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(0x01) - }) - end + "device added lifecycle creates child device for secondary OnOff endpoint", + function() + test.socket.device_lifecycle:__queue_receive({ mock_non_mns_device.id, "added" }) + mock_non_mns_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Generic Switch 1 2", + profile = "basic-switch", + parent_device_id = mock_non_mns_device.id, + parent_assigned_child_key = "02" + }) + end, + { + test_init = function() + test.mock_device.add_test_device(mock_non_mns_device) + end, + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua index 15e0e2dcb2..788c5477c5 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -20,6 +9,12 @@ local ElectricalMeasurement = clusters.ElectricalMeasurement local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local messages = require "st.zigbee.messages" +local config_reporting_response = require "st.zigbee.zcl.global_commands.configure_reporting_response" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local data_types = require "st.zigbee.data_types" +local Status = require "st.zigbee.generated.types.ZclStatus" local profile = t_utils.get_profile_definition("switch-power-smartplug.yml") @@ -64,14 +59,92 @@ local mock_child_device = test.mock_device.build_test_child_device({ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + mock_base_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_base_device) + mock_parent_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_parent_device) - test.mock_device.add_test_device(mock_child_device) - zigbee_test_utils.init_noop_health_check_timer() -end + mock_child_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_child_device)end test.set_test_init_function(test_init) +local function build_config_response_msg(device, cluster, status) + local addr_header = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local config_response_body = config_reporting_response.ConfigureReportingResponse({}, status) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(config_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = config_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +test.register_coroutine_test( + "configuration version below 1", + function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) + assert(mock_parent_device:get_field("_configuration_version") == nil) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "init" }) + assert(mock_child_device:get_field("_configuration_version") == nil) + test.socket.device_lifecycle:__queue_receive({ mock_child_device.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({mock_parent_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_parent_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({mock_parent_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_parent_device, 5, 600, 5):to_endpoint(0x02)}) + test.mock_time.advance_time(50 * 60 + 1) + test.wait_for_events() + test.socket.zigbee:__queue_receive({mock_parent_device.id, build_config_response_msg(mock_parent_device, ElectricalMeasurement.ID, Status.SUCCESS)}) + test.socket.zigbee:__queue_receive({mock_parent_device.id, build_config_response_msg(mock_parent_device, ElectricalMeasurement.ID, Status.SUCCESS):from_endpoint(0x02)}) + test.wait_for_events() + assert(mock_child_device:get_field("_configuration_version") == 1, "config version for child should be 1") + assert(mock_parent_device:get_field("_configuration_version") == 1, "config version for parent should be 1") + end, + { + test_init = function() + -- no op to avoid auto device add and immediate init event on driver startup + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "configuration version at 1 doesn't reconfigure", + function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) + mock_child_device:set_field("_configuration_version", 1, {persist = true}) + mock_parent_device:set_field("_configuration_version", 1, {persist = true}) + assert(mock_parent_device:get_field("_configuration_version") == 1) + assert(mock_child_device:get_field("_configuration_version") == 1) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_child_device.id, "init" }) + test.wait_for_events() + assert(mock_child_device:get_field("_configuration_version") == 1) + assert(mock_parent_device:get_field("_configuration_version") == 1) + end, + { + test_init = function() + -- no op to avoid auto device add and immediate init event on driver startup + end, + min_api_version = 17 + } +) + test.register_message_test( "Refresh on parent device should read all necessary attributes", { @@ -98,7 +171,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -128,7 +202,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -145,7 +220,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_child_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -162,7 +248,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -179,7 +276,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_child_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -196,7 +304,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -215,7 +334,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -234,7 +364,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -246,11 +387,22 @@ test.register_message_test( direction = "receive", message = { mock_child_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_child_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) @@ -262,11 +414,22 @@ test.register_message_test( direction = "receive", message = { mock_parent_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 17 } ) @@ -278,11 +441,22 @@ test.register_message_test( direction = "receive", message = { mock_child_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_child_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) @@ -294,11 +468,22 @@ test.register_message_test( direction = "receive", message = { mock_parent_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 17 } ) @@ -322,7 +507,10 @@ test.register_coroutine_test( mock_base_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_base_device):to_endpoint(0x01) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -338,7 +526,10 @@ test.register_coroutine_test( mock_parent_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_child_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua index 65985f6444..7a1af4a260 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -27,9 +16,7 @@ local mock_simple_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_simple_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_simple_device)end test.set_test_init_function(test_init) @@ -49,7 +36,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switchLevel.level(83)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_simple_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -66,7 +64,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_simple_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -83,7 +92,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_simple_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -95,6 +115,14 @@ test.register_message_test( direction = "receive", message = { mock_simple_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57, 0 } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_simple_device.id, capability_id = "switchLevel", capability_cmd_id = "setLevel" } + } + }, { channel = "zigbee", direction = "send", @@ -102,6 +130,9 @@ test.register_message_test( math.floor(57 * 0xFE / 100), 0) } } + }, + { + min_api_version = 17 } ) @@ -142,26 +173,29 @@ test.register_coroutine_test( 1) }) mock_simple_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) - end -) - -test.register_coroutine_test( - "health check coroutine", - function() - test.wait_for_events() - - test.mock_time.advance_time(10000) - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send({ mock_simple_device.id, OnOff.attributes.OnOff:read(mock_simple_device) }) - test.socket.zigbee:__expect_send({ mock_simple_device.id, Level.attributes.CurrentLevel:read(mock_simple_device) }) - test.wait_for_events() end, { - test_init = function() - test.mock_device.add_test_device(mock_simple_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end + min_api_version = 17 } ) +-- test.register_coroutine_test( +-- "health check coroutine", +-- function() +-- test.wait_for_events() + +-- test.mock_time.advance_time(10000) +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send({ mock_simple_device.id, OnOff.attributes.OnOff:read(mock_simple_device) }) +-- test.socket.zigbee:__expect_send({ mock_simple_device.id, Level.attributes.CurrentLevel:read(mock_simple_device) }) +-- test.wait_for_events() +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_simple_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua index aa366750d8..8727de3c35 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -74,7 +63,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -82,7 +74,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -111,7 +106,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -141,7 +137,8 @@ test.register_message_test( } }, { - inner_block_ordering = "RELAXED" + inner_block_ordering = "RELAXED", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua index 3efe35fc49..29a53accff 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -74,7 +63,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -82,7 +74,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -111,7 +106,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua index 2aa6dece8f..06c20b34e5 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -76,7 +65,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -121,7 +113,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -131,6 +124,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setHue") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -145,7 +139,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -154,6 +151,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setSaturation") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) local saturation = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -168,7 +166,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -176,6 +177,7 @@ test.register_coroutine_test( function() test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setColor") test.socket.zigbee:__expect_send( { mock_device.id, @@ -196,7 +198,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua index 32e8956250..4a83d8b920 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -43,6 +32,81 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) + + test.register_coroutine_test( "Configure should configure all necessary attributes and refresh device", function() @@ -99,7 +163,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } ) test.register_message_test( @@ -152,7 +220,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -162,6 +231,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setHue") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -176,7 +246,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -185,6 +258,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setSaturation") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) local saturation = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -199,7 +273,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -208,6 +285,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setColor") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -224,11 +302,14 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "Set Saturation command test", + "Set ColorTemperature command test", function() test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = { 200 } } }) @@ -247,7 +328,10 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -260,7 +344,10 @@ test.register_coroutine_test( ColorControl.commands.MoveToColorTemperature(mock_device, 200, 0) } ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua index 39f8a49f74..cfbfa42b50 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -30,7 +19,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "ROBB smarrt", model = "ROB_200-011-0", - server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0008, 0x0702, 0x0B04, 0x0B05 }, + server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0008, 0x0702, 0x0B04 }, client_clusters = { 0x0019 } } } @@ -39,9 +28,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + mock_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -53,11 +41,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -69,11 +68,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -90,7 +100,38 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 9.0, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Power meter", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 90) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 9.0, unit = "W" })) + } + }, + { + min_api_version = 17 } ) @@ -109,6 +150,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15.0, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua index 852854bc9f..1bfa694b5b 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -30,7 +19,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "ROBB smarrt", model = "ROB_200-014-0", - server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0008, 0x0702, 0x0B04, 0x0B05 }, + server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0008, 0x0702, 0x0B04 }, client_clusters = { 0x0019 } } } @@ -39,9 +28,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + mock_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -53,11 +41,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -69,11 +68,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -90,7 +100,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 9.0, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -109,6 +130,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15.0, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua index c528e22c92..80241b4ca9 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua @@ -1,20 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 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 capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff @@ -43,6 +33,70 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes and refresh device", function() @@ -84,8 +138,14 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } ) test.register_message_test( @@ -119,10 +179,67 @@ test.register_message_test( mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) + } } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 20 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17, + max_api_version = 19 } ) @@ -150,7 +267,57 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 17 + } +) + +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 +local REPORTED_MIRED_MIN = 160 +local REPORTED_MIRED_MAX = 370 + +test.register_coroutine_test( + "Step Color Temperature command with device-reported mired range test", + function() + -- Report non-default range values to verify subsequent step commands do not use defaults. + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_attr_report(mock_device, REPORTED_MIRED_MAX)}) + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_attr_report(mock_device, REPORTED_MIRED_MIN)}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2703, maximum = 6250}))) + test.wait_for_events() + + test.socket.capability:__queue_receive({mock_device.id, { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } }) + mock_device:expect_native_cmd_handler_registration("statelessColorTemperatureStep", "stepColorTemperatureByPercent") + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 42, TRANSITION_TIME, REPORTED_MIRED_MIN, REPORTED_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + } + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Step Level command test", + function() + test.socket.capability:__queue_receive({mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + } + ) + test.wait_for_events() + end, + { + min_api_version = 19 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua index 27839c9d5c..aa4e56653c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua @@ -1,27 +1,16 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" +local version = require "version" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff local Level = clusters.Level local IASZone = clusters.IASZone -local IasEnrollResponseCode = IASZone.types.EnrollResponseCode local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("on-off-level-motion-sensor.yml"), @@ -31,7 +20,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "sengled", model = "E13-N11", - server_clusters = { 0x0006, 0x0008, 0x0500 } + server_clusters = { 0x0006, 0x0008, 0x0500 }, + profile_id = 0xC05E } } } @@ -76,16 +66,11 @@ test.register_coroutine_test( mock_device.id, IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 30, 300, 1) }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.attributes.IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) - }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -96,7 +81,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -107,7 +95,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -116,6 +107,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) @@ -125,7 +117,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -134,6 +129,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device) }) @@ -143,7 +139,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -152,6 +151,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57 } } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) @@ -161,7 +161,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -176,7 +179,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) @@ -192,7 +206,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } + } + }, + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua index 01dbff5e48..4de1a70303 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local base64 = require "st.base64" local cluster_base = require "st.zigbee.cluster_base" @@ -45,9 +34,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -84,7 +71,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -116,7 +107,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( "infochanged to check for necessary preferences settings or updated when ledIntensity and minimalIntensity preference settings are zero with swBuild > 106", @@ -151,7 +146,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -183,7 +182,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -209,7 +212,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(600, data_types.Uint16, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -231,7 +238,11 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -267,7 +278,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -299,7 +314,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_message_test( @@ -339,6 +358,9 @@ test.register_message_test( Basic.attributes.ApplicationVersion:read(mock_device) } }, + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua old mode 100755 new mode 100644 index 8e7416b721..9d9b33df99 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -42,9 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -68,7 +55,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(device_info_copy.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -91,7 +82,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(device_info_copy.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -114,7 +109,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(device_info_copy.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua index 444f417220..4ff3fb2afa 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -35,10 +24,43 @@ local mock_device = test.mock_device.build_test_zigbee_device( } ) + +local mock_aurora_relay_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("switch-power.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Aurora", + model = "Smart16ARelay51AU", + server_clusters = {0x0006, 0x0B04, 0x0702} + } + } + } +) + +local mock_vimar_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("switch-power.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Vimar", + model = "Mains_Power_Outlet_v1.0", + server_clusters = {0x0006, 0x0B04, 0x0702} + } + } + } +) + + zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_aurora_relay_device) + test.mock_device.add_test_device(mock_vimar_device) end test.set_test_init_function(test_init) @@ -71,13 +93,25 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send( { mock_device.id, - ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 1, 3600, 5) + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) } ) test.socket.zigbee:__expect_send( { mock_device.id, - SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 1, 3600, 5) + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) } ) @@ -89,7 +123,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.Divisor:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.Multiplier:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -116,6 +153,22 @@ test.register_message_test( ElectricalMeasurement.attributes.ActivePower:read(mock_device) } }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + } + }, { channel = "zigbee", direction = "send", @@ -126,8 +179,142 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device :aurora relay", + function() + test.socket.device_lifecycle:__queue_receive({ mock_aurora_relay_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_aurora_relay_device.id, + zigbee_test_utils.build_bind_request(mock_aurora_relay_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_aurora_relay_device.id, + zigbee_test_utils.build_bind_request(mock_aurora_relay_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_aurora_relay_device.id, + zigbee_test_utils.build_bind_request(mock_aurora_relay_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_aurora_relay_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_aurora_relay_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_aurora_relay_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_aurora_relay_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send( + { + mock_aurora_relay_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_aurora_relay_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_aurora_relay_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_aurora_relay_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_aurora_relay_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_aurora_relay_device, 5, 3600, 5) + } + ) + + test.socket.zigbee:__expect_send({ mock_aurora_relay_device.id, OnOff.attributes.OnOff:read(mock_aurora_relay_device) }) + test.socket.zigbee:__expect_send({ mock_aurora_relay_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_aurora_relay_device) }) + test.socket.zigbee:__expect_send({ mock_aurora_relay_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_aurora_relay_device) }) + test.socket.zigbee:__expect_send({ mock_aurora_relay_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_aurora_relay_device) }) + test.socket.zigbee:__expect_send({ mock_aurora_relay_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_aurora_relay_device) }) + mock_aurora_relay_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device : vimar", + function() + test.socket.device_lifecycle:__queue_receive({ mock_vimar_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_vimar_device.id, + zigbee_test_utils.build_bind_request(mock_vimar_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_vimar_device.id, + zigbee_test_utils.build_bind_request(mock_vimar_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_vimar_device.id, + zigbee_test_utils.build_bind_request(mock_vimar_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_vimar_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_vimar_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_vimar_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_vimar_device, 1, 15, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_vimar_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_vimar_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send( + { + mock_vimar_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_vimar_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_vimar_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_vimar_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_vimar_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_vimar_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send({ + mock_vimar_device.id, + zigbee_test_utils.build_bind_request(mock_vimar_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ mock_vimar_device.id, OnOff.attributes.OnOff:read(mock_vimar_device) }) + test.socket.zigbee:__expect_send({ mock_vimar_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_vimar_device) }) + test.socket.zigbee:__expect_send({ mock_vimar_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_vimar_device) }) + test.socket.zigbee:__expect_send({ mock_vimar_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_vimar_device) }) + test.socket.zigbee:__expect_send({ mock_vimar_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_vimar_device) }) + mock_vimar_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua new file mode 100644 index 0000000000..ac3a78a982 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua @@ -0,0 +1,85 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local BasicCluster = clusters.Basic +local OnOffCluster = clusters.OnOff +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local profile = t_utils.get_profile_definition("basic-switch.yml") + +local mock_device = test.mock_device.build_test_zigbee_device({ + label = "Zigbee Switch", + profile = profile, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZ123fas", + server_clusters = { 0x0006 }, + }, + [2] = { + id = 2, + manufacturer = "_TZ123fas", + server_clusters = { 0x0006 }, + }, + }, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "lifecycle configure event should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300):to_endpoint(1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300):to_endpoint(2) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOffCluster.ID, 1):to_endpoint(1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOffCluster.ID, 2):to_endpoint(2) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_attribute_read(mock_device, BasicCluster.ID, {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe}) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua new file mode 100644 index 0000000000..b6b5adade5 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua @@ -0,0 +1,125 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local OnOff = clusters.OnOff +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local mock_simple_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("switch-3.yml"), + fingerprinted_endpoint_id = 0x01 + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_simple_device)end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Reported on off status should be handled: on ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_simple_device, + true):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled: on ep 2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_simple_device, + true):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("switch2", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) +test.register_message_test( + "Reported on off status should be handled: on ep 3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_simple_device, + true):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("switch3", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled: off ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_simple_device, + false):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability command switch on should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "switch", component = "switch3", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_simple_device.id, OnOff.server.commands.On(mock_simple_device):to_endpoint(0x03) } + } + }, + { + min_api_version = 17 + } +) + + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua index 24bb33b10f..8bbe6bad68 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local clusters = require "st.zigbee.zcl.clusters" @@ -129,6 +118,23 @@ local mock_seventh_child = test.mock_device.build_test_child_device( } ) +-- Single button device matching WALL HERO fingerprint (used to test button capability events) +local mock_button_device = test.mock_device.build_test_zigbee_device( + { + label = "WALL HERO Switch 1", + profile = scene_switch_profile_def, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "WALL HERO", + model = "ACL-401S1I", + server_clusters = { 0x0003, 0x0004, 0x0005, 0x0006 } + } + }, + fingerprinted_endpoint_id = 0x01 + } +) + zigbee_test_utils.prepare_zigbee_env_info() local function test_init() @@ -141,7 +147,7 @@ local function test_init() test.mock_device.add_test_device(mock_fifth_child) test.mock_device.add_test_device(mock_sixth_child) test.mock_device.add_test_device(mock_seventh_child) - zigbee_test_utils.init_noop_health_check_timer() + test.mock_device.add_test_device(mock_button_device) end test.set_test_init_function(test_init) @@ -165,7 +171,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -187,7 +204,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_first_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -209,7 +237,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_second_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -231,7 +270,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_third_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -253,7 +303,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -275,7 +336,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_first_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -297,7 +369,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_second_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -319,7 +402,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_third_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -332,7 +426,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_parent_device.id, cluster_base.write_manufacturer_specific_attribute(mock_parent_device, 0x0006, 0x6000, 0x1235, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -344,7 +441,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_parent_device.id, cluster_base.write_manufacturer_specific_attribute(mock_parent_device, 0x0006, 0x6000, 0x1235, data_types.Uint8, 0x00) }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -360,11 +460,22 @@ test.register_message_test( direction = "receive", message = { mock_parent_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 17 } ) @@ -376,11 +487,22 @@ test.register_message_test( direction = "receive", message = { mock_first_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) @@ -392,11 +514,22 @@ test.register_message_test( direction = "receive", message = { mock_second_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 17 } ) @@ -408,11 +541,22 @@ test.register_message_test( direction = "receive", message = { mock_third_child.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 17 } ) @@ -425,11 +569,22 @@ test.register_message_test( direction = "receive", message = { mock_parent_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 17 } ) @@ -441,11 +596,22 @@ test.register_message_test( direction = "receive", message = { mock_first_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) @@ -457,11 +623,22 @@ test.register_message_test( direction = "receive", message = { mock_second_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 17 } ) @@ -473,11 +650,22 @@ test.register_message_test( direction = "receive", message = { mock_third_child.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_third_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 17 } ) @@ -492,7 +680,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_fourth_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -505,7 +696,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_fifth_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -518,7 +712,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_sixth_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -531,7 +728,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_seventh_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -592,7 +792,24 @@ test.register_coroutine_test( mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(0x01) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "device_added lifecycle event should emit button capability events for button device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_button_device.id, "added" }) + test.socket.capability:__expect_send(mock_button_device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_button_device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua index eea20eb184..8a0db9d65c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -49,6 +38,9 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) @@ -80,12 +72,136 @@ test.register_coroutine_test( ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 20 + } ) test.register_message_test( @@ -122,7 +238,9 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17, + max_api_version = 19 } ) @@ -150,7 +268,10 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua new file mode 100644 index 0000000000..5f29f49c2b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua @@ -0,0 +1,445 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local OnOff = clusters.OnOff + +local common_switch_profile_def = t_utils.get_profile_definition("basic-switch.yml") + + +local mock_parent_device = test.mock_device.build_test_zigbee_device( + { + profile = common_switch_profile_def, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "JNL", + model = "Y-K003-001", + server_clusters = { 0003,0004,0005,0006 } + } + }, + fingerprinted_endpoint_id = 0x01 + } +) + +-- Switch 2 (Common Switch) +local mock_first_child = test.mock_device.build_test_child_device( + { + profile = common_switch_profile_def, + device_network_id = string.format("%04X:%02X", mock_parent_device:get_short_address(), 2), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 2) + } +) + +-- Switch 3 (Common Switch) +local mock_second_child = test.mock_device.build_test_child_device( + { + profile = common_switch_profile_def, + device_network_id = string.format("%04X:%02X", mock_parent_device:get_short_address(), 3), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 3) + } +) + + + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_first_child) + test.mock_device.add_test_device(mock_second_child) +end + +test.set_test_init_function(test_init) + +-- 4 Common Switch Tests +test.register_message_test( + "Reported on off status should be handled by parent device: on", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + true) :from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled by first child device: on", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_first_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + true) :from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_first_child:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled by Second child device: on", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_second_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + true) :from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_second_child:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "reported on off status should be handled by parent device: off", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + false) :from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.off()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled by first child device: off", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_first_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + false) :from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_first_child:generate_test_message("main", capabilities.switch.switch.off()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled by Second child device: off", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_second_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, + false) :from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_second_child:generate_test_message("main", capabilities.switch.switch.off()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Capability on command switch on should be handled : parent device", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "capability", + direction = "receive", + message = { mock_parent_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : first child device", + { + { + channel = "capability", + direction = "receive", + message = { mock_first_child.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : second child device", + { + { + channel = "capability", + direction = "receive", + message = { mock_second_child.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } + } + }, + { + min_api_version = 17 + } +) + + + + +test.register_message_test( + "Capability off command switch off should be handled : parent device", + { + { + channel = "capability", + direction = "receive", + message = { mock_parent_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_parent_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : first child device", + { + { + channel = "capability", + direction = "receive", + message = { mock_first_child.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_first_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : second child device", + { + { + channel = "capability", + direction = "receive", + message = { mock_second_child.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_second_child.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_dimmer_power_energy.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_dimmer_power_energy.lua deleted file mode 100644 index 3e4d1a64cc..0000000000 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_dimmer_power_energy.lua +++ /dev/null @@ -1,157 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local test = require "integration_test" -local clusters = require "st.zigbee.zcl.clusters" -local OnOffCluster = clusters.OnOff -local LevelCluster = clusters.Level -local SimpleMeteringCluster = clusters.SimpleMetering -local capabilities = require "st.capabilities" -local t_utils = require "integration_test.utils" - -local mock_device = test.mock_device.build_test_zigbee_device({ - profile = t_utils.get_profile_definition("switch-dimmer-power-energy.yml"), - zigbee_endpoints = { - [1] = { - id = 1, - server_clusters = { 0x0000, 0x0003, 0x0004, 0x0005, 0x0006, 0x0008, 0x0702, 0x0B05 }, - client_clusters = { 0x000A, 0x0019 } - } - } -}) - -local function test_init() - test.mock_device.add_test_device(mock_device) -end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Capability command On should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, OnOffCluster.server.commands.On(mock_device) } - } - } -) - -test.register_message_test( - "Capability command Off should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, OnOffCluster.server.commands.Off(mock_device) } - } - } -) - -test.register_message_test( - "Capability command setLevel should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57, 0 } } } - }, - { - channel = "zigbee", - direction = "send", - message = { - mock_device.id, - LevelCluster.server.commands.MoveToLevelWithOnOff(mock_device, math.floor(57 * 254 / 100)) - } - } - } -) - -test.register_message_test( - "Handle Switch Level", - { - { - channel = "zigbee", - direction = "receive", - message = { - mock_device.id, - LevelCluster.attributes.CurrentLevel:build_test_attr_report(mock_device, math.floor(57 / 100 * 254)) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(57)) - } - } -) - -test.register_message_test( - "Handle Power meter, Sensor value is in kW, capability attribute value is in W", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, SimpleMeteringCluster.attributes.Divisor:build_test_attr_report(mock_device, 0x0A) } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, SimpleMeteringCluster.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 0x14D) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 33300.0, unit = "W" })) - } - } -) - -test.register_message_test( - "Handle Energy meter", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, SimpleMeteringCluster.attributes.Multiplier:build_test_attr_report(mock_device, 1) } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, SimpleMeteringCluster.attributes.Divisor:build_test_attr_report(mock_device, 0x2710) } - }, - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, SimpleMeteringCluster.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 0x15B3) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.5555, unit = "kWh" })) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua index 430cc3e3d5..d2b73dd4b1 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -37,9 +26,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -52,11 +39,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -68,11 +66,22 @@ test.register_message_test( direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = { } } } }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + } + }, { channel = "zigbee", direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -89,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 9.766, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -104,7 +116,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 9.766, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -121,6 +144,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.009766, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua index 147aa452c8..e265fa13dc 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua @@ -1,22 +1,13 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local OnOff = clusters.OnOff local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" @@ -28,7 +19,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "DAWON_DNS", model = "PM-B430-ZB", - server_clusters = {} + server_clusters = { 0x0000, 0x0002, 0x0003, 0x0006, 0x0702, 0x0B04 } } } } @@ -36,9 +27,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -75,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 0.042, unit = "kWh"})) } + }, + { + min_api_version = 17 } ) @@ -91,7 +83,78 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 32.0, unit = "W" })) } + }, + { + min_api_version = 17 } ) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua index 16b0e50976..a616744baf 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -35,9 +24,7 @@ local mock_simple_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_simple_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_simple_device)end test.set_test_init_function(test_init) @@ -60,6 +47,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, OnOff.server.commands.On(mock_simple_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) @@ -81,6 +71,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, OnOff.server.commands.Off(mock_simple_device):to_endpoint(0x02) } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua index 4b1b22031a..f6842b077f 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua @@ -1,26 +1,22 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" +local version = require "version" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff local Level = clusters.Level local ColorControl = clusters.ColorControl +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 +local DEFAULT_MIRED_MIN = 154 +local DEFAULT_MIRED_MAX = 370 + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("color-temp-bulb.yml"), fingerprinted_endpoint_id = 0x01, @@ -29,7 +25,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "IKEA of Sweden", model = "TRADFRI bulb E26 WS clear 950lm", - server_clusters = { 0x0006, 0x0008, 0x0300 } + server_clusters = { 0x0006, 0x0008, 0x0300 }, + profile_id = 0xC05E, } } } @@ -51,7 +48,76 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(50000) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") + end, + min_api_version = 20 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(50000) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") + end, + min_api_version = 17, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -60,13 +126,60 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) test.wait_for_events() test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Switch command on should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Switch command off should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -75,13 +188,39 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device)}) test.wait_for_events() test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "SwitchLevel command setLevel should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = {50} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end + test.socket.zigbee:__expect_send({ mock_device.id, Level.commands.MoveToLevelWithOnOff(mock_device, math.floor(50 / 100.0 * 254), 0xFFFF)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -90,13 +229,18 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = {50} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end test.socket.zigbee:__expect_send({ mock_device.id, Level.commands.MoveToLevelWithOnOff(mock_device, math.floor(50 / 100.0 * 254), 0xFFFF)}) test.wait_for_events() test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -105,6 +249,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {200} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.commands.MoveToColorTemperature(mock_device, 5000, 0x0000)}) test.wait_for_events() @@ -112,7 +257,102 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "ColorTemperature command setColorTemperature should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {200} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.commands.MoveToColorTemperature(mock_device, 5000, 0x0000)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "StatelessColorTemperatureStep stepColorTemperatureByPercent should trigger delayed refresh on ZLL device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } }) + mock_device:expect_native_cmd_handler_registration("statelessColorTemperatureStep", "stepColorTemperatureByPercent") + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 43, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Rapid StatelessSwitchLevelStep stepLevel commands should cancel and recreate delayed refresh timer", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + test.wait_for_events() + test.mock_time.advance_time(1) + + -- Second step command: cancels timer #1, creates timer #2 + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + test.wait_for_events() + test.mock_time.advance_time(1) + -- now, nothing should happen since the first timer was cancelled and the second timer has not yet reached its 2s delay + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua index 3034ffdf84..2e688c0a86 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -29,7 +18,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "Leviton", model = "DL6HD", - server_clusters = { 0x0006, 0x0008 } + server_clusters = { 0x0006, 0x0008 }, + profile_id = 0xC05E, } } } @@ -75,7 +65,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -83,7 +76,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -93,7 +89,76 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(5*60, "interval", "polling") + end, + min_api_version = 17 + } ) test.register_coroutine_test( @@ -104,7 +169,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua index 0096214586..ec10a66a62 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua @@ -1,25 +1,19 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" +local version = require "version" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff local Level = clusters.Level +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("on-off-level.yml"), fingerprinted_endpoint_id = 0x01, @@ -28,7 +22,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "AduroSmart Eria", model = "ZLL-DimmableLight", - server_clusters = { 0x0006, 0x0008 } + server_clusters = { 0x0006, 0x0008 }, + profile_id = 0xC05E, } } } @@ -70,7 +65,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -80,7 +78,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -90,7 +91,32 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(50000) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") + end, + min_api_version = 17 + } ) test.register_coroutine_test( @@ -99,6 +125,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) @@ -107,7 +134,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -116,6 +146,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device) }) @@ -124,7 +155,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -133,6 +167,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57 } } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) @@ -141,7 +176,33 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "StatelessSwitchLevelStep stepLevel should trigger delayed refresh on ZLL device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua index 0de846a0dd..bbbda5660d 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua @@ -1,21 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" +local version = require "version" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff @@ -34,7 +24,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "IKEA of Sweden", model = "TRADFRI bulb E27 CWS opal 600lm", - server_clusters = { 0x0006, 0x0008, 0x0300 } + server_clusters = { 0x0006, 0x0008, 0x0300 }, + profile_id = 0xC05E, } } } @@ -92,7 +83,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -104,7 +98,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -116,7 +113,34 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(50000) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") + end, + min_api_version = 17 + } ) test.register_coroutine_test( @@ -125,6 +149,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) @@ -135,7 +160,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -144,6 +172,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device) }) @@ -154,7 +183,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -163,6 +195,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57 } } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) @@ -173,97 +206,168 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) -local test_data = { - { hue = 75, saturation = 65, x = 0x3E51, y = 0x255D }, - { hue = 75, saturation = nil, x = 0x500F, y = 0x543B } -} +test.register_coroutine_test( + "Set Hue command test (saturation 65)", + function() -for _, data in ipairs(test_data) do - test.register_coroutine_test( - "Set Hue command test", - function() - if data.saturation ~= nil then - test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentSaturation:build_test_attr_report(mock_device, math.ceil(data.saturation / 100 * 0xFE))}) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(data.saturation))) - end - - test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.socket.capability:__queue_receive({mock_device.id, - { - capability = "colorControl", - component = "main", - command = "setHue", - args = { data.hue } } - } - ) + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentSaturation:build_test_attr_report(mock_device, math.ceil(65 / 100 * 0xFE))}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) + mock_device:expect_native_attr_handler_registration("colorControl", "saturation") - test.wait_for_events() - test.mock_time.advance_time(0.2) + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setHue", + args = { 75 } } + } + ) - test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - ColorControl.commands.MoveToColor(mock_device, data.x, data.y, 0x0000) - } - ) + test.wait_for_events() + test.mock_time.advance_time(0.2) - test.wait_for_events() - test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x3E51, 0x255D, 0x0000) + } + ) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end - ) -end + test.wait_for_events() + test.mock_time.advance_time(2) -test_data = { - { hue = 75, saturation = 65, x = 0x3E51, y = 0x255D }, - { hue = nil, saturation = 65, x = 0x86EF, y = 0x5465 } -} -for _, data in ipairs(test_data) do - test.register_coroutine_test( - "Set Saturation command test", - function() - if data.hue ~= nil then - test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentHue:build_test_attr_report(mock_device, math.ceil(data.hue / 100 * 0xFE))}) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(data.hue))) - end - - test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.socket.capability:__queue_receive({mock_device.id, - { - capability = "colorControl", - component = "main", - command = "setSaturation", - args = { data.saturation } } - } - ) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 19 + } +) - test.wait_for_events() - test.mock_time.advance_time(0.2) +test.register_coroutine_test( + "Set Hue command test (saturation nil)", + function() - test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - ColorControl.commands.MoveToColor(mock_device, data.x, data.y, 0x0000) - } - ) + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setHue", + args = { 75 } } + } + ) - test.wait_for_events() - test.mock_time.advance_time(2) + test.wait_for_events() + test.mock_time.advance_time(0.2) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end - ) -end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x500F, 0x543B, 0x0000) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set Saturation command test (hue 75)", + function() + + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentHue:build_test_attr_report(mock_device, math.ceil(75 / 100 * 0xFE))}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) + mock_device:expect_native_attr_handler_registration("colorControl", "hue") + + + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setSaturation", + args = { 65 } } + } + ) + + test.wait_for_events() + test.mock_time.advance_time(0.2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x3E51, 0x255D, 0x0000) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Set Saturation command test (nil hue)", + function() + + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setSaturation", + args = { 65 } } + } + ) + + test.wait_for_events() + test.mock_time.advance_time(0.2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x86EF, 0x5465, 0x0000) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 17 + } +) test.register_coroutine_test( "Set Hue/Saturation command test", @@ -300,7 +404,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -338,7 +445,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -362,7 +472,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -374,7 +487,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -386,7 +502,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -398,7 +517,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -410,7 +532,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua index f2d8c5f2f4..4e2ad43b06 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua @@ -1,20 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" +local version = require "version" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff @@ -29,7 +19,8 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "AduroSmart Eria", model = "ZLL-ExtendedColor", - server_clusters = { 0x0006, 0x0008, 0x0300 } + server_clusters = { 0x0006, 0x0008, 0x0300 }, + profile_id = 0xC05E, } } } @@ -43,6 +34,78 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) + + test.register_coroutine_test( "Configure should configure all necessary attributes and refresh device", function() @@ -92,8 +155,32 @@ test.register_coroutine_test( ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16) } ) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -106,7 +193,91 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(5*60, "interval", "polling") + end, + min_api_version = 20 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(5*60, "interval", "polling") + end, + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability 'switch' command 'on' should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -115,6 +286,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) @@ -126,7 +298,39 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability 'switch' command 'off' should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -135,6 +339,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device) }) @@ -146,7 +351,39 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability 'switchLevel' command 'setLevel' on should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57 } } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end + + test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -155,6 +392,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57 } } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) @@ -166,7 +404,40 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + + end, + { + min_api_version = 17, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "ColorTemperature command setColorTemperature should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {200} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") end + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.commands.MoveToColorTemperature(mock_device, 5000, 0x0000)}) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -175,6 +446,7 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {200} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") end test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.commands.MoveToColorTemperature(mock_device, 5000, 0x0000)}) @@ -187,7 +459,12 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + + end, + { + min_api_version = 17, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -197,6 +474,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setHue") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -211,7 +489,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -221,6 +502,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setSaturation") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) local saturation = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -235,7 +517,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -245,6 +530,7 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } }) + mock_device:expect_native_cmd_handler_registration("colorControl", "setColor") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) @@ -261,7 +547,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua new file mode 100644 index 0000000000..c4878c95c2 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_multi_endpoint(device) + local main_endpoint = device:get_endpoint(0x0006) + for _, ep in ipairs(device.zigbee_endpoints) do + if ep.id ~= main_endpoint then + return true + end + end + return false +end + +return function(opts, driver, device) + local TUYA_MFR_HEADER = "_TZ" + if string.sub(device:get_manufacturer(),1,3) == TUYA_MFR_HEADER and is_multi_endpoint(device) then -- if it is a tuya device, then send the magic packet + local subdriver = require("tuya-multi") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua b/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua new file mode 100644 index 0000000000..51c91ad768 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua @@ -0,0 +1,54 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local zcl_messages = require "st.zigbee.zcl" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local read_attribute = require "st.zigbee.zcl.global_commands.read_attribute" + +local function read_attribute_function(device, cluster_id, attr_id) + local read_body = read_attribute.ReadAttribute( attr_id ) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(read_attribute.ReadAttribute.ID) + }) + local addrh = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(clusters.Basic.ID), + zb_const.HA_PROFILE_ID, + clusters.Basic.ID + ) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = read_body + }) + return messages.ZigbeeMessageTx({ + address_header = addrh, + body = message_body + }) +end + +local do_configure = function(self, device) + device:refresh() + device:configure() + local magic_spell = {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe} + device:send(read_attribute_function(device, clusters.Basic.ID, magic_spell)) +end + + +local tuya_switch_handler = { + NAME = "Tuya Switch Handler", + lifecycle_handlers = { + doConfigure = do_configure + }, + supported_capabilities = { + capabilities.switch + }, + can_handle = require("tuya-multi.can_handle"), +} + +return tuya_switch_handler diff --git a/drivers/SmartThings/zigbee-switch/src/wallhero/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/wallhero/can_handle.lua new file mode 100644 index 0000000000..69bcf73fce --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/wallhero/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "wallhero.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("wallhero") + return true, subdriver + end + end + return false +end + diff --git a/drivers/SmartThings/zigbee-switch/src/wallhero/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/wallhero/fingerprints.lua new file mode 100644 index 0000000000..9d58e6457a --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/wallhero/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "WALL HERO", model = "ACL-401S4I", switches = 4, buttons = 0 }, + { mfr = "WALL HERO", model = "ACL-401S8I", switches = 4, buttons = 4 }, + { mfr = "WALL HERO", model = "ACL-401S3I", switches = 3, buttons = 0 }, + { mfr = "WALL HERO", model = "ACL-401S2I", switches = 2, buttons = 0 }, + { mfr = "WALL HERO", model = "ACL-401S1I", switches = 1, buttons = 0 }, + { mfr = "WALL HERO", model = "ACL-401ON", switches = 1, buttons = 0 } +} diff --git a/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua b/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua index f7bd0406b7..5ad97bfb29 100644 --- a/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local log = require "log" @@ -18,32 +7,15 @@ local stDevice = require "st.device" local zcl_clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" +local configurations = require "configurations" local Scenes = zcl_clusters.Scenes local PRIVATE_CLUSTER_ID = 0x0006 local PRIVATE_ATTRIBUTE_ID = 0x6000 local MFG_CODE = 0x1235 -local FINGERPRINTS = { - { mfr = "WALL HERO", model = "ACL-401S4I", switches = 4, buttons = 0 }, - { mfr = "WALL HERO", model = "ACL-401S8I", switches = 4, buttons = 4 }, - { mfr = "WALL HERO", model = "ACL-401S3I", switches = 3, buttons = 0 }, - { mfr = "WALL HERO", model = "ACL-401S2I", switches = 2, buttons = 0 }, - { mfr = "WALL HERO", model = "ACL-401S1I", switches = 1, buttons = 0 }, - { mfr = "WALL HERO", model = "ACL-401ON", switches = 1, buttons = 0 } -} - -local function can_handle_wallhero_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("wallhero") - return true, subdriver - end - end - return false -end - local function get_children_info(device) + local FINGERPRINTS = require "wallhero.fingerprints" for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then return fingerprint.switches, fingerprint.buttons @@ -57,7 +29,7 @@ end local function create_child_devices(driver, device) local switch_amount, button_amount = get_children_info(device) - local base_name = device.label:sub(1, device.label:find(" ")) + local base_name = string.sub(device.label, 0, -2) -- Create Switch 2-4 for i = 2, switch_amount, 1 do if find_child(device, i) == nil then @@ -130,7 +102,7 @@ local wallheroswitch = { NAME = "Zigbee Wall Hero Switch", lifecycle_handlers = { added = device_added, - init = device_init, + init = configurations.reconfig_wrapper(device_init), infoChanged = device_info_changed }, zigbee_handlers = { @@ -140,7 +112,7 @@ local wallheroswitch = { } } }, - can_handle = can_handle_wallhero_switch + can_handle = require("wallhero.can_handle"), } return wallheroswitch diff --git a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/can_handle.lua new file mode 100644 index 0000000000..d45b7b745f --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local WHITE_COLOR_TEMP_BULB_FINGERPRINTS = require "white-color-temp-bulb.fingerprints" + local can_handle = (WHITE_COLOR_TEMP_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] + if can_handle then + local subdriver = require("white-color-temp-bulb") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/can_handle.lua new file mode 100644 index 0000000000..b3b8bd591b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local DURAGREEN_BULB_FINGERPRINTS = { + ["DURAGREEN"] = { + ["DG-CW-02"] = true, + ["DG-CW-01"] = true, + ["DG-CCT-01"] = true + }, + } + local res = (DURAGREEN_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] or false + if res then + return res, require("white-color-temp-bulb.duragreen") + else + return res + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/init.lua b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/init.lua index 7df79be32e..b6f96842c3 100644 --- a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/duragreen/init.lua @@ -1,34 +1,11 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local Level = clusters.Level -local DURAGREEN_BULB_FINGERPRINTS = { - ["DURAGREEN"] = { - ["DG-CW-02"] = true, - ["DG-CW-01"] = true, - ["DG-CCT-01"] = true - }, -} - -local function can_handle_duragreen_bulb(opts, driver, device) - return (DURAGREEN_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] or false -end - local function handle_set_level(driver, device, cmd) local level = math.floor(cmd.args.level/100.0 * 254) local transtition_time = cmd.args.rate or 0xFFFF @@ -46,7 +23,7 @@ local duragreen_color_temp_bulb = { [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level } }, - can_handle = can_handle_duragreen_bulb + can_handle = require("white-color-temp-bulb.duragreen.can_handle") } return duragreen_color_temp_bulb diff --git a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/fingerprints.lua new file mode 100644 index 0000000000..02bb28e88c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/fingerprints.lua @@ -0,0 +1,99 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + ["DURAGREEN"] = { + ["DG-CW-02"] = true, + ["DG-CW-01"] = true, + ["DG-CCT-01"] = true + }, + ["Samsung Electronics"] = { + ["ABL-LIGHT-Z-001"] = true, + ["SAMSUNG-ITM-Z-001"] = true + }, + ["Juno"] = { + ["ABL-LIGHT-Z-001"] = true + }, + ["AduroSmart Eria"] = { + ["AD-ColorTemperature3001"] = true + }, + ["Aurora"] = { + ["TWBulb51AU"] = true, + ["TWMPROZXBulb50AU"] = true, + ["TWStrip50AU"] = true, + ["TWGU10Bulb50AU"] = true, + ["TWCLBulb50AU"] = true + }, + ["CWD"] = { + ["ZB.A806Ecct-A001"] = true, + ["ZB.A806Bcct-A001"] = true, + ["ZB.M350cct-A001"] = true + }, + ["ETI"] = { + ["Zigbee CCT Downlight"] = true + }, + ["The Home Depot"] = { + ["Ecosmart-ZBT-BR30-CCT-Bulb"] = true, + ["Ecosmart-ZBT-A19-CCT-Bulb"] = true + }, + ["IKEA of Sweden"] = { + ["GUNNARP panel round"] = true, + ["LEPTITER Recessed spot light"] = true, + ["TRADFRI bulb E12 WS opal 600lm"] = true, + ["TRADFRI bulb E14 WS 470lm"] = true, + ["TRADFRI bulb E14 WS opal 600lm"] = true, + ["TRADFRI bulb E26 WS clear 806lm"] = true, + ["TRADFRI bulb E27 WS clear 806lm"] = true, + ["TRADFRI bulb E26 WS opal 1000lm"] = true, + ["TRADFRI bulb E27 WS opal 1000lm"] = true + }, + ["Megaman"] = { + ["Z3-ColorTemperature"] = true + }, + ["innr"] = { + ["RB 248 T"] = true, + ["RB 278 T"] = true, + ["RS 228 T"] = true + }, + ["OSRAM"] = { + ["LIGHTIFY BR Tunable White"] = true, + ["LIGHTIFY RT Tunable White"] = true, + ["Classic A60 TW"] = true, + ["LIGHTIFY A19 Tunable White"] = true, + ["Classic B40 TW - LIGHTIFY"] = true, + ["LIGHTIFY Conv Under Cabinet TW"] = true, + ["ColorstripRGBW"] = true, + ["LIGHTIFY Edge-lit Flushmount TW"] = true, + ["LIGHTIFY Surface TW"] = true, + ["LIGHTIFY Under Cabinet TW"] = true, + ["LIGHTIFY Edge-lit flushmount"] = true + }, + ["LEDVANCE"] = { + ["A19 TW 10 year"] = true, + ["MR16 TW"] = true, + ["BR30 TW"] = true, + ["RT TW"] = true + }, + ["Smarthome"] = { + ["S111-202A"] = true + }, + ["lk"] = { + ["ZBT-CCTLight-GLS0108"] = true + }, + ["MLI"] = { + ["ZBT-ColorTemperature"] = true + }, + ["sengled"] = { + ["Z01-A19NAE26"] = true, + ["Z01-A191AE26W"] = true, + ["Z01-A60EAB22"] = true, + ["Z01-A60EAE27"] = true + }, + ["Third Reality, Inc"] = { + ["3RSL011Z"] = true, + ["3RSL012Z"] = true + }, + ["Ajax Online"] = { + ["CCT"] = true + } +} diff --git a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/init.lua b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/init.lua index 70efb29afa..25bc13b65f 100644 --- a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -18,113 +7,6 @@ local colorTemperature_defaults = require "st.zigbee.defaults.colorTemperature_d local ColorControl = clusters.ColorControl -local WHITE_COLOR_TEMP_BULB_FINGERPRINTS = { - ["DURAGREEN"] = { - ["DG-CW-02"] = true, - ["DG-CW-01"] = true, - ["DG-CCT-01"] = true - }, - ["Samsung Electronics"] = { - ["ABL-LIGHT-Z-001"] = true, - ["SAMSUNG-ITM-Z-001"] = true - }, - ["Juno"] = { - ["ABL-LIGHT-Z-001"] = true - }, - ["AduroSmart Eria"] = { - ["AD-ColorTemperature3001"] = true - }, - ["Aurora"] = { - ["TWBulb51AU"] = true, - ["TWMPROZXBulb50AU"] = true, - ["TWStrip50AU"] = true, - ["TWGU10Bulb50AU"] = true, - ["TWCLBulb50AU"] = true - }, - ["CWD"] = { - ["ZB.A806Ecct-A001"] = true, - ["ZB.A806Bcct-A001"] = true, - ["ZB.M350cct-A001"] = true - }, - ["ETI"] = { - ["Zigbee CCT Downlight"] = true - }, - ["The Home Depot"] = { - ["Ecosmart-ZBT-BR30-CCT-Bulb"] = true, - ["Ecosmart-ZBT-A19-CCT-Bulb"] = true - }, - ["IKEA of Sweden"] = { - ["GUNNARP panel round"] = true, - ["LEPTITER Recessed spot light"] = true, - ["TRADFRI bulb E12 WS opal 600lm"] = true, - ["TRADFRI bulb E14 WS 470lm"] = true, - ["TRADFRI bulb E14 WS opal 600lm"] = true, - ["TRADFRI bulb E26 WS clear 806lm"] = true, - ["TRADFRI bulb E27 WS clear 806lm"] = true, - ["TRADFRI bulb E26 WS opal 1000lm"] = true, - ["TRADFRI bulb E27 WS opal 1000lm"] = true - }, - ["Megaman"] = { - ["Z3-ColorTemperature"] = true - }, - ["innr"] = { - ["RB 248 T"] = true, - ["RB 278 T"] = true, - ["RS 228 T"] = true - }, - ["OSRAM"] = { - ["LIGHTIFY BR Tunable White"] = true, - ["LIGHTIFY RT Tunable White"] = true, - ["Classic A60 TW"] = true, - ["LIGHTIFY A19 Tunable White"] = true, - ["Classic B40 TW - LIGHTIFY"] = true, - ["LIGHTIFY Conv Under Cabinet TW"] = true, - ["ColorstripRGBW"] = true, - ["LIGHTIFY Edge-lit Flushmount TW"] = true, - ["LIGHTIFY Surface TW"] = true, - ["LIGHTIFY Under Cabinet TW"] = true, - ["LIGHTIFY Edge-lit flushmount"] = true - }, - ["LEDVANCE"] = { - ["A19 TW 10 year"] = true, - ["MR16 TW"] = true, - ["BR30 TW"] = true, - ["RT TW"] = true - }, - ["Smarthome"] = { - ["S111-202A"] = true - }, - ["lk"] = { - ["ZBT-CCTLight-GLS0108"] = true - }, - ["MLI"] = { - ["ZBT-ColorTemperature"] = true - }, - ["sengled"] = { - ["Z01-A19NAE26"] = true, - ["Z01-A191AE26W"] = true, - ["Z01-A60EAB22"] = true, - ["Z01-A60EAE27"] = true - }, - ["Third Reality, Inc"] = { - ["3RSL011Z"] = true, - ["3RSL012Z"] = true - }, - ["Ajax Online"] = { - ["CCT"] = true - } -} - -local function can_handle_white_color_temp_bulb(opts, driver, device) - local can_handle = (WHITE_COLOR_TEMP_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] - if can_handle then - local subdriver = require("white-color-temp-bulb") - return true, subdriver - else - return false - end -end - local function set_color_temperature_handler(driver, device, cmd) colorTemperature_defaults.set_color_temperature(driver, device, cmd) @@ -140,10 +22,8 @@ local white_color_temp_bulb = { [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temperature_handler } }, - sub_drivers = { - require("white-color-temp-bulb.duragreen"), - }, - can_handle = can_handle_white_color_temp_bulb + sub_drivers = require("white-color-temp-bulb.sub_drivers"), + can_handle = require("white-color-temp-bulb.can_handle"), } return white_color_temp_bulb diff --git a/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/sub_drivers.lua new file mode 100644 index 0000000000..9beaf743bc --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/white-color-temp-bulb/sub_drivers.lua @@ -0,0 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" +return { + lazy_load("white-color-temp-bulb.duragreen"), +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/can_handle.lua new file mode 100644 index 0000000000..7b13c4b630 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_DIMMER_POWER_ENERGY_FINGERPRINTS = { + { mfr = "Jasco Products", model = "43082" } +} + +return function(opts, driver, device) + for _, fingerprint in ipairs(ZIGBEE_DIMMER_POWER_ENERGY_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("zigbee-dimmer-power-energy") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/enbrighten-metering-dimmer/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/enbrighten-metering-dimmer/init.lua deleted file mode 100644 index 936e04f150..0000000000 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/enbrighten-metering-dimmer/init.lua +++ /dev/null @@ -1,66 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local constants = require "st.zigbee.constants" -local SimpleMetering = clusters.SimpleMetering - -local ENBRIGHTEN_METERING_DIMMER_FINGERPRINTS = { - { mfr = "Jasco Products", model = "43082" } -} - -local is_enbrighten_metering_dimmer = function(opts, driver, device) - for _, fingerprint in ipairs(ENBRIGHTEN_METERING_DIMMER_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - -local device_init = function(self, device) - local customEnergyDivisor = 10000 - device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, customEnergyDivisor, {persist = true}) -end - -local do_configure = function(self, device) - device:refresh() - device:configure() -end - -local instantaneous_demand_handler = function(driver, device, value, zb_rx) - local raw_value = value.value - local divisor = 10 - raw_value = raw_value / divisor - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.powerMeter.power({value = raw_value, unit = "W" })) -end - -local enbrighten_metering_dimmer = { - NAME = "enbrighten metering dimmer", - zigbee_handlers = { - attr = { - [SimpleMetering.ID] = { - [SimpleMetering.attributes.InstantaneousDemand.ID] = instantaneous_demand_handler - } - } - }, - lifecycle_handlers = { - init = device_init, - doConfigure = do_configure - }, - can_handle = is_enbrighten_metering_dimmer -} - -return enbrighten_metering_dimmer diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua index f799a165aa..869678ef24 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua @@ -1,34 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local SimpleMetering = clusters.SimpleMetering -local ElectricalMeasurement = clusters.ElectricalMeasurement +local constants = require "st.zigbee.constants" +local configurations = require "configurations" -local ZIGBEE_DIMMER_POWER_ENERGY_FINGERPRINTS = { - { mfr = "Jasco Products", model = "43082" } -} - -local is_zigbee_dimmer_power_energy = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_DIMMER_POWER_ENERGY_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("zigbee-dimmer-power-energy") - return true, subdriver - end - end - return false +local device_init = function(self, device) + local customEnergyDivisor = 10000 + device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, customEnergyDivisor, {persist = true}) end local do_configure = function(self, device) @@ -41,24 +22,29 @@ local do_configure = function(self, device) device:send(SimpleMetering.attributes.Divisor:read(device)) device:send(SimpleMetering.attributes.Multiplier:read(device)) end +end - if device:supports_capability(capabilities.energyMeter) then - -- Divisor and multipler for EnergyMeter - device:send(ElectricalMeasurement.attributes.ACPowerDivisor:read(device)) - device:send(ElectricalMeasurement.attributes.ACPowerMultiplier:read(device)) - end +local instantaneous_demand_handler = function(driver, device, value, zb_rx) + local raw_value = value.value + local divisor = 10 + raw_value = raw_value / divisor + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.powerMeter.power({value = raw_value, unit = "W" })) end local zigbee_dimmer_power_energy_handler = { NAME = "zigbee dimmer power energy handler", + zigbee_handlers = { + attr = { + [SimpleMetering.ID] = { + [SimpleMetering.attributes.InstantaneousDemand.ID] = instantaneous_demand_handler + } + } + }, lifecycle_handlers = { + init = configurations.reconfig_wrapper(device_init), doConfigure = do_configure, }, - sub_drivers = { - require("zigbee-dimmer-power-energy/enbrighten-metering-dimmer") - }, - can_handle = is_zigbee_dimmer_power_energy - + can_handle = require("zigbee-dimmer-power-energy.can_handle"), } return zigbee_dimmer_power_energy_handler diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/can_handle.lua new file mode 100644 index 0000000000..cbbc8dfeab --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local DIMMING_LIGHT_FINGERPRINTS = require "zigbee-dimming-light.fingerprints" + for _, fingerprint in ipairs(DIMMING_LIGHT_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("zigbee-dimming-light") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/fingerprints.lua new file mode 100644 index 0000000000..820bd633b6 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/fingerprints.lua @@ -0,0 +1,36 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = "Vimar", model = "DimmerSwitch_v1.0"}, -- Vimar Smart Dimmer Switch + {mfr = "OSRAM", model = "LIGHTIFY A19 ON/OFF/DIM"}, -- SYLVANIA Smart A19 Soft White + {mfr = "OSRAM", model = "LIGHTIFY A19 ON/OFF/DIM 10 Year"}, -- SYLVANIA Smart 10-Year A19 + {mfr = "OSRAM SYLVANIA", model = "iQBR30"}, -- SYLVANIA Ultra iQ + {mfr = "OSRAM", model = "LIGHTIFY PAR38 ON/OFF/DIM"}, -- SYLVANIA Smart PAR38 Soft White + {mfr = "OSRAM", model = "LIGHTIFY BR ON/OFF/DIM"}, -- SYLVANIA Smart BR30 Soft White + {mfr = "sengled", model = "E11-G13"}, -- Sengled Element Classic + {mfr = "sengled", model = "E11-G14"}, -- Sengled Element Classic + {mfr = "sengled", model = "E11-G23"}, -- Sengled Element Classic + {mfr = "sengled", model = "E11-G33"}, -- Sengled Element Classic + {mfr = "sengled", model = "E12-N13"}, -- Sengled Element Classic + {mfr = "sengled", model = "E12-N14"}, -- Sengled Element Classic + {mfr = "sengled", model = "E12-N15"}, -- Sengled Element Classic + {mfr = "sengled", model = "E11-N13"}, -- Sengled Element Classic + {mfr = "sengled", model = "E11-N14"}, -- Sengled Element Classic + {mfr = "sengled", model = "E1A-AC2"}, -- Sengled DownLight + {mfr = "sengled", model = "E11-N13A"}, -- Sengled Extra Bright Soft White + {mfr = "sengled", model = "E11-N14A"}, -- Sengled Extra Bright Daylight + {mfr = "sengled", model = "E21-N13A"}, -- Sengled Soft White + {mfr = "sengled", model = "E21-N14A"}, -- Sengled Daylight + {mfr = "sengled", model = "E11-U21U31"}, -- Sengled Element Touch + {mfr = "sengled", model = "E13-A21"}, -- Sengled LED Flood Light + {mfr = "sengled", model = "E11-N1G"}, -- Sengled Smart LED Vintage Edison Bulb + {mfr = "sengled", model = "E23-N11"}, -- Sengled Element Classic par38 + {mfr = "Leviton", model = "DL6HD"}, -- Leviton Dimmer Switch + {mfr = "Leviton", model = "DL3HL"}, -- Leviton Lumina RF Plug-In Dimmer + {mfr = "Leviton", model = "DL1KD"}, -- Leviton Lumina RF Dimmer Switch + {mfr = "Leviton", model = "ZSD07"}, -- Leviton Lumina RF 0-10V Dimming Wall Switch + {mfr = "MRVL", model = "MZ100"}, + {mfr = "CREE", model = "Connected A-19 60W Equivalent"}, + {mfr = "Insta GmbH", model = "NEXENTRO Dimming Actuator"} +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua index 7395823a2e..880e947163 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua @@ -1,57 +1,14 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" +local configurations = require "configurations" +local switch_utils = require "switch_utils" local OnOff = clusters.OnOff local Level = clusters.Level -local DIMMING_LIGHT_FINGERPRINTS = { - {mfr = "Vimar", model = "DimmerSwitch_v1.0"}, -- Vimar Smart Dimmer Switch - {mfr = "OSRAM", model = "LIGHTIFY A19 ON/OFF/DIM"}, -- SYLVANIA Smart A19 Soft White - {mfr = "OSRAM", model = "LIGHTIFY A19 ON/OFF/DIM 10 Year"}, -- SYLVANIA Smart 10-Year A19 - {mfr = "OSRAM SYLVANIA", model = "iQBR30"}, -- SYLVANIA Ultra iQ - {mfr = "OSRAM", model = "LIGHTIFY PAR38 ON/OFF/DIM"}, -- SYLVANIA Smart PAR38 Soft White - {mfr = "OSRAM", model = "LIGHTIFY BR ON/OFF/DIM"}, -- SYLVANIA Smart BR30 Soft White - {mfr = "sengled", model = "E11-G13"}, -- Sengled Element Classic - {mfr = "sengled", model = "E11-G14"}, -- Sengled Element Classic - {mfr = "sengled", model = "E11-G23"}, -- Sengled Element Classic - {mfr = "sengled", model = "E11-G33"}, -- Sengled Element Classic - {mfr = "sengled", model = "E12-N13"}, -- Sengled Element Classic - {mfr = "sengled", model = "E12-N14"}, -- Sengled Element Classic - {mfr = "sengled", model = "E12-N15"}, -- Sengled Element Classic - {mfr = "sengled", model = "E11-N13"}, -- Sengled Element Classic - {mfr = "sengled", model = "E11-N14"}, -- Sengled Element Classic - {mfr = "sengled", model = "E1A-AC2"}, -- Sengled DownLight - {mfr = "sengled", model = "E11-N13A"}, -- Sengled Extra Bright Soft White - {mfr = "sengled", model = "E11-N14A"}, -- Sengled Extra Bright Daylight - {mfr = "sengled", model = "E21-N13A"}, -- Sengled Soft White - {mfr = "sengled", model = "E21-N14A"}, -- Sengled Daylight - {mfr = "sengled", model = "E11-U21U31"}, -- Sengled Element Touch - {mfr = "sengled", model = "E13-A21"}, -- Sengled LED Flood Light - {mfr = "sengled", model = "E11-N1G"}, -- Sengled Smart LED Vintage Edison Bulb - {mfr = "sengled", model = "E23-N11"}, -- Sengled Element Classic par38 - {mfr = "Leviton", model = "DL6HD"}, -- Leviton Dimmer Switch - {mfr = "Leviton", model = "DL3HL"}, -- Leviton Lumina RF Plug-In Dimmer - {mfr = "Leviton", model = "DL1KD"}, -- Leviton Lumina RF Dimmer Switch - {mfr = "Leviton", model = "ZSD07"}, -- Leviton Lumina RF 0-10V Dimming Wall Switch - {mfr = "MRVL", model = "MZ100"}, - {mfr = "CREE", model = "Connected A-19 60W Equivalent"}, - {mfr = "Insta GmbH", model = "NEXENTRO Dimming Actuator"} -} - local DIMMING_LIGHT_CONFIGURATION = { { cluster = OnOff.ID, @@ -73,38 +30,30 @@ local DIMMING_LIGHT_CONFIGURATION = { } } -local function can_handle_zigbee_dimming_light(opts, driver, device) - for _, fingerprint in ipairs(DIMMING_LIGHT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("zigbee-dimming-light") - return true, subdriver - end - end - return false +local function do_configure(driver, device) + device:refresh() + device:configure() end local function device_init(driver, device) for _,attribute in ipairs(DIMMING_LIGHT_CONFIGURATION) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end local function device_added(driver, device) - device:emit_event(capabilities.switchLevel.level(100)) + switch_utils.emit_event_if_latest_state_missing(device, "main", capabilities.switchLevel, capabilities.switchLevel.level.NAME, capabilities.switchLevel.level(100)) end local zigbee_dimming_light = { NAME = "Zigbee Dimming Light", lifecycle_handlers = { - init = device_init, - added = device_added - }, - sub_drivers = { - require("zigbee-dimming-light/osram-iqbr30"), - require("zigbee-dimming-light/zll-dimmer") + init = configurations.reconfig_wrapper(device_init), + added = device_added, + doConfigure = do_configure }, - can_handle = can_handle_zigbee_dimming_light + sub_drivers = require("zigbee-dimming-light.sub_drivers"), + can_handle = require("zigbee-dimming-light.can_handle"), } return zigbee_dimming_light diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/can_handle.lua new file mode 100644 index 0000000000..8463ce2a77 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local res = device:get_manufacturer() == "OSRAM SYLVANIA" and device:get_model() == "iQBR30" + if res then + return res, require("zigbee-dimming-light.osram-iqbr30") + end + return res +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/init.lua index c6d31b7e10..74d9acb903 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/osram-iqbr30/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,10 +9,6 @@ local Level = clusters.Level local SwitchLevel = capabilities.switchLevel -local function can_handle_osram_iqbr30(opts, driver, device, ...) - return device:get_manufacturer() == "OSRAM SYLVANIA" and device:get_model() == "iQBR30" -end - local function set_switch_level_handler(driver, device, cmd) local level = math.floor(cmd.args.level / 100.0 * 254) @@ -40,7 +25,7 @@ local osram_iqbr30 = { [SwitchLevel.commands.setLevel.NAME] = set_switch_level_handler } }, - can_handle = can_handle_osram_iqbr30 + can_handle = require("zigbee-dimming-light.osram-iqbr30.can_handle"), } return osram_iqbr30 diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/sub_drivers.lua new file mode 100644 index 0000000000..fa188a4e7e --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("zigbee-dimming-light.osram-iqbr30"), + lazy_load("zigbee-dimming-light.zll-dimmer") +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/can_handle.lua new file mode 100644 index 0000000000..d75b86b353 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local ZLL_DIMMER_FINGERPRINTS = require("zigbee-dimming-light.zll-dimmer.fingerprints") + for _, fingerprint in ipairs(ZLL_DIMMER_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-dimming-light.zll-dimmer") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/fingerprints.lua new file mode 100644 index 0000000000..1a5176bcd5 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = "Leviton", model = "DL6HD"}, -- Leviton Dimmer Switch + {mfr = "Leviton", model = "DL3HL"}, -- Leviton Lumina RF Plug-In Dimmer + {mfr = "Leviton", model = "DL1KD"}, -- Leviton Lumina RF Dimmer Switch + {mfr = "Leviton", model = "ZSD07"}, -- Leviton Lumina RF 0-10V Dimming Wall Switch + {mfr = "MRVL", model = "MZ100"}, + {mfr = "CREE", model = "Connected A-19 60W Equivalent"} +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/init.lua index 72b35fd580..039b049fc7 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/zll-dimmer/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -19,24 +8,6 @@ local Level = clusters.Level local SwitchLevel = capabilities.switchLevel -local ZLL_DIMMER_FINGERPRINTS = { - {mfr = "Leviton", model = "DL6HD"}, -- Leviton Dimmer Switch - {mfr = "Leviton", model = "DL3HL"}, -- Leviton Lumina RF Plug-In Dimmer - {mfr = "Leviton", model = "DL1KD"}, -- Leviton Lumina RF Dimmer Switch - {mfr = "Leviton", model = "ZSD07"}, -- Leviton Lumina RF 0-10V Dimming Wall Switch - {mfr = "MRVL", model = "MZ100"}, - {mfr = "CREE", model = "Connected A-19 60W Equivalent"} -} - -local function can_handle_zll_dimmer(opts, driver, device) - for _, fingerprint in ipairs(ZLL_DIMMER_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - local function set_switch_level_handler(driver, device, cmd) local level = math.floor(cmd.args.level / 100.0 * 254) @@ -51,7 +22,7 @@ local zll_dimmer = { [SwitchLevel.commands.setLevel.NAME] = set_switch_level_handler } }, - can_handle = can_handle_zll_dimmer + can_handle = require("zigbee-dimming-light.zll-dimmer.can_handle") } return zll_dimmer diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/can_handle.lua new file mode 100644 index 0000000000..fbc679fb18 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_DUAL_METERING_SWITCH_FINGERPRINT = { + {mfr = "Aurora", model = "DoubleSocket50AU"} +} + +return function(opts, driver, device, ...) + for _, fingerprint in ipairs(ZIGBEE_DUAL_METERING_SWITCH_FINGERPRINT) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("zigbee-dual-metering-switch") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua index 3a952cb32e..f88c6396f5 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua @@ -1,39 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local st_device = require "st.device" local clusters = require "st.zigbee.zcl.clusters" local OnOff = clusters.OnOff local ElectricalMeasurement = clusters.ElectricalMeasurement local utils = require "st.utils" +local configurations = require "configurations" local CHILD_ENDPOINT = 2 -local ZIGBEE_DUAL_METERING_SWITCH_FINGERPRINT = { - {mfr = "Aurora", model = "DoubleSocket50AU"} -} - -local function can_handle_zigbee_dual_metering_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(ZIGBEE_DUAL_METERING_SWITCH_FINGERPRINT) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("zigbee-dual-metering-switch") - return true, subdriver - end - end - return false -end - local function do_refresh(self, device) device:send(OnOff.attributes.OnOff:read(device)) device:send(ElectricalMeasurement.attributes.ActivePower:read(device)) @@ -76,10 +52,10 @@ local zigbee_dual_metering_switch = { } }, lifecycle_handlers = { - init = device_init, + init = configurations.reconfig_wrapper(device_init), added = device_added }, - can_handle = can_handle_zigbee_dual_metering_switch + can_handle = require("zigbee-dual-metering-switch.can_handle"), } return zigbee_dual_metering_switch diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/can_handle.lua new file mode 100644 index 0000000000..e6e3165aa3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local can_handle = device:get_manufacturer() == "DAWON_DNS" + if can_handle then + local subdriver = require("zigbee-metering-plug-power-consumption-report") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua index f2b9c09868..1845878f19 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua @@ -1,33 +1,16 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" -local constants = require "st.zigbee.constants" -local messages = require "st.zigbee.messages" -local bind_request = require "st.zigbee.zdo.bind_request" -local zdo_messages = require "st.zigbee.zdo" local zigbee_constants = require "st.zigbee.constants" +local energy_meter_defaults = require "st.zigbee.defaults.energyMeter_defaults" +local configurations = require "configurations" -local OnOff = clusters.OnOff local SimpleMetering = clusters.SimpleMetering local function energy_meter_handler(driver, device, value, zb_rx) local raw_value = value.value - local multiplier = device:get_field(constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 - local divisor = device:get_field(constants.SIMPLE_METERING_DIVISOR_KEY) or 1000 - local converted_value = raw_value * multiplier/divisor local delta_energy = 0.0 local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) @@ -36,29 +19,12 @@ local function energy_meter_handler(driver, device, value, zb_rx) end device:emit_event(capabilities.powerConsumptionReport.powerConsumption({energy = raw_value, deltaEnergy = delta_energy })) -- the unit of these values should be 'Wh' - device:emit_event(capabilities.energyMeter.energy({value = converted_value, unit = "kWh"})) -end - -local function build_bind_request(device, cluster, hub_zigbee_eui, src_endpoint) - local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, bind_request.BindRequest.ID) - local bind_req = bind_request.BindRequest(device.zigbee_eui, src_endpoint, cluster, bind_request.ADDRESS_MODE_64_BIT, hub_zigbee_eui, constants.HUB.ENDPOINT) - local message_body = zdo_messages.ZdoMessageBody({ - zdo_body = bind_req - }) - local bind_cmd = messages.ZigbeeMessageTx({ - address_header = addr_header, - body = message_body - }) - return bind_cmd + energy_meter_defaults.energy_meter_handler(driver, device, value, zb_rx) end local do_configure = function(self, device) device:configure() device:refresh() - for comp_id, comp in pairs(device.profile.components) do - local src_ep = device:get_endpoint_for_component_id(comp_id) - device:send(build_bind_request(device, OnOff.ID, self.environment_info.hub_zigbee_eui, src_ep)) - end end local device_init = function(self, device) @@ -66,7 +32,7 @@ local device_init = function(self, device) end local zigbee_metering_plug_power_conumption_report = { - NAME = "zigbee metering plug power conumption report", + NAME = "zigbee metering plug power consumption report", zigbee_handlers = { attr = { [SimpleMetering.ID] = { @@ -75,18 +41,10 @@ local zigbee_metering_plug_power_conumption_report = { } }, lifecycle_handlers = { - init = device_init, + init = configurations.reconfig_wrapper(device_init), doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - local can_handle = device:get_manufacturer() == "DAWON_DNS" - if can_handle then - local subdriver = require("zigbee-metering-plug-power-consumption-report") - return true, subdriver - else - return false - end - end + can_handle = require("zigbee-metering-plug-power-consumption-report.can_handle"), } return zigbee_metering_plug_power_conumption_report diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/can_handle.lua new file mode 100644 index 0000000000..18cff448c1 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(opts, driver, device) + local AURORA_RELAY_FINGERPRINTS = { + { mfr = "Aurora", model = "Smart16ARelay51AU" }, + { mfr = "Develco Products A/S", model = "Smart16ARelay51AU" }, + { mfr = "SALUS", model = "SX885ZB" } + } + for _, fingerprint in ipairs(AURORA_RELAY_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-switch-power.aurora-relay") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/init.lua index 0f8eac96bc..266ff9b4e7 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/aurora-relay/init.lua @@ -1,34 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local constants = require "st.zigbee.constants" -local AURORA_RELAY_FINGERPRINTS = { - { mfr = "Aurora", model = "Smart16ARelay51AU" }, - { mfr = "Develco Products A/S", model = "Smart16ARelay51AU" }, - { mfr = "SALUS", model = "SX885ZB" } -} - -local function can_handle_aurora_relay(opts, driver, device) - for _, fingerprint in ipairs(AURORA_RELAY_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - local function do_configure(driver, device) device:configure() @@ -43,7 +17,7 @@ local aurora_relay = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_aurora_relay + can_handle = require("zigbee-switch-power.aurora-relay.can_handle"), } return aurora_relay diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/can_handle.lua new file mode 100644 index 0000000000..ed4db3dc52 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local SWITCH_POWER_FINGERPRINTS = require "zigbee-switch-power.fingerprints" + for _, fingerprint in ipairs(SWITCH_POWER_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("zigbee-switch-power") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua new file mode 100644 index 0000000000..f393f61eea --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "Vimar", model = "Mains_Power_Outlet_v1.0" }, + { model = "PAN18-v1.0.7" }, + { model = "E210-KR210Z1-HA" }, + { mfr = "Aurora", model = "Smart16ARelay51AU" }, + { mfr = "Develco Products A/S", model = "Smart16ARelay51AU" }, + { mfr = "Jasco Products", model = "45853" }, + { mfr = "Jasco Products", model = "45856" }, + { mfr = "MEGAMAN", model = "SH-PSUKC44B-E" }, + { mfr = "ClimaxTechnology", model = "PSM_00.00.00.35TC" }, + { mfr = "SALUS", model = "SX885ZB" }, + { mfr = "AduroSmart Eria", model = "AD-SmartPlug3001" }, + { mfr = "AduroSmart Eria", model = "BPU3" }, + { mfr = "AduroSmart Eria", model = "BDP3001" }, + { mfr = "LEDVANCE", model = "PLUG COMPACT EU EM T" }, + { mfr = "LEDVANCE", model = "PLUG EU EM T" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/init.lua index d9ae8f4750..0ea2224a6f 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -19,32 +8,6 @@ local constants = require "st.zigbee.constants" local SimpleMetering = clusters.SimpleMetering local ElectricalMeasurement = clusters.ElectricalMeasurement -local SWITCH_POWER_FINGERPRINTS = { - { mfr = "Vimar", model = "Mains_Power_Outlet_v1.0" }, - { model = "PAN18-v1.0.7" }, - { model = "E210-KR210Z1-HA" }, - { mfr = "Aurora", model = "Smart16ARelay51AU" }, - { mfr = "Develco Products A/S", model = "Smart16ARelay51AU" }, - { mfr = "Jasco Products", model = "45853" }, - { mfr = "Jasco Products", model = "45856" }, - { mfr = "MEGAMAN", model = "SH-PSUKC44B-E" }, - { mfr = "ClimaxTechnology", model = "PSM_00.00.00.35TC" }, - { mfr = "SALUS", model = "SX885ZB" }, - { mfr = "AduroSmart Eria", model = "AD-SmartPlug3001" }, - { mfr = "AduroSmart Eria", model = "BPU3" }, - { mfr = "AduroSmart Eria", model = "BDP3001" } -} - -local function can_handle_zigbee_switch_power(opts, driver, device) - for _, fingerprint in ipairs(SWITCH_POWER_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - local subdriver = require("zigbee-switch-power") - return true, subdriver - end - end - return false -end - local function active_power_meter_handler(driver, device, value, zb_rx) local raw_value = value.value local divisor = device:get_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY) or 10 @@ -75,11 +38,8 @@ local zigbee_switch_power = { } } }, - sub_drivers = { - require("zigbee-switch-power/aurora-relay"), - require("zigbee-switch-power/vimar") - }, - can_handle = can_handle_zigbee_switch_power + sub_drivers = require("zigbee-switch-power.sub_drivers"), + can_handle = require("zigbee-switch-power.can_handle"), } return zigbee_switch_power diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/can_handle.lua new file mode 100644 index 0000000000..4ecb5ab271 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require("zigbee-switch-power.ledvance-metering-plug.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("zigbee-switch-power.ledvance-metering-plug") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/fingerprints.lua new file mode 100644 index 0000000000..50542e69ea --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/fingerprints.lua @@ -0,0 +1,7 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "LEDVANCE", model = "PLUG COMPACT EU EM T" }, + { mfr = "LEDVANCE", model = "PLUG EU EM T" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/init.lua new file mode 100644 index 0000000000..55e703a6d6 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/init.lua @@ -0,0 +1,23 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zigbee_constants = require "st.zigbee.constants" + +local function device_init(driver, device) + if device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == nil then + device:set_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY, 1, {persist = true}) + end + if device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil then + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 100, {persist = true}) + end +end + +local ledvance_metering_plug = { + NAME = "LEDVANCE Metering Plug", + lifecycle_handlers = { + init = device_init + }, + can_handle = require("zigbee-switch-power.ledvance-metering-plug.can_handle") +} + +return ledvance_metering_plug diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua new file mode 100644 index 0000000000..0c1ba5eeef --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("zigbee-switch-power.aurora-relay"), + lazy_load("zigbee-switch-power.vimar"), + lazy_load("zigbee-switch-power.ledvance-metering-plug") +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/can_handle.lua new file mode 100644 index 0000000000..8143fb6a8e --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(opts, driver, device) + local VIMAR_FINGERPRINTS = { + { mfr = "Vimar", model = "Mains_Power_Outlet_v1.0" } + } + for _, fingerprint in ipairs(VIMAR_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-switch-power.vimar") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/init.lua index ed65ce785b..82eb0674d4 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/vimar/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local constants = require "st.zigbee.constants" local device_management = require "st.zigbee.device_management" @@ -18,19 +7,6 @@ local zcl_clusters = require "st.zigbee.zcl.clusters" local ElectricalMeasurement = zcl_clusters.ElectricalMeasurement -local VIMAR_FINGERPRINTS = { - { mfr = "Vimar", model = "Mains_Power_Outlet_v1.0" } -} - -local function can_handle_vimar_switch_power(opts, driver, device) - for _, fingerprint in ipairs(VIMAR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - local function do_configure(driver, device) device:configure() device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1, {persist = true}) @@ -45,7 +21,7 @@ local vimar_switch_power = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_vimar_switch_power + can_handle = require("zigbee-switch-power.vimar.can_handle"), } return vimar_switch_power diff --git a/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/can_handle.lua new file mode 100644 index 0000000000..f2de6f2d81 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local ZLL_DIMMER_BULB_FINGERPRINTS = require "zll-dimmer-bulb.fingerprints" + local can_handle = (ZLL_DIMMER_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] + if can_handle then + local subdriver = require("zll-dimmer-bulb") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/fingerprints.lua new file mode 100644 index 0000000000..ab13464d62 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/fingerprints.lua @@ -0,0 +1,117 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + ["AduroSmart Eria"] = { + ["ZLL-DimmableLight"] = true, + ["ZLL-ExtendedColor"] = true, + ["ZLL-ColorTemperature"] = true + }, + ["IKEA of Sweden"] = { + ["TRADFRI bulb E26 opal 1000lm"] = true, + ["TRADFRI bulb E12 W op/ch 400lm"] = true, + ["TRADFRI bulb E17 W op/ch 400lm"] = true, + ["TRADFRI bulb GU10 W 400lm"] = true, + ["TRADFRI bulb E27 W opal 1000lm"] = true, + ["TRADFRI bulb E26 W opal 1000lm"] = true, + ["TRADFRI bulb E14 W op/ch 400lm"] = true, + ["TRADFRI transformer 10W"] = true, + ["TRADFRI Driver 10W"] = true, + ["TRADFRI transformer 30W"] = true, + ["TRADFRI Driver 30W"] = true, + ["TRADFRI bulb E26 WS clear 950lm"] = true, + ["TRADFRI bulb GU10 WS 400lm"] = true, + ["TRADFRI bulb E12 WS opal 400lm"] = true, + ["TRADFRI bulb E26 WS opal 980lm"] = true, + ["TRADFRI bulb E27 WS clear 950lm"] = true, + ["TRADFRI bulb E14 WS opal 400lm"] = true, + ["TRADFRI bulb E27 WS opal 980lm"] = true, + ["FLOALT panel WS 30x30"] = true, + ["FLOALT panel WS 30x90"] = true, + ["FLOALT panel WS 60x60"] = true, + ["SURTE door WS 38x64"] = true, + ["JORMLIEN door WS 40x80"] = true, + ["TRADFRI bulb E27 CWS opal 600lm"] = true, + ["TRADFRI bulb E26 CWS opal 600lm"] = true + }, + ["Eaton"] = { + ["Halo_RL5601"] = true + }, + ["Megaman"] = { + ["ZLL-DimmableLight"] = true, + ["ZLL-ExtendedColor"] = true + }, + ["MEGAMAN"] = { + ["BSZTM002"] = true, + ["BSZTM003"] = true + }, + ["innr"] = { + ["RS 125"] = true, + ["RB 165"] = true, + ["RB 175 W"] = true, + ["RB 145"] = true, + ["RS 128 T"] = true, + ["RB 178 T"] = true, + ["RB 148 T"] = true, + ["RB 185 C"] = true, + ["FL 130 C"] = true, + ["OFL 120 C"] = true, + ["OFL 140 C"] = true, + ["OSL 130 C"] = true + }, + ["Leviton"] = { + ["DG3HL"] = true, + ["DG6HD"] = true + }, + ["OSRAM"] = { + ["Classic A60 W clear"] = true, + ["Classic A60 W clear - LIGHTIFY"] = true, + ["CLA60 OFD OSRAM"] = true, + ["Classic A60 RGBW"] = true, + ["PAR 16 50 RGBW - LIGHTIFY"] = true, + ["CLA60 RGBW OSRAM"] = true, + ["Flex RGBW"] = true, + ["Gardenpole RGBW-Lightify"] = true, + ["LIGHTIFY Outdoor Flex RGBW"] = true, + ["LIGHTIFY Indoor Flex RGBW"] = true, + ["Classic B40 TW - LIGHTIFY"] = true, + ["CLA60 TW OSRAM"] = true + }, + ["Philips"] = { + ["LWB006"] = true, + ["LWB007"] = true, + ["LWB010"] = true, + ["LWB014"] = true, + ["LCT001"] = true, + ["LCT002"] = true, + ["LCT003"] = true, + ["LCT007"] = true, + ["LCT010"] = true, + ["LCT011"] = true, + ["LCT012"] = true, + ["LCT014"] = true, + ["LCT015"] = true, + ["LCT016"] = true, + ["LST001"] = true, + ["LST002"] = true, + ["LTW001"] = true, + ["LTW004"] = true, + ["LTW010"] = true, + ["LTW011"] = true, + ["LTW012"] = true, + ["LTW013"] = true, + ["LTW014"] = true, + ["LTW015"] = true + }, + ["sengled"] = { + ["E14-U43"] = true, + ["E13-N11"] = true + }, + ["GLEDOPTO"] = { + ["GL-C-008"] = true, + ["GL-B-001Z"] = true + }, + ["Ubec"] = { + ["BBB65L-HY"] = true + } +} diff --git a/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/init.lua b/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/init.lua index 6c74f9b89f..d6becf1467 100644 --- a/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zll-dimmer-bulb/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -19,131 +8,6 @@ local colorTemperature_defaults = require "st.zigbee.defaults.colorTemperature_d local OnOff = clusters.OnOff local Level = clusters.Level -local ZLL_DIMMER_BULB_FINGERPRINTS = { - ["AduroSmart Eria"] = { - ["ZLL-DimmableLight"] = true, - ["ZLL-ExtendedColor"] = true, - ["ZLL-ColorTemperature"] = true - }, - ["IKEA of Sweden"] = { - ["TRADFRI bulb E26 opal 1000lm"] = true, - ["TRADFRI bulb E12 W op/ch 400lm"] = true, - ["TRADFRI bulb E17 W op/ch 400lm"] = true, - ["TRADFRI bulb GU10 W 400lm"] = true, - ["TRADFRI bulb E27 W opal 1000lm"] = true, - ["TRADFRI bulb E26 W opal 1000lm"] = true, - ["TRADFRI bulb E14 W op/ch 400lm"] = true, - ["TRADFRI transformer 10W"] = true, - ["TRADFRI Driver 10W"] = true, - ["TRADFRI transformer 30W"] = true, - ["TRADFRI Driver 30W"] = true, - ["TRADFRI bulb E26 WS clear 950lm"] = true, - ["TRADFRI bulb GU10 WS 400lm"] = true, - ["TRADFRI bulb E12 WS opal 400lm"] = true, - ["TRADFRI bulb E26 WS opal 980lm"] = true, - ["TRADFRI bulb E27 WS clear 950lm"] = true, - ["TRADFRI bulb E14 WS opal 400lm"] = true, - ["TRADFRI bulb E27 WS opal 980lm"] = true, - ["FLOALT panel WS 30x30"] = true, - ["FLOALT panel WS 30x90"] = true, - ["FLOALT panel WS 60x60"] = true, - ["SURTE door WS 38x64"] = true, - ["JORMLIEN door WS 40x80"] = true, - ["TRADFRI bulb E27 CWS opal 600lm"] = true, - ["TRADFRI bulb E26 CWS opal 600lm"] = true - }, - ["Eaton"] = { - ["Halo_RL5601"] = true - }, - ["Megaman"] = { - ["ZLL-DimmableLight"] = true, - ["ZLL-ExtendedColor"] = true - }, - ["MEGAMAN"] = { - ["BSZTM002"] = true, - ["BSZTM003"] = true - }, - ["innr"] = { - ["RS 125"] = true, - ["RB 165"] = true, - ["RB 175 W"] = true, - ["RB 145"] = true, - ["RS 128 T"] = true, - ["RB 178 T"] = true, - ["RB 148 T"] = true, - ["RB 185 C"] = true, - ["FL 130 C"] = true, - ["OFL 120 C"] = true, - ["OFL 140 C"] = true, - ["OSL 130 C"] = true - }, - ["Leviton"] = { - ["DG3HL"] = true, - ["DG6HD"] = true - }, - ["OSRAM"] = { - ["Classic A60 W clear"] = true, - ["Classic A60 W clear - LIGHTIFY"] = true, - ["CLA60 OFD OSRAM"] = true, - ["Classic A60 RGBW"] = true, - ["PAR 16 50 RGBW - LIGHTIFY"] = true, - ["CLA60 RGBW OSRAM"] = true, - ["Flex RGBW"] = true, - ["Gardenpole RGBW-Lightify"] = true, - ["LIGHTIFY Outdoor Flex RGBW"] = true, - ["LIGHTIFY Indoor Flex RGBW"] = true, - ["Classic B40 TW - LIGHTIFY"] = true, - ["CLA60 TW OSRAM"] = true - }, - ["Philips"] = { - ["LWB006"] = true, - ["LWB007"] = true, - ["LWB010"] = true, - ["LWB014"] = true, - ["LCT001"] = true, - ["LCT002"] = true, - ["LCT003"] = true, - ["LCT007"] = true, - ["LCT010"] = true, - ["LCT011"] = true, - ["LCT012"] = true, - ["LCT014"] = true, - ["LCT015"] = true, - ["LCT016"] = true, - ["LST001"] = true, - ["LST002"] = true, - ["LTW001"] = true, - ["LTW004"] = true, - ["LTW010"] = true, - ["LTW011"] = true, - ["LTW012"] = true, - ["LTW013"] = true, - ["LTW014"] = true, - ["LTW015"] = true - }, - ["sengled"] = { - ["E14-U43"] = true, - ["E13-N11"] = true - }, - ["GLEDOPTO"] = { - ["GL-C-008"] = true, - ["GL-B-001Z"] = true - }, - ["Ubec"] = { - ["BBB65L-HY"] = true - } -} - -local function can_handle_zll_dimmer_bulb(opts, driver, device) - local can_handle = (ZLL_DIMMER_BULB_FINGERPRINTS[device:get_manufacturer()] or {})[device:get_model()] - if can_handle then - local subdriver = require("zll-dimmer-bulb") - return true, subdriver - else - return false - end -end - local function do_configure(driver, device) device:configure() end @@ -204,10 +68,7 @@ local zll_dimmer_bulb = { [capabilities.colorTemperature.commands.setColorTemperature.NAME] = handle_set_color_temperature } }, - sub_drivers = { - require("zll-dimmer-bulb/ikea-xy-color-bulb") - }, - can_handle = can_handle_zll_dimmer_bulb + can_handle = require("zll-dimmer-bulb.can_handle"), } return zll_dimmer_bulb diff --git a/drivers/SmartThings/zigbee-switch/src/zll-polling/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zll-polling/can_handle.lua new file mode 100644 index 0000000000..c39ee387e3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zll-polling/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, zb_rx, ...) + local constants = require "st.zigbee.constants" + + local endpoint = device.zigbee_endpoints[device.fingerprinted_endpoint_id] or device.zigbee_endpoints[tostring(device.fingerprinted_endpoint_id)] + if (endpoint ~= nil and endpoint.profile_id == constants.ZLL_PROFILE_ID) then + local subdriver = require("zll-polling") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua b/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua new file mode 100644 index 0000000000..814e0f70ce --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua @@ -0,0 +1,69 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local device_lib = require "st.device" +local clusters = require "st.zigbee.zcl.clusters" +local configurationMap = require "configurations" + +local INFREQUENT_POLL_COUNTER = "_infrequent_poll_counter" +local ZLL_POLL_TIMER = "_zll_poll_timer" + +local function do_zll_poll(device) + if device == nil or type(device.get_field) ~= "function" then + return + end + + local infrequent_counter = device:get_field(INFREQUENT_POLL_COUNTER) or 1 + if infrequent_counter == 12 then + -- do a full refresh once an hour + device:refresh() + infrequent_counter = 0 + else + -- Read On/Off every poll + for _, ep in pairs(device.zigbee_endpoints) do + if device:supports_server_cluster(clusters.OnOff.ID, ep.id) then + device:send(clusters.OnOff.attributes.OnOff:read(device):to_endpoint(ep.id)) + end + end + infrequent_counter = infrequent_counter + 1 + end + device:set_field(INFREQUENT_POLL_COUNTER, infrequent_counter) +end + +local function set_up_zll_polling(driver, device) + -- only set this up for non-child devices + if device.network_type ~= device_lib.NETWORK_TYPE_ZIGBEE then + return + end + + -- should never happen, but defensive check + local existing_timer = device:get_field(ZLL_POLL_TIMER) + if existing_timer ~= nil then + device.thread:cancel_timer(existing_timer) + end + + local timer = device.thread:call_on_schedule(5 * 60, function() + do_zll_poll(device) + end, "zll_polling") + + device:set_field(ZLL_POLL_TIMER, timer) +end + +local function remove_zll_polling(driver, device) + local existing_timer = device:get_field(ZLL_POLL_TIMER) + if existing_timer ~= nil then + device.thread:cancel_timer(existing_timer) + device:set_field(ZLL_POLL_TIMER, nil) + end +end + +local ZLL_polling = { + NAME = "ZLL Polling", + lifecycle_handlers = { + init = configurationMap.reconfig_wrapper(set_up_zll_polling), + removed = remove_zll_polling + }, + can_handle = require("zll-polling.can_handle"), +} + +return ZLL_polling diff --git a/drivers/SmartThings/zigbee-thermostat/capabilities/invisibleCapabilities.yaml b/drivers/SmartThings/zigbee-thermostat/capabilities/invisibleCapabilities.yaml new file mode 100644 index 0000000000..b95ff0e002 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/capabilities/invisibleCapabilities.yaml @@ -0,0 +1,18 @@ +id: stse.invisibleCapabilities +version: 1 +status: proposed +name: invisible capabilities +ephemeral: false +attributes: + invisibleCapabilities: + schema: + type: object + properties: + value: + type: array + items: + type: string + additionalProperties: false + required: [] + enumCommands: [] +commands: {} diff --git a/drivers/SmartThings/zigbee-thermostat/capabilities/valveCalibration.yaml b/drivers/SmartThings/zigbee-thermostat/capabilities/valveCalibration.yaml new file mode 100644 index 0000000000..c3ebe041c9 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/capabilities/valveCalibration.yaml @@ -0,0 +1,24 @@ +id: stse.valveCalibration +version: 1 +status: proposed +name: Valve calibration +ephemeral: false +attributes: + calibrationState: + schema: + type: object + properties: + value: + type: string + enum: + - calibrationSuccess + - calibrationFailure + - calibrationPending + additionalProperties: false + required: + - value + enumCommands: [] +commands: + startCalibration: + name: startCalibration + arguments: [] diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index b8793b0043..efa89be552 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -124,6 +124,21 @@ zigbeeManufacturer: manufacturer: Resideo Korea model: DT300ST-M000 deviceProfileName: thermostat-resideo-dt300st-m000 + - id: "Resideo Korea/MC200ST" + deviceLabel: Valve Controller + manufacturer: Resideo Korea + model: MC200ST + deviceProfileName: thermostat-resideo-dt300st-m000 + - id: "LUMI/lumi.airrtc.agl001" + deviceLabel: Aqara Smart Radiator Thermostat E1 + manufacturer: LUMI + model: lumi.airrtc.agl001 + deviceProfileName: thermostat-aqara + - id: "WALL HERO/ACL-403STC1" + deviceLabel: WALLHERO Thermostat ACL-403STC1 + manufacturer: WALL HERO + model: ACL-403STC1 + deviceProfileName: thermostat-thirty-buttons-wallhero zigbeeGeneric: - id: "genericThermostat" deviceLabel: Zigbee Thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-aqara.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-aqara.yml new file mode 100644 index 0000000000..35cfe7e464 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-aqara.yml @@ -0,0 +1,40 @@ +name: thermostat-aqara +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: valve + version: 1 + - id: stse.valveCalibration + version: 1 + - id: hardwareFault + version: 1 + - id: battery + version: 1 + - id: stse.invisibleCapabilities + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +- id: ChildLock + capabilities: + - id: lock + version: 1 + categories: + - name: SmartLock +preferences: + - preferenceId: stse.antifreezeModeSetting + explicit: true + - preferenceId: stse.notificationOfValveTest + explicit: true +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_Thermostat_E1 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-thirty-buttons-wallhero.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-thirty-buttons-wallhero.yml new file mode 100644 index 0000000000..5a7bff92a8 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-thirty-buttons-wallhero.yml @@ -0,0 +1,249 @@ +name: thermostat-thirty-buttons-wallhero +components: + - id: main + label: 空调(AC) + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: temperatureSetpoint + version: 1 + - id: thermostatMode + version: 1 + - id: fanMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: fan + label: 新风(Fresh Air) + capabilities: + - id: switch + version: 1 + - id: fanMode + version: 1 + categories: + - name: Fan + - id: heat + label: 地暖(Floor Heating) + capabilities: + - id: switch + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + categories: + - name: Thermostat + - id: button1 + label: 回家(Home) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + label: 离家(Leave Home) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + label: 晨起(Morning) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + label: 睡眠(Sleep) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + label: 阅读(Reading) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + label: 观影(Movie) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + label: 全开(All On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + label: 全关(All Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button9 + label: 空调温度调高(AC Temp Up) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button10 + label: 空调温度降低(AC Temp Down) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button11 + label: 开窗帘(Open Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button12 + label: 关窗帘(Close Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button13 + label: 空调开(AC On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button14 + label: 空调关(AC Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button15 + label: 无风开(No Wind On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button16 + label: 无风关(No Wind Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button17 + label: 制冷(Cooling) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button18 + label: 制热(Heating) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button19 + label: 新风开(Fresh Air On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button20 + label: 新风关(Fresh Air Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button21 + label: 开电视(Turn On TV) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button22 + label: 关电视(Turn Off TV) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button23 + label: 开音乐(Play Music) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button24 + label: 关音乐(Stop Music) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button25 + label: 开纱帘(Open Screen Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button26 + label: 关纱帘(Close Screen Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button27 + label: 洗衣服(Wash Clothes) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button28 + label: 衣服护理(Clothes Care) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button29 + label: 用餐(Dining) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button30 + label: 开大门(Open Main Door) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/zigbee-thermostat/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-thermostat/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..30f1243e76 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.airrtc.agl001" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua b/drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua new file mode 100644 index 0000000000..b17b3d9e1c --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua @@ -0,0 +1,262 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.zigbee.data_types" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local capabilities = require "st.capabilities" +local Thermostat = clusters.Thermostat +local PowerConfiguration = clusters.PowerConfiguration + +local invisibleCapabilities = capabilities["stse.invisibleCapabilities"] +local valveCalibration = capabilities["stse.valveCalibration"] +local valveCalibrationCommandName = "startCalibration" + +local PRIVATE_CLUSTER_ID = 0xFCC0 +local MFG_CODE = 0x115F +local PRIVATE_VALVE_CALIBRATION_ID = 0x0270 +local PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID = 0x0271 +local PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID = 0x0272 +local PRIVATE_THERM0STAT_VALVE_DETECTION_SWITCH_ID = 0x0274 +local PRIVATE_THERMOSTAT_ALARM_INFORMATION_ID = 0x0275 +local PRIVATE_CHILD_LOCK_ID = 0x0277 +local PRIVATE_ANTIFREEZE_MODE_TEMPERATURE_SETTING_ID = 0x0279 +local PRIVATE_VALVE_RESULT_CALIBRATION_ID = 0x027B +local PRIVATE_BATTERY_ENERGY_ID = 0x040A + + +local preference_map = { + ["stse.notificationOfValveTest"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = PRIVATE_THERM0STAT_VALVE_DETECTION_SWITCH_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + value_map = { [true] = 0x01,[false] = 0x00 }, + }, + ["stse.antifreezeModeSetting"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = PRIVATE_ANTIFREEZE_MODE_TEMPERATURE_SETTING_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint32, + }, +} + +local function value_conversion(value) + return tonumber(value)*50 + 450 +end + +local function device_info_changed(driver, device, event, 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.value_map ~= nil then + value = attr.value_map[value] + end + if preferences[id] == device.preferences["stse.antifreezeModeSetting"] then + device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, + attr.mfg_code, attr.data_type, value_conversion(value))) + end + if preferences[id] == device.preferences["stse.notificationOfValveTest"] then + device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, + attr.mfg_code, attr.data_type, value)) + end + end + end + end +end + + +local function supported_thermostat_modes_handler(driver, device, value) + device:emit_event(capabilities.thermostatMode.supportedThermostatModes({ + capabilities.thermostatMode.thermostatMode.manual.NAME, + capabilities.thermostatMode.thermostatMode.antifreezing.NAME + }, { visibility = { displayed = false } })) +end + +local function do_refresh(driver, device) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, MFG_CODE)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_VALVE_RESULT_CALIBRATION_ID, MFG_CODE)) +end + +local function device_init(driver, device) + do_refresh(driver, device) +end + +local function emit_component_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component.id, capability.ID, attribute_name) == nil then + device:emit_component_event(component, value) + end +end + +local function device_added(driver, device) + supported_thermostat_modes_handler(driver, device, nil) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 21.0, unit = "C"})) + device:emit_event(capabilities.temperatureMeasurement.temperature({value = 27.0, unit = "C"})) + device:emit_event(capabilities.thermostatMode.thermostatMode.manual()) + device:emit_event(capabilities.hardwareFault.hardwareFault.clear()) + device:emit_event(valveCalibration.calibrationState.calibrationPending()) + device:emit_event(invisibleCapabilities.invisibleCapabilities({""})) + device:emit_event(capabilities.battery.battery(100)) + emit_component_event_if_latest_state_missing(device, device.profile.components.main, capabilities.valve, capabilities.valve.valve.NAME, capabilities.valve.valve.open()) + emit_component_event_if_latest_state_missing(device, device.profile.components.ChildLock, capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) +end + +local function thermostat_alarm_status_handler(driver, device, value, zb_rx) + if value.value == 1 then + device:emit_event(capabilities.hardwareFault.hardwareFault.detected()) + elseif value.value == 0 then + device:emit_event(capabilities.hardwareFault.hardwareFault.clear()) + end +end + +local function valve_calibration_status_handler(driver, device, value, zb_rx) + if value.value == 0 then + device:emit_event(valveCalibration.calibrationState.calibrationPending()) + elseif value.value == 1 then + device:emit_event(valveCalibration.calibrationState.calibrationSuccess()) + elseif value.value == 2 then + device:emit_event(valveCalibration.calibrationState.calibrationFailure()) + end +end + +local function thermostat_operating_mode_status_handler(driver, device, value, zb_rx) + if value.value == 0 or value.value == 3 then + device:emit_event(capabilities.thermostatMode.thermostatMode.manual()) + device:emit_event(invisibleCapabilities.invisibleCapabilities({""})) + elseif value.value == 2 then + device:emit_event(capabilities.thermostatMode.thermostatMode.antifreezing()) + device:emit_event(invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint"})) + end +end + +local function valve_status_handler(driver, device, value, zb_rx) + local thermostat_mode = device:get_latest_state("main", capabilities.thermostatMode.ID,capabilities.thermostatMode.thermostatMode.NAME) + + if value.value == 1 then + if thermostat_mode == 'manual' then + device:emit_event(capabilities.valve.valve.open()) + device:emit_event(invisibleCapabilities.invisibleCapabilities({""})) + elseif thermostat_mode == 'antifreezing' then + device:emit_event(capabilities.valve.valve.open()) + device:emit_event(invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint"})) + end + elseif value.value == 0 then + device:emit_event(capabilities.valve.valve.closed()) + device:emit_event(invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint","stse.valveCalibration","thermostatMode","lock"})) + end +end + +local function child_switch_status_handler(driver, device, value, zb_rx) + if value.value == 1 then + device:emit_component_event(device.profile.components.ChildLock, capabilities.lock.lock.locked()) + elseif value.value == 0 then + device:emit_component_event(device.profile.components.ChildLock, capabilities.lock.lock.unlocked()) + end +end + +local function thermostat_mode_attr_handler(driver, device, command) + local set_mode = command.args.mode + local pre_mode = device:get_latest_state("main", capabilities.thermostatMode.ID,capabilities.thermostatMode.thermostatMode.NAME) + + if pre_mode ~= set_mode then + if set_mode == 'manual' then + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x00)) + elseif set_mode == 'antifreezing' then + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x02)) + end + else + if set_mode == 'manual' then + device:emit_event(capabilities.thermostatMode.thermostatMode.manual()) + elseif set_mode == 'antifreezing' then + device:emit_event(capabilities.thermostatMode.thermostatMode.antifreezing()) + end + end +end + +local function battery_energy_status_handler(driver, device, value, zb_rx) + device:emit_event(capabilities.battery.battery(value.value)) +end + +local function open_heating_attr_handler(driver, device, command) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) +end + +local function close_heating_attr_handler(driver, device, command) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x00)) +end + +local function valve_calibration_attr_handler(driver, device, command) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_VALVE_CALIBRATION_ID, MFG_CODE, data_types.Uint8, 0x01)) +end + +local function child_switch_on_attr_handler(driver, device, command) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_CHILD_LOCK_ID, MFG_CODE, data_types.Uint8, 0x01)) +end + +local function child_switch_off_attr_handler(driver, device, command) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_CHILD_LOCK_ID, MFG_CODE, data_types.Uint8, 0x00)) +end + +local aqara_radiator_thermostat_e1_handler = { + NAME = "Aqara Smart Radiator Thermostat E1 Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = device_info_changed + }, + zigbee_handlers = { + attr = { + [PRIVATE_CLUSTER_ID] = { + [PRIVATE_THERMOSTAT_ALARM_INFORMATION_ID] = thermostat_alarm_status_handler, + [PRIVATE_VALVE_RESULT_CALIBRATION_ID] = valve_calibration_status_handler, + [PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID] = thermostat_operating_mode_status_handler, + [PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID] = valve_status_handler, + [PRIVATE_CHILD_LOCK_ID] = child_switch_status_handler, + [PRIVATE_BATTERY_ENERGY_ID] = battery_energy_status_handler + }, + [Thermostat.ID] = { + [Thermostat.attributes.ControlSequenceOfOperation.ID] = supported_thermostat_modes_handler + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryVoltage.ID] = function() end, + [PowerConfiguration.attributes.BatteryAlarmState.ID] = function() end + } + } + }, + capability_handlers = { + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = thermostat_mode_attr_handler + }, + [capabilities.valve.ID] = { + [capabilities.valve.commands.open.NAME] = open_heating_attr_handler, + [capabilities.valve.commands.close.NAME] = close_heating_attr_handler + }, + [valveCalibration.ID] = { + [valveCalibrationCommandName] = valve_calibration_attr_handler + }, + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = child_switch_on_attr_handler, + [capabilities.lock.commands.unlock.NAME] = child_switch_off_attr_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + can_handle = require("aqara.can_handle"), +} + +return aqara_radiator_thermostat_e1_handler diff --git a/drivers/SmartThings/zigbee-thermostat/src/danfoss/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/danfoss/can_handle.lua new file mode 100644 index 0000000000..2f9ba97b72 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/danfoss/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_danfoss_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("danfoss.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("danfoss") + end + end + return false +end + +return is_danfoss_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/danfoss/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/danfoss/fingerprints.lua new file mode 100644 index 0000000000..26386b21a9 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/danfoss/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local DANFOSS_THERMOSTAT_FINGERPRINTS = { + { mfr = "Danfoss", model = "eTRV0100" } +} + +return DANFOSS_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua b/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua index c5b181955f..f5b0d5434c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua @@ -1,19 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local PowerConfiguration = clusters.PowerConfiguration -local DANFOSS_THERMOSTAT_FINGERPRINTS = { - { mfr = "Danfoss", model = "eTRV0100" } -} -local is_danfoss_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(DANFOSS_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local danfoss_thermostat = { NAME = "Danfoss Thermostat Handler", @@ -27,7 +19,7 @@ local danfoss_thermostat = { lifecycle_handlers = { init = battery_defaults.build_linear_voltage_init(2.4, 3.2) }, - can_handle = is_danfoss_thermostat + can_handle = require("danfoss.can_handle"), } -return danfoss_thermostat \ No newline at end of file +return danfoss_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/fidure/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/fidure/can_handle.lua new file mode 100644 index 0000000000..24ee0ea0e2 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/fidure/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function fidure_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Fidure" and device:get_model() == "A1732R3" then + return true, require("fidure") + end + return false +end + +return fidure_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua b/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua index 8b46c65e73..5d87089aa1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" @@ -38,9 +28,7 @@ local fidure_thermostat = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Fidure" and device:get_model() == "A1732R3" - end + can_handle = require("fidure.can_handle"), } return fidure_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/init.lua b/drivers/SmartThings/zigbee-thermostat/src/init.lua index 6602c1c3db..cfbbd76a0c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/init.lua @@ -1,28 +1,19 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Zigbee Driver utilities -local ZigbeeDriver = require "st.zigbee" -local device_management = require "st.zigbee.device_management" -local defaults = require "st.zigbee.defaults" -local utils = require "st.utils" +local ZigbeeDriver = require "st.zigbee" +local device_management = require "st.zigbee.device_management" +local defaults = require "st.zigbee.defaults" +local utils = require "st.utils" -- Zigbee Spec Utils local clusters = require "st.zigbee.zcl.clusters" local PowerConfiguration = clusters.PowerConfiguration local Thermostat = clusters.Thermostat local FanControl = clusters.FanControl +local TemperatureMeasurement = clusters.TemperatureMeasurement local FanMode = FanControl.attributes.FanMode local FanModeSequence = FanControl.attributes.FanModeSequence @@ -31,7 +22,7 @@ local ThermostatControlSequence = Thermostat.attributes.ControlSequenceOfOperati -- Capabilities local capabilities = require "st.capabilities" -local TemperatureMeasurement = capabilities.temperatureMeasurement +local Temperature = capabilities.temperatureMeasurement local ThermostatCoolingSetpoint = capabilities.thermostatCoolingSetpoint local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local ThermostatMode = capabilities.thermostatMode @@ -50,7 +41,9 @@ local THERMOSTAT_MODE_MAP = { [ThermostatSystemMode.AUTO] = ThermostatMode.thermostatMode.auto, [ThermostatSystemMode.COOL] = ThermostatMode.thermostatMode.cool, [ThermostatSystemMode.HEAT] = ThermostatMode.thermostatMode.heat, - [ThermostatSystemMode.EMERGENCY_HEATING] = ThermostatMode.thermostatMode.emergency_heat + [ThermostatSystemMode.EMERGENCY_HEATING] = ThermostatMode.thermostatMode.emergency_heat, + [ThermostatSystemMode.DRY] = ThermostatMode.thermostatMode.dryair, + [ThermostatSystemMode.FAN_ONLY] = ThermostatMode.thermostatMode.fanonly } local FAN_MODE_MAP = { @@ -91,6 +84,12 @@ local SUPPORTED_THERMOSTAT_MODES = { ThermostatMode.thermostatMode.emergency_heat.NAME} } +-- TemperatureMeasurement cluster defaults +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + local battery_voltage_handler = function(driver, device, battery_voltage) if (battery_voltage.value == 0) then -- this means we're plugged in device:emit_event(PowerSource.powerSource.mains()) @@ -164,6 +163,40 @@ local thermostat_mode_setter = function(mode_name) end end +local setpoint_limit_handler_factory = function(min_or_max, heat_or_cool) + + local field = 'setpoint_' .. min_or_max .. '_' .. heat_or_cool + local paired_field = 'setpoint_min_' .. heat_or_cool + if min_or_max == 'min' then + paired_field = 'setpoint_max_' .. heat_or_cool + end + + return function(driver, device, setpoint) + local celsius_value = setpoint.value / 100.0 + device:set_field(field, celsius_value) + if device:get_field(field) and device:get_field(paired_field) then + + local event_constructor = capabilities.thermostatHeatingSetpoint.heatingSetpointRange + if heat_or_cool == 'cool' then + event_constructor = capabilities.thermostatCoolingSetpoint.coolingSetpointRange + end + + device:emit_event(event_constructor( + { + unit = 'C', + value = { + minimum = device:get_field('setpoint_min_' .. heat_or_cool), + maximum = device:get_field('setpoint_max_' .. heat_or_cool) + } + } + )) + + device:set_field(field, nil) + device:set_field(paired_field, nil) + end + end +end + local set_thermostat_fan_mode = function(driver, device, command) for zigbee_attr_val, st_cap_val in pairs(FAN_MODE_MAP) do if command.args.mode == st_cap_val.NAME then @@ -197,6 +230,29 @@ local set_setpoint_factory = function(setpoint_attribute) end end +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + local do_refresh = function(self, device) local attributes = { Thermostat.attributes.OccupiedCoolingSetpoint, @@ -205,6 +261,10 @@ local do_refresh = function(self, device) Thermostat.attributes.ControlSequenceOfOperation, Thermostat.attributes.ThermostatRunningState, Thermostat.attributes.SystemMode, + Thermostat.attributes.MinHeatSetpointLimit, + Thermostat.attributes.MaxHeatSetpointLimit, + Thermostat.attributes.MinCoolSetpointLimit, + Thermostat.attributes.MaxCoolSetpointLimit, FanControl.attributes.FanModeSequence, FanControl.attributes.FanMode, PowerConfiguration.attributes.BatteryVoltage, @@ -223,12 +283,14 @@ local do_configure = function(self, device) end local device_added = function(self, device) + device:send(TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) + device:send(TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) do_refresh(self, device) end local zigbee_thermostat_driver = { supported_capabilities = { - TemperatureMeasurement, + Temperature, ThermostatCoolingSetpoint, ThermostatHeatingSetpoint, ThermostatMode, @@ -248,11 +310,19 @@ local zigbee_thermostat_driver = { [Thermostat.attributes.ControlSequenceOfOperation.ID] = supported_thermostat_modes_handler, [Thermostat.attributes.ThermostatRunningState.ID] = thermostat_operating_state_handler, [Thermostat.attributes.ThermostatRunningMode.ID] = thermostat_mode_handler, - [Thermostat.attributes.SystemMode.ID] = thermostat_mode_handler + [Thermostat.attributes.SystemMode.ID] = thermostat_mode_handler, + [Thermostat.attributes.MinHeatSetpointLimit.ID] = setpoint_limit_handler_factory('min', 'heat'), + [Thermostat.attributes.MaxHeatSetpointLimit.ID] = setpoint_limit_handler_factory('max', 'heat'), + [Thermostat.attributes.MinCoolSetpointLimit.ID] = setpoint_limit_handler_factory('min', 'cool'), + [Thermostat.attributes.MaxCoolSetpointLimit.ID] = setpoint_limit_handler_factory('max', 'cool'), }, [FanControl.ID] = { [FanControl.attributes.FanModeSequence.ID] = supported_fan_modes_handler, [FanControl.attributes.FanMode.ID] = thermostat_fan_mode_handler + }, + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), } } }, @@ -284,19 +354,9 @@ local zigbee_thermostat_driver = { doConfigure = do_configure, added = device_added }, - sub_drivers = { - require("zenwithin"), - require("fidure"), - require("sinope"), - require("stelpro-ki-zigbee-thermostat"), - require("stelpro"), - require("lux-konoz"), - require("leviton"), - require("danfoss"), - require("popp"), - require("vimar"), - require("resideo_korea") - }, + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_thermostat_driver, zigbee_thermostat_driver.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-thermostat/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-thermostat/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/leviton/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/leviton/can_handle.lua new file mode 100644 index 0000000000..054aaa821b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/leviton/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function leviton_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "HAI" and device:get_model() == "65A01-1" then + return true, require("leviton") + end + return false +end + +return leviton_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua b/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua index 9b1150ae25..4b35916229 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local Thermostat = clusters.Thermostat @@ -128,9 +118,7 @@ local leviton_thermostat = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "HAI" and device:get_model() == "65A01-1" - end + can_handle = require("leviton.can_handle"), } return leviton_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/can_handle.lua new file mode 100644 index 0000000000..b7f7e5220f --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_lux_konoz = function(opts, driver, device) + local FINGERPRINTS = require("lux-konoz.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("lux-konoz") + end + end + return false +end + +return is_lux_konoz diff --git a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/fingerprints.lua new file mode 100644 index 0000000000..01bb0bac8c --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local LUX_KONOZ_THERMOSTAT_FINGERPRINTS = { + { mfr = "LUX", model = "KONOZ" } +} + +return LUX_KONOZ_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua index 815639985c..0609128ac2 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local Thermostat = clusters.Thermostat @@ -19,18 +9,7 @@ local capabilities = require "st.capabilities" local ThermostatMode = capabilities.thermostatMode -local LUX_KONOZ_THERMOSTAT_FINGERPRINTS = { - { mfr = "LUX", model = "KONOZ" } -} -local is_lux_konoz = function(opts, driver, device) - for _, fingerprint in ipairs(LUX_KONOZ_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end -- LUX KONOz reports extra ["auto", "emergency heat"] which, actually, aren't supported local supported_thermostat_modes_handler = function(driver, device, supported_modes) @@ -46,7 +25,7 @@ local lux_konoz = { } } }, - can_handle = is_lux_konoz + can_handle = require("lux-konoz.can_handle"), } return lux_konoz diff --git a/drivers/SmartThings/zigbee-thermostat/src/popp/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/popp/can_handle.lua new file mode 100644 index 0000000000..907350cd1c --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/popp/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_popp_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("popp.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("popp") + end + end + return false +end + +return is_popp_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/popp/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/popp/fingerprints.lua new file mode 100644 index 0000000000..16a5cc0942 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/popp/fingerprints.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local POPP_THERMOSTAT_FINGERPRINTS = { + { + mfr = "D5X84YU", + model = "eT093WRO" + }, + { + mfr = "D5X84YU", + model = "eT093WRG" + } +} + +return POPP_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua b/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua index f9c4e09406..f842fe8c2b 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua @@ -1,17 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. --- Zigbee driver utilities +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local data_types = require "st.zigbee.data_types" @@ -34,14 +23,6 @@ local ThermostatMode = capabilities.thermostatMode local TemperatureAlarm = capabilities.temperatureAlarm local Switch = capabilities.switch -local POPP_THERMOSTAT_FINGERPRINTS = { { - mfr = "D5X84YU", - model = "eT093WRO" -}, { - mfr = "D5X84YU", - model = "eT093WRG" -} } - local STORED_HEAT_MODE = "stored_heat_mode" local MFG_CODE = 0x1246 @@ -114,15 +95,6 @@ local PREFERENCE_TABLES = { local SUPPORTED_MODES = { ThermostatMode.thermostatMode.heat.NAME, ThermostatMode.thermostatMode.eco.NAME } -local is_popp_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(POPP_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - -- Helpers -- has member check function @@ -167,8 +139,8 @@ local turn_switch_on = function(driver, device) end end --- Custom setpoint command handler -local setpoint_cmd_handler = function(driver, device, cmd) +-- custom thermostatMode_handler +local thermostat_mode_handler = function(driver, device, cmd) local payload local mode = cmd.args.mode @@ -182,7 +154,7 @@ local setpoint_cmd_handler = function(driver, device, cmd) -- convert setpoint value into bytes e.g. 25.5 -> 2550 -> \x09\xF6 -> \xF6\x09 local p2 = last_setpointTemp & 0xFF local p3 = last_setpointTemp >> 8 - local type = device:get_latest_state("main", ThermostatMode.ID, ThermostatMode.thermostatMode.heat.NAME) or 'eco' + local type = 0x00 -- eco if mode == ThermostatMode.thermostatMode.heat.NAME then -- Setpoint type "1": the actuator will make a large movement to minimize reaction time to UI @@ -210,11 +182,31 @@ local setpoint_cmd_handler = function(driver, device, cmd) end -- temperature setpoint handler -local handle_set_setpoint = function(driver, device, command) +local thermostat_setpoint_handler = function(driver, device, command) local value = command.args.setpoint + local type = 0x00 -- default eco + + local mode = device:get_latest_state("main", ThermostatMode.ID, ThermostatMode.thermostatMode.NAME, 'eco') + + if mode == ThermostatMode.thermostatMode.heat.NAME then + -- Setpoint type "1": the actuator will make a large movement to minimize reaction time to UI + type = 0x01 + elseif mode == ThermostatMode.thermostatMode.eco.NAME then + -- Setpoint type "0": the behavior will be the same as setting the attribute "Occupied Heating Setpoint" to the same value + type = 0x00 + end + + -- prepare setpoint for correct 4 char dec format + local setpointTemp = math.floor(value * 100) + + -- convert setpoint value into bytes e.g. 25.5 -> 2550 -> \x09\xF6 -> \xF6\x09 + local p2 = setpointTemp & 0xFF + local p3 = setpointTemp >> 8 - -- write new setpoint - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, value * 100)) + -- send thermostat setpoint command + local payload = string.char(type, p2, p3) + device:send(cluster_base.build_manufacturer_specific_command(device, Thermostat.ID, THERMOSTAT_SETPOINT_CMD_ID, + MFG_CODE, payload)) -- turn thermostat ventile on turn_switch_on(driver, device) @@ -305,7 +297,6 @@ local function device_init(driver, device) -- Add the manufacturer-specific attributes to generate their configure reporting and bind requests for _, config in pairs(cluster_configurations) do device:add_configured_attribute(config) - device:add_monitored_attribute(config) end -- initial set of heating mode local stored_heat_mode = device:get_field(STORED_HEAT_MODE) or 'eco' @@ -377,10 +368,10 @@ local popp_thermostat = { [capabilities.refresh.commands.refresh.NAME] = do_refresh }, [ThermostatHeatingSetpoint.ID] = { - [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = handle_set_setpoint + [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = thermostat_setpoint_handler }, [ThermostatMode.ID] = { - [ThermostatMode.commands.setThermostatMode.NAME] = setpoint_cmd_handler + [ThermostatMode.commands.setThermostatMode.NAME] = thermostat_mode_handler }, [Switch.ID] = { [Switch.commands.on.NAME] = switch_handler_factory('on'), @@ -406,7 +397,7 @@ local popp_thermostat = { doConfigure = do_configure, infoChanged = info_changed }, - can_handle = is_popp_thermostat + can_handle = require("popp.can_handle"), } return popp_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/can_handle.lua new file mode 100644 index 0000000000..94eee72e87 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function resideo_korea_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Resideo Korea" and device:get_model() == "DT300ST-M000" then + return true, require("resideo_korea") + end + return false +end + +return resideo_korea_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua index a1e68ef02a..14221dd223 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_lib = require "st.device" local device_management = require "st.zigbee.device_management" @@ -178,9 +168,7 @@ local resideo_thermostat = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Resideo Korea" and device:get_model() == "DT300ST-M000" - end + can_handle = require("resideo_korea.can_handle"), } return resideo_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sinope/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sinope/can_handle.lua new file mode 100644 index 0000000000..fdc43dab3b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sinope/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sinope_thermostat = function(opts, driver, device) + local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" + if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then + return true, require("sinope") + else + return false + end +end + +return is_sinope_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua index bef41f2b9c..7926b1e0c7 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local clusters = require "st.zigbee.zcl.clusters" @@ -26,8 +16,6 @@ local ThermostatOperatingState = capabilities.thermostatOperatingState local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local TemperatureMeasurement = capabilities.temperatureMeasurement -local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" - local SINOPE_CUSTOM_CLUSTER = 0xFF01 local MFR_TIME_FORMAT_ATTRIBUTE = 0x0114 local MFR_AIR_FLOOR_MODE_ATTRIBUTE = 0x0105 @@ -91,13 +79,6 @@ local PREFERENCE_TABLES = { } } -local is_sinope_thermostat = function(opts, driver, device) - if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then - return true - else - return false - end -end local do_refresh = function(self, device) local attributes = { @@ -176,7 +157,7 @@ local sinope_thermostat = { doConfigure = do_configure, infoChanged = info_changed }, - can_handle = is_sinope_thermostat + can_handle = require("sinope.can_handle"), } return sinope_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/can_handle.lua new file mode 100644 index 0000000000..3f0bebb126 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_ki_zigbee_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("stelpro-ki-zigbee-thermostat.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro-ki-zigbee-thermostat") + end + end + return false +end + +return is_stelpro_ki_zigbee_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/fingerprints.lua new file mode 100644 index 0000000000..2cf1a884d7 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS = { + { mfr = "Stelpro", model = "STZB402+" }, + { mfr = "Stelpro", model = "ST218" }, +} + +return STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua index bcc09b271c..d9f53defe1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" @@ -30,10 +20,6 @@ local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local TemperatureMeasurement = capabilities.temperatureMeasurement local TemperatureAlarm = capabilities.temperatureAlarm -local STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "STZB402+" }, - { mfr = "Stelpro", model = "ST218" }, -} -- The Groovy DTH stored the raw Celsius values because it was responsible for converting -- to Farenheit if the user's location necessitated. Right now the driver only operates @@ -60,14 +46,6 @@ local THERMOSTAT_MODE_MAP = { [ThermostatSystemMode.EMERGENCY_HEATING] = ThermostatMode.thermostatMode.eco } -local is_stelpro_ki_zigbee_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function has_member(haystack, needle) for _, value in ipairs(haystack) do @@ -234,7 +212,7 @@ local function set_heating_setpoint(driver, device, command) end if value >= MIN_SETPOINT and value <= MAX_SETPOINT then - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, value * 100)) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(value * 100))) device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) device:send(Thermostat.attributes.PIHeatingDemand:read(device)) end @@ -342,7 +320,7 @@ local stelpro_ki_zigbee_thermostat = { added = device_added, doConfigure = do_configure }, - can_handle = is_stelpro_ki_zigbee_thermostat + can_handle = require("stelpro-ki-zigbee-thermostat.can_handle"), } return stelpro_ki_zigbee_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/can_handle.lua new file mode 100644 index 0000000000..117d8ca51d --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("stelpro.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro") + end + end + return false +end + +return is_stelpro_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/fingerprints.lua new file mode 100644 index 0000000000..9271bba198 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local STELPRO_THERMOSTAT_FINGERPRINTS = { + { mfr = "Stelpro", model = "MaestroStat" }, + { mfr = "Stelpro", model = "SORB" }, + { mfr = "Stelpro", model = "SonomaStyle" } +} + +return STELPRO_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua index 063f423517..d186c46b67 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -25,20 +15,7 @@ local RX_HEAT_VALUE = 0x7fff local FREEZE_ALRAM_TEMPERATURE = 0 local HEAT_ALRAM_TEMPERATURE = 50 -local STELPRO_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "MaestroStat" }, - { mfr = "Stelpro", model = "SORB" }, - { mfr = "Stelpro", model = "SonomaStyle" } -} -local is_stelpro_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function get_temperature(temperature) return temperature / 100 @@ -128,8 +105,8 @@ local stelpro_thermostat = { added = device_added, infoChanged = info_changed }, - sub_drivers = { require("stelpro.stelpro_sorb"), require("stelpro.stelpro_maestrostat") }, - can_handle = is_stelpro_thermostat + sub_drivers = require("stelpro.sub_drivers"), + can_handle = require("stelpro.can_handle"), } return stelpro_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/can_handle.lua new file mode 100644 index 0000000000..f9410f80ff --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_thermostat = function(opts, driver, device) + local FINGERPRINTS = require "stelpro.stelpro_maestrostat.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro.stelpro_maestrostat") + end + end + return false +end + +return is_stelpro_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/fingerprints.lua new file mode 100644 index 0000000000..600fe09180 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "Stelpro", model = "MaestroStat" }, +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/init.lua similarity index 67% rename from drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat.lua rename to drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/init.lua index c4d81b944a..fecb34666a 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,19 +9,6 @@ local RelativeHumidity = clusters.RelativeHumidity local Thermostat = clusters.Thermostat local ThermostatUserInterfaceConfiguration = clusters.ThermostatUserInterfaceConfiguration -local STELPRO_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "MaestroStat" }, -} - -local is_stelpro_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - local do_refresh = function(self, device) local attributes = { Thermostat.attributes.LocalTemperature, @@ -74,7 +50,7 @@ local stelpro_maestro_othermostat = { added = device_added, doConfigure = do_configure }, - can_handle = is_stelpro_thermostat + can_handle = require("stelpro.stelpro_maestrostat.can_handle") } return stelpro_maestro_othermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/can_handle.lua new file mode 100644 index 0000000000..d9e99178e5 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_sorb_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("stelpro.stelpro_sorb.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro.stelpro_sorb") + end + end + return false +end + +return is_stelpro_sorb_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/fingerprints.lua new file mode 100644 index 0000000000..eb8731f6dc --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/fingerprints.lua @@ -0,0 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "Stelpro", model = "SORB" }, + { mfr = "Stelpro", model = "SonomaStyle" } +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/init.lua similarity index 66% rename from drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb.lua rename to drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/init.lua index 504d4351e0..39c370960f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,20 +9,6 @@ local RelativeHumidity = clusters.RelativeHumidity local Thermostat = clusters.Thermostat local ThermostatUserInterfaceConfiguration = clusters.ThermostatUserInterfaceConfiguration -local STELPRO_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "SORB" }, - { mfr = "Stelpro", model = "SonomaStyle" } -} - -local is_stelpro_sorb_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - local do_refresh = function(self, device) local attributes = { Thermostat.attributes.LocalTemperature, @@ -75,7 +50,7 @@ local stelpro_sorb_thermostat = { added = device_added, doConfigure = do_configure }, - can_handle = is_stelpro_sorb_thermostat + can_handle = require("stelpro.stelpro_sorb.can_handle") } return stelpro_sorb_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/sub_drivers.lua new file mode 100644 index 0000000000..aa48c7eda6 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("stelpro.stelpro_sorb"), + lazy_load_if_possible("stelpro.stelpro_maestrostat") +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua new file mode 100644 index 0000000000..3b535c3cf0 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zenwithin"), + lazy_load_if_possible("fidure"), + lazy_load_if_possible("sinope"), + lazy_load_if_possible("stelpro-ki-zigbee-thermostat"), + lazy_load_if_possible("stelpro"), + lazy_load_if_possible("lux-konoz"), + lazy_load_if_possible("leviton"), + lazy_load_if_possible("danfoss"), + lazy_load_if_possible("popp"), + lazy_load_if_possible("vimar"), + lazy_load_if_possible("resideo_korea"), + lazy_load_if_possible("aqara"), + lazy_load_if_possible("wallhero"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua new file mode 100644 index 0000000000..8b84e987a0 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua @@ -0,0 +1,477 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local test = require "integration_test" + +local Thermostat = clusters.Thermostat +local valveCalibration = capabilities["stse.valveCalibration"] +local invisibleCapabilities = capabilities["stse.invisibleCapabilities"] +local valveCalibrationCommandName = "startCalibration" +local thermostatModeId = "thermostatMode" +test.add_package_capability("valveCalibration.yaml") +test.add_package_capability("invisibleCapabilities.yaml") +local PRIVATE_CLUSTER_ID = 0xFCC0 +local MFG_CODE = 0x115F +local PRIVATE_VALVE_CALIBRATION_ID = 0x0270 +local PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID = 0x0271 +local PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID = 0x0272 +local PRIVATE_THERM0STAT_VALVE_DETECTION_SWITCH_ID = 0x0274 +local PRIVATE_THERMOSTAT_ALARM_INFORMATION_ID = 0x0275 +local PRIVATE_CHILD_LOCK_ID = 0x0277 +local PRIVATE_ANTIFREEZE_MODE_TEMPERATURE_SETTING_ID = 0x0279 +local PRIVATE_VALVE_RESULT_CALIBRATION_ID = 0x027B +local PRIVATE_BATTERY_ENERGY_ID = 0x040A + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("thermostat-aqara.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.airrtc.agl001", + server_clusters = { PRIVATE_CLUSTER_ID, Thermostat.ID, 0x0001, 0x0402} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, MFG_CODE) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_VALVE_RESULT_CALIBRATION_ID, MFG_CODE) + }) +end + +test.set_test_init_function(test_init) + + + + + +test.register_coroutine_test( + "Handle notificationOfValveTest in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.notificationOfValveTest"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_THERM0STAT_VALVE_DETECTION_SWITCH_ID, MFG_CODE, data_types.Uint8, 0x01) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle antifreezeModeSetting in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.antifreezeModeSetting"] = "1" } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_ANTIFREEZE_MODE_TEMPERATURE_SETTING_ID, MFG_CODE, data_types.Uint32, 500) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "hardwareFault report should be handled, detected", + function() + local attr_report_data = { + { PRIVATE_THERMOSTAT_ALARM_INFORMATION_ID, data_types.Uint32.ID, 0x00000001 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.hardwareFault.hardwareFault.detected())) + + local attr_report_data_1 = { + { PRIVATE_THERMOSTAT_ALARM_INFORMATION_ID, data_types.Uint32.ID, 0x00000000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_1, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.hardwareFault.hardwareFault.clear())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "valve result report should be handled, successful", + function() + local attr_report_data = { + { PRIVATE_VALVE_RESULT_CALIBRATION_ID, data_types.Uint8.ID, 0x01 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + valveCalibration.calibrationState.calibrationSuccess())) + + local attr_report_data_1 = { + { PRIVATE_VALVE_RESULT_CALIBRATION_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_1, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + valveCalibration.calibrationState.calibrationPending())) + + local attr_report_data_2 = { + { PRIVATE_VALVE_RESULT_CALIBRATION_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_2, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + valveCalibration.calibrationState.calibrationFailure())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "thermostat mode report should be handled, manual", + function() + local attr_report_data = { + { PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode.manual())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({""}))) + + local attr_report_data_1 = { + { PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_1, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode.antifreezing())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "valve status report should be handled, close", + function() + local attr_report_data = { + { PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.valve.valve.closed())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint","stse.valveCalibration","thermostatMode","lock"}))) + + local attr_report_data_1 = { + { PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_1, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode.manual())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({""}))) + + local attr_report_data_2 = { + { PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID, data_types.Uint8.ID, 0x01 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_2, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.valve.valve.open())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({""}))) + + local attr_report_data_3 = { + { PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_3, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode.antifreezing())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint"}))) + + local attr_report_data_4 = { + { PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID, data_types.Uint8.ID, 0x01 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_4, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.valve.valve.open())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint"}))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "child lock status report should be handled, unlocked", + function() + local attr_report_data = { + { PRIVATE_CHILD_LOCK_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("ChildLock", + capabilities.lock.lock.unlocked())) + + local attr_report_data_1 = { + { PRIVATE_CHILD_LOCK_ID, data_types.Uint8.ID, 0x01 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data_1, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("ChildLock", + capabilities.lock.lock.locked())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery voltage report should be handled, 48", + function() + local attr_report_data = { + { PRIVATE_BATTERY_ENERGY_ID, data_types.Uint8.ID, 0x30 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(48)) + ) + end, + { + min_api_version = 17 + } +) + +-- test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events", function() +-- test.socket.zigbee:__queue_receive({mock_device.id, +-- Thermostat.attributes.ControlSequenceOfOperation:build_test_attr_report( +-- mock_device, 0x02)}) +-- test.socket.capability:__expect_send(mock_device:generate_test_message("main", +-- capabilities.thermostatMode.supportedThermostatModes({ +-- capabilities.thermostatMode.thermostatMode.manual.NAME, +-- capabilities.thermostatMode.thermostatMode.antifreezing.NAME +-- }, { visibility = { displayed = false } }))) +-- end) + +test.register_coroutine_test( + "Capability on command should be handled : thermostat mode manual", + function() + local attr_report_data = { + { PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.capability:__queue_receive({ mock_device.id, + { capability = thermostatModeId, component = "main", command = "setThermostatMode", args = {"manual"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_THERMOSTAT_OPERATING_MODE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x00) + }) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode.manual()) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + invisibleCapabilities.invisibleCapabilities({""}))) + test.wait_for_events() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = thermostatModeId, component = "main", command = "setThermostatMode", args = {"manual"}} + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode.manual()) + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability on command should be handled : valve open", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "valve", component = "main", command = "open", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability on command should be handled : valve Calibration", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.valveCalibration", component = "main", command = valveCalibrationCommandName, args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_VALVE_CALIBRATION_ID, MFG_CODE, data_types.Uint8, 0x01) }) + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "Capability on command should be handled : child lock locked", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "lock", component = "ChildLock", command = "lock", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_CHILD_LOCK_ID, MFG_CODE, data_types.Uint8, 0x01) }) + end, + { + min_api_version = 17 + } +) +--]] +test.register_coroutine_test( + "Handle added lifecycle", + function() + -- The initial valve and lock event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ + capabilities.thermostatMode.thermostatMode.manual.NAME, + capabilities.thermostatMode.thermostatMode.antifreezing.NAME + }, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 21.0, unit = "C"})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 27.0, unit = "C"})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.manual()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", valveCalibration.calibrationState.calibrationPending()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", invisibleCapabilities.invisibleCapabilities({""})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(100)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.valve.valve.open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("ChildLock", capabilities.lock.lock.unlocked()) + ) + -- Avoid sending the initial open and lock event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ + capabilities.thermostatMode.thermostatMode.manual.NAME, + capabilities.thermostatMode.thermostatMode.antifreezing.NAME + }, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 21.0, unit = "C"})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 27.0, unit = "C"})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.manual()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", valveCalibration.calibrationState.calibrationPending()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", invisibleCapabilities.invisibleCapabilities({""})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(100)) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua index e97be89176..b1942290db 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -39,9 +28,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -59,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -76,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -93,6 +86,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -121,7 +117,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -149,7 +148,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua index 34c72e2957..43fe7c3c82 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -22,9 +25,7 @@ local mock_device_danfoss = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device_danfoss) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device_danfoss)end test.set_test_init_function(test_init) @@ -41,6 +42,9 @@ test.register_message_test( direction = "send", message = mock_device_danfoss:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -49,7 +53,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device_danfoss.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device_danfoss, 2100) }) test.socket.capability:__expect_send(mock_device_danfoss:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -57,14 +64,20 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device_danfoss.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device_danfoss, 2100) }) test.socket.capability:__expect_send(mock_device_danfoss:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Thermostat cooling setpoint reporting should not create setpoint events, the mode is not supported Danfoss", function () test.socket.zigbee:__queue_receive({ mock_device_danfoss.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device_danfoss, 2100) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -87,7 +100,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device_danfoss:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -103,7 +119,10 @@ test.register_message_test( direction = "send", message = mock_device_danfoss:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua index 29bc834b31..469f36dec9 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -33,9 +22,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -74,7 +61,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -86,6 +76,9 @@ test.register_message_test( message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, 3), } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua index 412af0f5e6..8fc6b2944f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -37,9 +26,7 @@ local ENDPOINT = 10 zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -64,7 +51,10 @@ test.register_coroutine_test( mock_device.id, FanControl.attributes.FanMode:configure_reporting(mock_device, 5, 1800, nil):to_endpoint(ENDPOINT)}) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -78,7 +68,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -86,7 +79,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2100) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -94,7 +90,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.OFF) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.off())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -103,7 +102,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.ControlSequenceOfOperation:build_test_attr_report(mock_device, 0x02)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"auto", "cool", "heat", "emergency heat"},{visibility = {displayed = false }}))) - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -111,7 +113,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, FanControl.attributes.FanMode.AUTO) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatFanMode.thermostatFanMode.auto())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -123,7 +128,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2100) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -135,7 +143,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2100) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -146,7 +157,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.COOL)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2100) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -157,7 +171,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.EMERGENCY_HEATING)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2100) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +183,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatHeatingSetpoint.ID, command = "setHeatingSetpoint", args = {21} } }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 21.0, unit = "C"}))) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2100):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -175,7 +195,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatCoolingSetpoint.ID, command = "setCoolingSetpoint", args = {21} } }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({value = 21.0, unit = "C"}))) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2100):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -183,7 +206,10 @@ test.register_coroutine_test( function () test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatMode.ID, command = "setThermostatMode", args = {"cool"} } }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 3):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -191,7 +217,45 @@ test.register_coroutine_test( function () test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatFanMode.ID, command = "setThermostatFanMode", args = {"auto"} } }) test.socket.zigbee:__expect_send( { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 5):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + -- The initial valve and lock event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ + capabilities.thermostatMode.thermostatMode.auto.NAME, + capabilities.thermostatMode.thermostatMode.cool.NAME, + capabilities.thermostatMode.thermostatMode.heat.NAME, + capabilities.thermostatMode.thermostatMode.emergency_heat.NAME + }, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatFanMode.supportedThermostatFanModes({ + capabilities.thermostatFanMode.thermostatFanMode.auto.NAME, + capabilities.thermostatFanMode.thermostatFanMode.on.NAME, + capabilities.thermostatFanMode.thermostatFanMode.circulate.NAME + }, { visibility = { displayed = false } })) + ) + test.socket.zigbee:__expect_send( { mock_device.id, FanControl.attributes.FanMode:read(mock_device):to_endpoint(ENDPOINT) }) + test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.SystemMode:read(mock_device):to_endpoint(ENDPOINT) }) + test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.ControlSequenceOfOperation:read(mock_device):to_endpoint(ENDPOINT) }) + test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) + test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) + test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device):to_endpoint(ENDPOINT) }) + end, + { + min_api_version = 17 + } ) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua index 084ff54c73..d1ae50109d 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + require "integration_test" -- Mock out globals local test = require "integration_test" @@ -18,6 +21,7 @@ local ThermostatMode = capabilities.thermostatMode local MFG_CODE = 0x1246 local ETRV_WINDOW_OPEN_DETECTION_ATTR_ID = 0x4000 local EXTERNAL_WINDOW_OPEN_DETECTION = 0x4003 +local THERMOSTAT_SETPOINT_CMD_ID = 0x40 -- utils local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -42,9 +46,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -65,6 +67,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -81,7 +86,7 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send( { mock_device.id, - Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2750) + cluster_base.build_manufacturer_specific_command(mock_device, Thermostat.ID, THERMOSTAT_SETPOINT_CMD_ID, MFG_CODE, string.char(0x00, (math.floor(27.5 * 100) & 0xFF), (math.floor(27.5 * 100) >> 8))) } ) test.wait_for_events() @@ -93,7 +98,54 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "External window open detection window open", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, Thermostat.ID, EXTERNAL_WINDOW_OPEN_DETECTION, MFG_CODE, data_types.Boolean, false) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "External window open detection window closed", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, Thermostat.ID, EXTERNAL_WINDOW_OPEN_DETECTION, MFG_CODE, data_types.Boolean, true) + } + ) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -114,7 +166,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 5, 300, 50) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -133,6 +188,9 @@ test.register_message_test( { ThermostatMode.thermostatMode.heat.NAME, ThermostatMode.thermostatMode.eco.NAME }, { visibility = { displayed = false } })) } + }, + { + min_api_version = 17 } ) @@ -194,6 +252,9 @@ test.register_message_test( MFG_CODE) } } + }, + { + min_api_version = 17 } ) @@ -228,7 +289,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -253,7 +317,187 @@ test.register_coroutine_test( capabilities.battery.battery(batt_perc))) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting the thermostat mode to heat should generate the correct zigbee messages", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive( + { + mock_device.id, + { + component = "main", + capability = capabilities.thermostatMode.ID, + command = "heat", + args = {} + } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, + Thermostat.attributes.SystemMode.HEAT) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.SystemMode:read(mock_device) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting the thermostat mode to off should generate the correct zigbee messages", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive( + { + mock_device.id, + { + component = "main", + capability = capabilities.thermostatMode.ID, + command = "off", + args = {} + } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, + Thermostat.attributes.SystemMode.OFF) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.SystemMode:read(mock_device) + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting the thermostat mode should generate the appropriate messages", + function () + test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatMode.ID, command = "setThermostatMode", args = {"eco"} } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.build_manufacturer_specific_command(mock_device, Thermostat.ID, THERMOSTAT_SETPOINT_CMD_ID, MFG_CODE, string.char(0x00, (math.floor(21.0 * 100) & 0xFF), (math.floor(21.0 * 100) >> 8))) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.eco())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.eco())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ThermostatUIConfig.attributes.KeypadLockout:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.LocalTemperature:read(mock_device) + } + ) + test.socket.zigbee:__expect_send({mock_device.id, cluster_base.read_manufacturer_specific_attribute( + mock_device, + Thermostat.ID, + ETRV_WINDOW_OPEN_DETECTION_ATTR_ID, + MFG_CODE + )}) + test.socket.zigbee:__expect_send({mock_device.id, cluster_base.read_manufacturer_specific_attribute( + mock_device, + Thermostat.ID, + EXTERNAL_WINDOW_OPEN_DETECTION, + MFG_CODE + )}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported Thermostat WINDOW_OPEN_DETECTION_ATTR_ID attribute", + function() + local attr_report_data = { + { ETRV_WINDOW_OPEN_DETECTION_ATTR_ID, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Thermostat.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported Thermostat WINDOW_OPEN_DETECTION_ATTR_ID attribute", + function() + local attr_report_data = { + { EXTERNAL_WINDOW_OPEN_DETECTION, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Thermostat.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua index 2a3348e3d0..71264c3a99 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -78,9 +67,7 @@ local function test_init() test.mock_device.add_test_device(mock_second_child) test.mock_device.add_test_device(mock_third_child) test.mock_device.add_test_device(mock_forth_child) - test.mock_device.add_test_device(mock_fifth_child) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_fifth_child)end test.set_test_init_function(test_init) @@ -113,7 +100,11 @@ test.register_coroutine_test("Configure should configure all necessary attribute mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end) +end, +{ + min_api_version = 17 +} +) -------------------------------------------------------------------------------- -- Parent thermostat device @@ -132,7 +123,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes", fun for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_device)}) end -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -142,7 +137,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -155,7 +154,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -167,7 +170,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -178,7 +185,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages", function() test.socket.capability:__queue_receive({mock_device.id, { @@ -189,7 +200,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2100)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages", function() test.socket.capability:__queue_receive({mock_device.id, { @@ -201,7 +216,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages", function() test.socket.capability:__queue_receive({mock_device.id, { @@ -213,7 +232,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 17 +} +) -------------------------------------------------------------------------------- -- First child thermostat device @@ -232,7 +255,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_first_child)}) end -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -242,7 +269,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -255,7 +286,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_first_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -267,7 +302,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -278,7 +317,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with first child device", function() test.socket.capability:__queue_receive({mock_first_child.id, { @@ -289,7 +332,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_first_child, 2100)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with first child device", function() test.socket.capability:__queue_receive({mock_first_child.id, { @@ -301,7 +348,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_first_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with first child device", function() test.socket.capability:__queue_receive({mock_first_child.id, { @@ -313,7 +364,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_first_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 17 +} +) -------------------------------------------------------------------------------- -- Second child thermostat device @@ -332,7 +387,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_second_child)}) end -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -342,7 +401,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -355,7 +418,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_second_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -367,7 +434,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -378,7 +449,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with second child device", function() test.socket.capability:__queue_receive({mock_second_child.id, { @@ -389,7 +464,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_second_child, 2100)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with second child device", function() test.socket.capability:__queue_receive({mock_second_child.id, { @@ -401,7 +480,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_second_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with second child device", function() test.socket.capability:__queue_receive({mock_second_child.id, { @@ -413,7 +496,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_second_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 17 +} +) -------------------------------------------------------------------------------- -- Third child thermostat device @@ -432,7 +519,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_third_child)}) end -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -442,7 +533,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -455,7 +550,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_third_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -467,7 +566,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -478,7 +581,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with third child device", function() test.socket.capability:__queue_receive({mock_third_child.id, { @@ -489,7 +596,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_third_child, 2100)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with third child device", function() test.socket.capability:__queue_receive({mock_third_child.id, { @@ -501,7 +612,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_third_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with third child device", function() test.socket.capability:__queue_receive({mock_third_child.id, { @@ -513,7 +628,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_third_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 17 +} +) -------------------------------------------------------------------------------- -- Forth child thermostat device @@ -532,7 +651,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_forth_child)}) end -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -542,7 +665,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -555,7 +682,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_forth_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -567,7 +698,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -578,7 +713,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with forth child device", function() test.socket.capability:__queue_receive({mock_forth_child.id, { @@ -589,7 +728,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_forth_child, 2100)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with forth child device", function() test.socket.capability:__queue_receive({mock_forth_child.id, { @@ -601,7 +744,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_forth_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with forth child device", function() test.socket.capability:__queue_receive({mock_forth_child.id, { @@ -613,7 +760,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_forth_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 17 +} +) -------------------------------------------------------------------------------- -- Fifth child thermostat device @@ -632,7 +783,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_fifth_child)}) end -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -642,7 +797,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -655,7 +814,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_fifth_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -667,7 +830,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -678,7 +845,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with fifth child device", function() test.socket.capability:__queue_receive({mock_fifth_child.id, { @@ -689,7 +860,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_fifth_child, 2100)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with fifth child device", function() test.socket.capability:__queue_receive({mock_fifth_child.id, { @@ -701,7 +876,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_fifth_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 17 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with fifth child device", function() test.socket.capability:__queue_receive({mock_fifth_child.id, { @@ -713,7 +892,29 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_fifth_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 17 +} +) +test.register_coroutine_test("ThermostatRunningState reporting shoulb create the appropriate events", function() + test.socket.zigbee:__queue_receive({mock_device.id, + Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0001)}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatOperatingState.thermostatOperatingState({value="heating"}))) + test.socket.zigbee:__queue_receive({mock_device.id, + Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0002)}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatOperatingState.thermostatOperatingState({value="cooling"}))) + test.socket.zigbee:__queue_receive({mock_device.id, + Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0004)}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatOperatingState.thermostatOperatingState({value="fan only"}))) +end, +{ + min_api_version = 17 +} +) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua index 497205f559..271c070df3 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -39,9 +28,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -58,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -74,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 17 } ) @@ -107,7 +100,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 305) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -200,7 +196,10 @@ test.register_coroutine_test( data_types.validate_or_build_type(3000, data_types.Int16, "payload") ) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua index d7aebeeb56..55ba5be56c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -39,9 +28,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -58,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -74,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 17 } ) @@ -107,7 +100,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 305) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -210,7 +206,10 @@ test.register_coroutine_test( data_types.validate_or_build_type(0x0708, data_types.Uint16, "payload") ) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua index edd13c8a38..2506ca0f2f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -39,9 +28,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -58,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -74,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 17 } ) @@ -107,7 +100,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 305) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -184,7 +180,40 @@ test.register_coroutine_test( ) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should send read requests for all necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperature:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.PIHeatingDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:read(mock_device) + }) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua index f69cdc237c..470bb1378d 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -51,9 +40,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -70,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 17 } ) @@ -86,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -107,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 55.0, unit = "C"})) } + }, + { + min_api_version = 17 } ) @@ -128,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = -1.0, unit = "C"})) } + }, + { + min_api_version = 17 } ) @@ -149,6 +148,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 15.0, unit = "C"})) } + }, + { + min_api_version = 17 } ) @@ -165,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 25.0, unit = "C"})) } + }, + { + min_api_version = 17 } ) @@ -181,6 +186,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", ThermostatMode.thermostatMode.off()) } + }, + { + min_api_version = 17 } ) @@ -200,6 +208,9 @@ test.register_message_test( zigbee_test_utils.build_attribute_read(mock_device, Thermostat.ID, {MFR_SETPOINT_MODE_ATTTRIBUTE}, MFG_CODE) } } + }, + { + min_api_version = 17 } ) @@ -229,7 +240,10 @@ test.register_coroutine_test( Thermostat.attributes.PIHeatingDemand:build_test_attr_report(mock_device, 0) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", ThermostatOperatingState.thermostatOperatingState("idle"))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -249,7 +263,10 @@ test.register_coroutine_test( {{ MFR_SETPOINT_MODE_ATTTRIBUTE, data_types.Uint16.ID, 0x04}}, MFG_CODE) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", ThermostatMode.thermostatMode.heat())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -279,7 +296,10 @@ test.register_coroutine_test( mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -309,7 +329,10 @@ test.register_coroutine_test( mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -398,7 +421,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -480,6 +506,116 @@ test.register_message_test( cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) } }, + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages", function() + test.socket.capability:__queue_receive({mock_device.id, { + component = "main", + capability = capabilities.thermostatHeatingSetpoint.ID, + command = "setHeatingSetpoint", + args = {21} + }}) + test.socket.zigbee:__expect_send({mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2100)}) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.PIHeatingDemand:read(mock_device) + }) +end, +{ + min_api_version = 17 +} +) + +test.register_coroutine_test( + "Setting thermostat mode to eco should generate correct zigbee messages", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = {"eco"}} + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, ThermostatSystemMode.HEAT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE, data_types.Enum8, 0x05) + }) + test.wait_for_events() + + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) + }) + end, + { + min_api_version = 17 + } +) +test.register_coroutine_test( + "LocalTemperature handler should request PIHeatingDemand when setpoint > temperature", + function() + local RAW_SETPOINT_FIELD = "raw_setpoint" + mock_device:set_field(RAW_SETPOINT_FIELD, 3000, { persist = true }) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2000) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.PIHeatingDemand:read(mock_device) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 20.0, unit = "C" })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting an unsupported thermostat mode should re-emit the current mode", + function() + -- Establish a known current mode state + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, ThermostatSystemMode.OFF) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", ThermostatMode.thermostatMode.off()) + ) + test.wait_for_events() + + -- "cool" is not in SUPPORTED_MODES for stelpro-ki; the driver re-emits the current mode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = { "cool" } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", ThermostatMode.thermostatMode.off()) + ) + end, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua index fbbf077596..208949090c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -43,9 +32,7 @@ local mock_device_maestro = test.mock_device.build_test_zigbee_device({ profile zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - test.mock_device.add_test_device(mock_device_maestro) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device_maestro)end test.set_test_init_function(test_init) @@ -62,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 18.5, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -83,6 +73,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 17 } ) @@ -104,6 +97,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -135,7 +131,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +165,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -186,7 +188,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -206,7 +211,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -227,7 +235,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -243,6 +254,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 17 } ) @@ -259,6 +273,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("heating")) } + }, + { + min_api_version = 17 } ) @@ -275,6 +292,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 18.5, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -291,6 +311,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 25 })) } + }, + { + min_api_version = 17 } ) @@ -330,7 +353,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -372,7 +398,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -409,7 +438,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -451,7 +483,10 @@ test.register_coroutine_test( }) mock_device_maestro:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -463,7 +498,10 @@ test.register_coroutine_test( test.wait_for_events() -- Event not to be handled by driver test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({preferences = { lock = 1 } })) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -473,7 +511,48 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({preferences = { lock = 0 } })) test.socket.zigbee:__expect_send({mock_device.id, ThermostatUserInterfaceConfiguration.attributes.KeypadLockout:write(mock_device, 0x00)}) test.wait_for_events() - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + ) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperature:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.PIHeatingDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ThermostatUserInterfaceConfiguration.attributes.TemperatureDisplayMode:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ThermostatUserInterfaceConfiguration.attributes.KeypadLockout:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + RelativeHumidity.attributes.MeasuredValue:read(mock_device) + }) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua index 32a089680b..afbe34524b 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -69,9 +58,7 @@ local function test_init() mock_device_vimar_heating:set_field(VIMAR_CURRENT_PROFILE, ThermostatMode.thermostatMode.heat.NAME, { persist = true }) mock_device_vimar_cooling:set_field(VIMAR_CURRENT_PROFILE, ThermostatMode.thermostatMode.cool.NAME, { persist = true }) test.mock_device.add_test_device(mock_device_vimar_heating) - test.mock_device.add_test_device(mock_device_vimar_cooling) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device_vimar_cooling)end test.set_test_init_function(test_init) @@ -98,6 +85,9 @@ test.register_message_test( capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }) ) } + }, + { + min_api_version = 17 } ) @@ -126,6 +116,9 @@ test.register_message_test( capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 30.0, unit = "C" }) ) } + }, + { + min_api_version = 17 } ) @@ -154,6 +147,9 @@ test.register_message_test( capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 18.0, unit = "C" }) ) } + }, + { + min_api_version = 17 } ) @@ -190,7 +186,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 17 + } ) -- Test (SmartThings -> Device) @@ -226,7 +225,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 17 + } ) -- Test (SmartThings -> Device) @@ -265,7 +267,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 17 + } ) -- Test (SmartThings -> Device) @@ -304,7 +309,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 17 + } ) @@ -365,7 +373,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device_vimar_cooling, 2720) } ) - end + end, + { + min_api_version = 17 + } ) @@ -402,7 +413,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 17 + } ) -- Test (SmartThings -> Device) @@ -438,7 +452,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 17 + } ) -- Test (SmartThings -> Device) @@ -477,7 +494,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 17 + } ) -- Test (SmartThings -> Device) @@ -516,7 +536,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 17 + } ) @@ -577,7 +600,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device_vimar_heating, 1920) } ) - end + end, + { + min_api_version = 17 + } ) @@ -609,6 +635,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -640,6 +669,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -696,6 +728,9 @@ test.register_message_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } } + }, + { + min_api_version = 17 } ) @@ -753,6 +788,9 @@ test.register_message_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } } + }, + { + min_api_version = 17 } ) @@ -780,7 +818,10 @@ test.register_coroutine_test( } } ) - end + end, + { + min_api_version = 17 + } ) -- Test (Device -> SmartThings) @@ -807,7 +848,10 @@ test.register_coroutine_test( } } ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua index d9ab6656a0..c16757366d 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -38,9 +27,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -60,7 +47,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -77,6 +67,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("cooling")) } + }, + { + min_api_version = 17 } ) test.register_message_test( @@ -93,6 +86,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -110,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -148,7 +147,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -207,7 +209,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({ "off", "heat", "cool" }, { visibility = { displayed = false } })) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -284,7 +289,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -416,7 +424,10 @@ test.register_coroutine_test( ) test.mock_time.advance_time(2) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -448,7 +459,10 @@ test.register_coroutine_test( ) test.mock_time.advance_time(2) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -460,6 +474,417 @@ test.register_message_test( message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, 3), } } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting cooling setpoint while in heat mode should re-emit the current cooling setpoint", + function() + -- Put device in heat mode + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- Set a known cooling setpoint state + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2500) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) + ) + test.wait_for_events() + + -- Try to set cooling setpoint while in heat mode; driver defers and re-emits current + test.timer.__create_and_queue_test_time_advance_timer(10, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 27 } } + }) + test.wait_for_events() + + test.mock_time.advance_time(10) + -- After the delay, update_device_setpoint re-emits the unchanged cooling setpoint (25.0 C) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "MinHeatSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 500) + }) + test.wait_for_events() + assert(mock_device:get_field("minHeatSetpoint") == 500, "minHeatSetpoint field not stored correctly") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "MaxHeatSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 3500) + }) + test.wait_for_events() + assert(mock_device:get_field("maxHeatSetpoint") == 3500, "maxHeatSetpoint field not stored correctly") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "MinCoolSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:build_test_attr_report(mock_device, 1600) + }) + test.wait_for_events() + assert(mock_device:get_field("minCoolSetpoint") == 1600, "minCoolSetpoint field not stored correctly") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "MaxCoolSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:build_test_attr_report(mock_device, 3200) + }) + test.wait_for_events() + assert(mock_device:get_field("maxCoolSetpoint") == 3200, "maxCoolSetpoint field not stored correctly") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Heating setpoint is clamped to MinHeatSetpointLimit", + function() + -- Receive MinHeatSetpointLimit = 18.0 C (1800 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 1800) + }) + test.wait_for_events() + + -- Put device in heat mode so the setpoint is applied immediately + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- Try to set heating setpoint below the stored min (16 C < 18 C); expect it clamped to 18 C + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 16 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1800) -- clamped to 18.0 C + }) + test.mock_time.advance_time(2) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Heating setpoint is clamped to MaxHeatSetpointLimit", + function() + -- Receive MaxHeatSetpointLimit = 30.0 C (3000 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 3000) + }) + test.wait_for_events() + + -- Put device in heat mode + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- Try to set heating setpoint above the stored max (35 C > 30 C); expect it clamped to 30 C + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 35 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 3000) -- clamped to 30.0 C + }) + test.mock_time.advance_time(2) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cooling setpoint is clamped to MinCoolSetpointLimit", + function() + -- Receive MinCoolSetpointLimit = 18.0 C (1800 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:build_test_attr_report(mock_device, 1800) + }) + test.wait_for_events() + + -- Put device in cool mode so the setpoint is applied immediately + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x03) -- COOL + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) + ) + test.wait_for_events() + + -- Try to set cooling setpoint below the stored min (16 C < 18 C); expect it clamped to 18 C + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 16 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 1800) -- clamped to 18.0 C + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cooling setpoint is clamped to MaxCoolSetpointLimit", + function() + -- Receive MaxCoolSetpointLimit = 30.0 C (3000 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:build_test_attr_report(mock_device, 3000) + }) + test.wait_for_events() + + -- Put device in cool mode + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x03) -- COOL + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) + ) + test.wait_for_events() + + -- Try to set cooling setpoint above the stored max (35 C > 30 C); expect it clamped to 30 C + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 35 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 3000) -- clamped to 30.0 C + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +-- Integration test: full set_cooling_setpoint clamping path +-- +-- Exercises every branch in set_cooling_setpoint that involves get_field / default: +-- +-- Phase 1 – no limit fields present yet → clamp uses DEFAULT_MIN (4.0) / DEFAULT_MAX (37.5) +-- A value already inside the defaults passes through unchanged. +-- A value above DEFAULT_MAX is clamped down to 37.5 C. +-- +-- Phase 2 – device reports MinCoolSetpointLimit (20.0 C) and MaxCoolSetpointLimit (28.0 C) +-- stored via setpoint_limit_handler → get_field now returns real device limits. +-- A value inside those limits passes through unchanged. +-- A value below MinCoolSetpointLimit is clamped up to 20.0 C. +-- A value above MaxCoolSetpointLimit is clamped down to 28.0 C. +-- +-- Phase 3 – device switches to heat mode. +-- setCoolingSetpoint is rejected: driver defers with 10s backstop, +-- then re-emits the last known cooling setpoint without writing to the device. +test.register_coroutine_test( + "set_cooling_setpoint: default clamp, then device-reported limit clamp, then heat-mode rejection", + function() + -- ── Phase 1: no limit fields – defaults apply ───────────────────────────── + + -- Put device in cool mode so setpoints are applied immediately throughout phase 1 & 2 + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x03) -- COOL + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) + ) + test.wait_for_events() + + -- 1a. Value within defaults (25 C): written as-is + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 25 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2500) + }) + test.wait_for_events() + + -- Establish the device's reported current cooling setpoint state (needed later for re-emit) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2500) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) + ) + test.wait_for_events() + + -- 1b. Value above DEFAULT_MAX (38 C > 37.5 C): clamped to 37.5 C → written as 3750 + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 38 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 3750) + }) + test.wait_for_events() + + -- ── Phase 2: device reports real limits; get_field overrides defaults ────── + + -- Device sends MinCoolSetpointLimit = 20.0 C and MaxCoolSetpointLimit = 28.0 C + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:build_test_attr_report(mock_device, 2000) + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:build_test_attr_report(mock_device, 2800) + }) + test.wait_for_events() + + -- 2a. Value inside device limits (24 C): written as-is + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 24 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2400) + }) + test.wait_for_events() + + -- 2b. Value below device MinCoolSetpointLimit (18 C < 20 C): clamped to 20.0 C + -- (18 is above DEFAULT_MIN of 4.0, proving the device field is used, not the default) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 18 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2000) + }) + test.wait_for_events() + + -- 2c. Value above device MaxCoolSetpointLimit (30 C > 28 C): clamped to 28.0 C + -- (30 is below DEFAULT_MAX of 37.5, proving the device field is used, not the default) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 30 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2800) + }) + test.wait_for_events() + + -- ── Phase 3: device switches to heat mode; setCoolingSetpoint is deferred ── + + -- Re-establish a known current cooling setpoint for the re-emit assertion + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2400) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 24.0, unit = "C" })) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- 3. setCoolingSetpoint while in heat mode: driver stores the field (clamped to 24 C by + -- device limits), then takes the else-branch and arms the 10s backstop timer. + -- After the timer fires, update_device_setpoint detects heat mode, discards the pending + -- cooling value, and re-emits the last reported cooling setpoint (24.0 C) unchanged. + test.timer.__create_and_queue_test_time_advance_timer(10, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 24 } } + }) + test.wait_for_events() + + test.mock_time.advance_time(10) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 24.0, unit = "C" })) + ) + test.wait_for_events() + end, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua index a02045a1a9..e954e05a8a 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -18,6 +7,7 @@ local clusters = require "st.zigbee.zcl.clusters" local PowerConfiguration = clusters.PowerConfiguration local Thermostat = clusters.Thermostat local FanControl = clusters.FanControl +local TemperatureMeasurement = clusters.TemperatureMeasurement local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" @@ -28,9 +18,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -48,6 +36,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -65,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -89,6 +83,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -106,7 +103,53 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -123,6 +166,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -140,6 +186,71 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Thermostat cooling setpoint bounds are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.MinCoolSetpointLimit:build_test_attr_report(mock_device, + 1000)} + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.MaxCoolSetpointLimit:build_test_attr_report(mock_device, + 3500)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange( + { + unit = 'C', + value = {minimum = 10.0, maximum = 35.0} + } + )) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Thermostat heating setpoint bounds are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, + 1000)} + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, + 3500)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange( + { + unit = 'C', + value = {minimum = 10.0, maximum = 35.0} + } + )) + } + }, + { + min_api_version = 17 } ) @@ -162,7 +273,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -184,7 +298,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -201,6 +318,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("cooling")) } + }, + { + min_api_version = 17 } ) @@ -239,7 +359,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -256,6 +379,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 17 } ) @@ -284,7 +410,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -312,7 +441,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -340,7 +472,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -368,7 +503,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -395,7 +533,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -423,7 +564,10 @@ test.register_coroutine_test( FanControl.attributes.FanMode:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -451,7 +595,10 @@ test.register_coroutine_test( FanControl.attributes.FanMode:read(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -494,7 +641,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -505,6 +655,22 @@ test.register_message_test( direction = "receive", message = {mock_device.id, "added"} }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, { channel = "zigbee", direction = "send", @@ -553,6 +719,38 @@ test.register_message_test( Thermostat.attributes.SystemMode:read(mock_device) } }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:read(mock_device) + } + }, { channel = "zigbee", direction = "send", @@ -585,6 +783,9 @@ test.register_message_test( PowerConfiguration.attributes.BatteryAlarmState:read(mock_device) } }, + }, + { + min_api_version = 17 } ) @@ -644,6 +845,38 @@ test.register_message_test( Thermostat.attributes.SystemMode:read(mock_device) } }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:read(mock_device) + } + }, { channel = "zigbee", direction = "send", @@ -676,6 +909,9 @@ test.register_message_test( PowerConfiguration.attributes.BatteryAlarmState:read(mock_device) } }, + }, + { + min_api_version = 17 } ) @@ -693,6 +929,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("cool")) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat_wallhero_3in1.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat_wallhero_3in1.lua new file mode 100644 index 0000000000..a93a3a36ee --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat_wallhero_3in1.lua @@ -0,0 +1,1240 @@ +-- Copyright 2026 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local Scenes = clusters.Scenes +local OnOff = clusters.OnOff +local FanControl = clusters.FanControl +local Thermostat = clusters.Thermostat +local ThermostatMode = capabilities.thermostatMode +local FanMode = capabilities.fanMode +local button_attr = capabilities.button.button + +local SUPPORTED_FAN_MODES = { + { "auto", "high", "medium", "low"}, +} + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("thermostat-thirty-buttons-wallhero.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "WALL HERO", + model = "ACL-403STC1", + server_clusters = { 0x0005,0x0006,0x0201,0x0202, 0x0203 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + ThermostatMode.supportedThermostatModes({"cool", "dryair", "fanonly", "heat"}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "fan", + FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.temperatureSetpoint.temperatureSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "heat", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.switch.switch.off() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "heat", + capabilities.switch.switch.off() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "fan", + capabilities.switch.switch.off() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + ThermostatMode.thermostatMode.cool() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + FanMode.fanMode.auto() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "fan", + FanMode.fanMode.auto() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.temperatureSetpoint.temperatureSetpoint({value = 26, unit = "C"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "heat", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 26, unit = "C"}) + ) + ) + + for _, component in pairs(mock_device.profile.components) do + if component.id ~= "main" and component.id ~= "heat" and component.id ~= "fan" then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + component.id, + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + component.id, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.LocalTemperature:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device):to_endpoint(0x01) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.SystemMode:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + FanControl.attributes.FanMode:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + FanControl.attributes.FanMode:read(mock_device):to_endpoint(0x02) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x03) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device):to_endpoint(0x03) + } + ) + test.socket:set_time_advance_per_select(0.1) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Reported on status should be handled: on ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature reports using the temperatureMeasurement should be handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, + 2500):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Cooling setpoint reports temperatureSetpoint are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, + 2500):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({ value = 25.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Thermostat running mode reports 3 cool are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 3):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("cool")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Thermostat running mode reports 4 heat are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 4):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("heat")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Thermostat running mode reports 8 dryair are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 8):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("dryair")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Thermostat running mode reports 1 auto are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 1):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("auto")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports 1 low are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 1):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("low")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports 2 medium are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 2):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("medium")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports 3 high are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 3):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("high")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports 5 auto are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 5):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("auto")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("heat", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("heat", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Heating setpoint reports heatingSetpoint are handled : ep3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, + 2500):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("heat", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 1):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("low")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 2):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("medium")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 3):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("high")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "FanControl fan mode reports 5 auto are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 5):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("auto")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : ep3", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "heat", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x03) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : ep3", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "heat", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x03) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "fan", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "fan", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability temperatureSetpoint command setpoint 27 ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = { 27 } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2700):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability thermostat command cool ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = {"cool" } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 3):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability thermostat command dryair ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = {"dryair" } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 8):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability thermostat command fanonly ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = {"fanonly" } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 7):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability thermostat command heat ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = { "heat"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 4):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command auto ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode", args = {"auto"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 5):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command low ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode", args = {"low"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 1):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command medium ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode", args = {"medium"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 2):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command high ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode" , args = {"high"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 3):to_endpoint(0x01) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability thermostatHeatingSetpoint command setHeatingSetpoint 27 ep3", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatHeatingSetpoint", component = "heat", command = "setHeatingSetpoint", args = { 27 } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2700):to_endpoint(0x03) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command auto ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode", args = {"auto"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 5):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command low ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode", args = {"low"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 1):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command medium ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode", args = {"medium"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 2):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability fanMode command high ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode" , args = {"high"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 3):to_endpoint(0x02) } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "RecallScene command should be handled", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 4) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 5) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 6) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button3", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 7) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button4", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 8) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button5", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 9) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button6", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 10) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button7", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 11) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button8", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 12) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button9", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 13) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button10", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 14) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button11", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 15) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button12", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 16) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button13", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 17) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button14", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 18) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button15", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 19) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button16", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 20) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button17", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 21) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button18", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 22) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button19", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 23) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button20", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 24) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button21", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 25) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button22", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 26) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button23", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 27) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button24", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 28) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button25", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 29) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button26", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 30) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button27", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 31) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button28", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 32) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button29", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 33) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button30", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 34) }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/vimar/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/vimar/can_handle.lua new file mode 100644 index 0000000000..3aa0478f46 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/vimar/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local vimar_thermostat_can_handle = function(opts, driver, device) + local VIMAR_THERMOSTAT_FINGERPRINT = { + mfr = "Vimar", + model = "WheelThermostat_v1.0" + } + + if device:get_manufacturer() == VIMAR_THERMOSTAT_FINGERPRINT.mfr and + device:get_model() == VIMAR_THERMOSTAT_FINGERPRINT.model then + return true, require("vimar") + end + return false +end + +return vimar_thermostat_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua b/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua index b4070cf569..7ad274449f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local clusters = require "st.zigbee.zcl.clusters" @@ -38,11 +28,6 @@ local VIMAR_THERMOSTAT_MODE_MAP = { [ThermostatSystemMode.HEAT] = ThermostatMode.thermostatMode.heat, } -local VIMAR_THERMOSTAT_FINGERPRINT = { - mfr = "Vimar", - model = "WheelThermostat_v1.0" -} - -- NOTE: This is a global variable to use in order to save the current thermostat profile local VIMAR_CURRENT_PROFILE = "_vimarThermostatCurrentProfile" @@ -50,10 +35,6 @@ local VIMAR_THERMOSTAT_HEATING_PROFILE = "thermostat-fanless-heating-no-fw" local VIMAR_THERMOSTAT_COOLING_PROFILE = "thermostat-fanless-cooling-no-fw" -local vimar_thermostat_can_handle = function(opts, driver, device) - return device:get_manufacturer() == VIMAR_THERMOSTAT_FINGERPRINT.mfr and - device:get_model() == VIMAR_THERMOSTAT_FINGERPRINT.model -end local vimar_thermostat_supported_modes_handler = function(driver, device, supported_modes) device:emit_event( @@ -182,7 +163,7 @@ local vimar_thermostat_subdriver = { } }, doConfigure = vimar_thermostat_do_configure, - can_handle = vimar_thermostat_can_handle + can_handle = require("vimar.can_handle"), } return vimar_thermostat_subdriver diff --git a/drivers/SmartThings/zigbee-thermostat/src/wallhero/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/wallhero/can_handle.lua new file mode 100644 index 0000000000..476deedf2b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/wallhero/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "wallhero.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("wallhero") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/wallhero/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/wallhero/fingerprints.lua new file mode 100644 index 0000000000..d1a79015f1 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/wallhero/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "WALL HERO", model = "ACL-403STC1" } +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/wallhero/init.lua b/drivers/SmartThings/zigbee-thermostat/src/wallhero/init.lua new file mode 100644 index 0000000000..807f2e564b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/wallhero/init.lua @@ -0,0 +1,232 @@ +-- Copyright 2026 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local log = require "log" +local socket = require "cosock.socket" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local Scenes = zcl_clusters.Scenes +local OnOff = zcl_clusters.OnOff +local FanControl = zcl_clusters.FanControl +local Thermostat = zcl_clusters.Thermostat +local ThermostatMode = capabilities.thermostatMode +local FanMode = capabilities.fanMode + +local function scenes_cluster_handler(driver, device, zb_rx) + local additional_fields = { + state_change = true + } + --Endpoint 1 to 3 are respectively the air conditioner, fresh air, and floor heating. Endpoint 4 to Endpoint 33 are buttons. + local ep = zb_rx.address_header.src_endpoint.value - 3 + local button_name = "button" .. ep + local event = capabilities.button.button.pushed(additional_fields) + local comp = device.profile.components[button_name] + if comp ~= nil then + device:emit_component_event(comp, event) + else + log.warn("Attempted to emit button event for unknown button: " .. button_name) + end +end + +local function on_off_attr_handler(driver, device, value, zb_rx) + local attr = capabilities.switch.switch + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, value.value and attr.on() or attr.off()) +end + +local function on_handler(driver, device, command) + device:send_to_component(command.component, OnOff.server.commands.On(device)) +end + +local function off_handler(driver, device, command) + device:send_to_component(command.component, OnOff.server.commands.Off(device)) +end + +local function caps_temperatureSetpoint_handler(driver, device, command) + local temperature = tonumber(command.args.setpoint)*100 + device:send_to_component(command.component, Thermostat.attributes.OccupiedCoolingSetpoint:write(device,temperature)) +end + +local SUPPORTED_FAN_MODES = { + { "auto", "high", "medium", "low"}, +} + +local FAN_MODE_TO_ZIGBEE = { + ["auto"] = 0x05, + ["low"] = 0x01, + ["medium"] = 0x02, + ["high"] = 0x03 +} + +local ZIGBEE_TO_FAN_MODES = { + [1] = "low" , + [2] = "medium" , + [3] = "high" , + [5] = "auto" +} + +local function fan_mode_attr_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + local str = ZIGBEE_TO_FAN_MODES[value.value] + if ep == 1 then + device:emit_component_event(device.profile.components.main,FanMode.fanMode({ value = str })) + elseif ep == 2 then + device:emit_component_event(device.profile.components.fan,FanMode.fanMode({ value = str })) + end +end + +local function setFanMode_handler(driver, device, command) + local value = FAN_MODE_TO_ZIGBEE[command.args.fanMode] + device:send_to_component(command.component, FanControl.attributes.FanMode:write(device, value)) +end + +local function thermostat_attr_occupiedCoolingSetpoint_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + if ep == 1 then + local temp = value.value/100 + device:emit_component_event(device.profile.components.main,capabilities.temperatureSetpoint.temperatureSetpoint({value = temp, unit = "C"})) + end +end + +local function do_refresh(driver, device) + device:send_to_component("main", OnOff.attributes.OnOff:read(device)) + device:send_to_component("main", Thermostat.attributes.LocalTemperature:read(device)) + device:send_to_component("main", Thermostat.attributes.OccupiedCoolingSetpoint:read(device)) + + device:send_to_component("main", Thermostat.attributes.SystemMode:read(device)) + device:send_to_component("main", FanControl.attributes.FanMode:read(device)) + device:send_to_component("fan", OnOff.attributes.OnOff:read(device)) + + device:send_to_component("fan", FanControl.attributes.FanMode:read(device)) + device:send_to_component("heat", OnOff.attributes.OnOff:read(device)) + device:send_to_component("heat", Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) +end + +local function added_handler(driver, device) + device:emit_component_event(device.profile.components.main,ThermostatMode.supportedThermostatModes({"cool", "dryair", "fanonly", "heat"}, { visibility = { displayed = false } })) + device:emit_component_event(device.profile.components.main,FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }})) + device:emit_component_event(device.profile.components.fan,FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }})) + + device:emit_component_event(device.profile.components.main,capabilities.temperatureSetpoint.temperatureSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" })) + device:emit_component_event(device.profile.components.heat,capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" })) + + device:emit_component_event(device.profile.components.main,capabilities.switch.switch.off()) + device:emit_component_event(device.profile.components.heat,capabilities.switch.switch.off()) + device:emit_component_event(device.profile.components.fan,capabilities.switch.switch.off()) + + device:emit_component_event(device.profile.components.main,ThermostatMode.thermostatMode.cool()) + device:emit_component_event(device.profile.components.main,FanMode.fanMode.auto()) + device:emit_component_event(device.profile.components.fan,FanMode.fanMode.auto()) + device:emit_component_event(device.profile.components.main,capabilities.temperatureSetpoint.temperatureSetpoint({value = 26, unit = "C"})) + device:emit_component_event(device.profile.components.heat,capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 26, unit = "C"})) + + for _, component in pairs(device.profile.components) do + if component.id ~= "main" and component.id ~= "heat" and component.id ~= "fan" then + device:emit_component_event(component, + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) + device:emit_component_event(component, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } })) + -- Without this time delay, the state of some buttons cannot be updated + socket.sleep(1) + end + end + do_refresh(driver, device) +end + +local function component_to_endpoint(device, component_id) + local ep_num + if component_id == "main" then + ep_num = 1 + elseif component_id == "fan" then + ep_num = 2 + elseif component_id == "heat" then + ep_num = 3 + end + return ep_num or device.fingerprinted_endpoint_id +end + +local function endpoint_to_component(device, ep) + if ep > 3 then + ep = ep - 3 + local button_comp = string.format("button%d", ep) + if device.profile.components[button_comp] ~= nil then + return button_comp + else + return nil + end + else + if ep == 1 then + return "main" + elseif ep == 2 then + return "fan" + else + return "heat" + end + end +end + +local device_init = function(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local wallhero_thermostat_3in1 = { + NAME = "Wall Hero thermostat 3in1", + supported_capabilities = { + capabilities.switch, + capabilities.temperatureSetpoint + }, + lifecycle_handlers = { + init = device_init, + added = added_handler + }, + health_check = false, + zigbee_handlers = { + cluster = { + [Scenes.ID] = { + [Scenes.server.commands.RecallScene.ID] = scenes_cluster_handler, + } + }, + attr = { + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = on_off_attr_handler + }, + [FanControl.ID] = { + [FanControl.attributes.FanMode.ID] = fan_mode_attr_handler + }, + [Thermostat.ID] = { + [Thermostat.attributes.OccupiedCoolingSetpoint.ID] = thermostat_attr_occupiedCoolingSetpoint_handler + } + } + }, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = on_handler, + [capabilities.switch.commands.off.NAME] = off_handler + }, + [capabilities.temperatureSetpoint.ID] = { + [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = caps_temperatureSetpoint_handler + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = setFanMode_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + + can_handle = require("wallhero.can_handle") +} + +return wallhero_thermostat_3in1 diff --git a/drivers/SmartThings/zigbee-thermostat/src/zenwithin/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/can_handle.lua new file mode 100644 index 0000000000..818764c88a --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function zenwithin_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Zen Within" and device:get_model() == "Zen-01" then + return true, require("zenwithin") + end + return false +end + +return zenwithin_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua index e4b11de8be..f39da8afe5 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local utils = require "st.utils" @@ -38,9 +28,9 @@ local HEATING_SETPOINT = "heatingSetpoint" local BAT_MIN = 3.4 -- voltage when device UI starts to die, ie, when battery fails local BAT_MAX = 6.0 -- 4 batteries at 1.5V (6.0V) -local DEFAULT_MIN_SETPOINT = 4.0 -local DEFAULT_MAX_SETPOINT = 37.5 - +local DEFAULT_MIN_SETPOINT = 400 -- 4.0C +local DEFAULT_MAX_SETPOINT = 3750 -- 37.5C +local FAHRENHEIT_THRESHOLD = 40 -- 40C -- In zenwithin sub driver, supported thermostat mode follow preference option because sensor always returns 'all mode possible' local SUPPORTED_THERMOSTAT_MODES = { [0x01] = { ModeAttribute.off.NAME, ModeAttribute.heat.NAME }, @@ -103,11 +93,12 @@ local update_device_setpoint = function(device) local current_mode = device:get_latest_state("main", ThermostatMode.ID, ModeAttribute.NAME) if (current_mode == ModeAttribute.heat.NAME or current_mode == ModeAttribute.emergency_heat.NAME) and cooling_setpoint ~= nil then - cooling_setpoint = device:get_latest_state("main", ThermostatCoolingSetpoint.ID, ThermostatCoolingSetpoint.coolingSetpoint.NAME) -- tried to set cooling setpoint while device was in heat mode + cooling_setpoint = device:get_latest_state("main", ThermostatCoolingSetpoint.ID, ThermostatCoolingSetpoint.coolingSetpoint.NAME) device:emit_event(ThermostatCoolingSetpoint.coolingSetpoint({value = cooling_setpoint, unit = "C"})) cooling_setpoint = nil elseif (current_mode == ModeAttribute.cool.NAME) and heating_setpoint ~= nil then + -- tried to set heating setpoint while device was in cool mode heating_setpoint = device:get_latest_state("main", ThermostatHeatingSetpoint.ID, ThermostatHeatingSetpoint.heatingSetpoint.NAME) device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({value = heating_setpoint, unit = "C"})) heating_setpoint = nil @@ -118,22 +109,23 @@ local update_device_setpoint = function(device) end if (heating_setpoint ~= nil) then - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(heating_setpoint*100))) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(heating_setpoint))) end if (cooling_setpoint ~= nil) then - device:send(Thermostat.attributes.OccupiedCoolingSetpoint:write(device, utils.round(cooling_setpoint*100))) + device:send(Thermostat.attributes.OccupiedCoolingSetpoint:write(device, utils.round(cooling_setpoint))) end end local set_cooling_setpoint = function(driver, device, command) local value = command.args.setpoint - if value >= 40 then -- we got a command in fahrenheit + if value >= FAHRENHEIT_THRESHOLD then -- we got a command in fahrenheit value = utils.f_to_c(value) end - value = utils.clamp_value(value, - device:get_field(MIN_HEAT_LIMIT) or DEFAULT_MIN_SETPOINT, - device:get_field(MAX_HEAT_LIMIT) or DEFAULT_MAX_SETPOINT) + -- Scale up to centidegrees to align with the rest of the values + value = utils.clamp_value(value * 100, + device:get_field(MIN_COOL_LIMIT) or DEFAULT_MIN_SETPOINT, + device:get_field(MAX_COOL_LIMIT) or DEFAULT_MAX_SETPOINT) device:set_field(COOLING_SETPOINT, value) local current_mode = device:get_latest_state("main", ThermostatMode.ID, ModeAttribute.NAME) if current_mode == ModeAttribute.cool.NAME or current_mode == ModeAttribute.auto.NAME then @@ -148,10 +140,11 @@ end local set_heating_setpoint = function(driver, device, command) local value = command.args.setpoint - if value >= 40 then -- we got a command in fahrenheit + if value >= FAHRENHEIT_THRESHOLD then -- we got a command in fahrenheit value = utils.f_to_c(value) end - value = utils.clamp_value(value, + -- Scale up to centidegrees to align with the rest of the values + value = utils.clamp_value(value * 100, device:get_field(MIN_HEAT_LIMIT) or DEFAULT_MIN_SETPOINT, device:get_field(MAX_HEAT_LIMIT) or DEFAULT_MAX_SETPOINT) device:set_field(HEATING_SETPOINT, value) @@ -213,9 +206,7 @@ local zenwithin_thermostat = { infoChanged = info_changed, init = battery_defaults.build_linear_voltage_init(BAT_MIN, BAT_MAX) }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Zen Within" and device:get_model() == "Zen-01" - end + can_handle = require("zenwithin.can_handle"), } return zenwithin_thermostat diff --git a/drivers/SmartThings/zigbee-thing/src/init.lua b/drivers/SmartThings/zigbee-thing/src/init.lua index f414839d3a..47f4a3629a 100644 --- a/drivers/SmartThings/zigbee-thing/src/init.lua +++ b/drivers/SmartThings/zigbee-thing/src/init.lua @@ -19,6 +19,7 @@ local zigbee_thing_template = { supported_capabilities = { capabilities.refresh, }, + health_check = false, } local zigbee_thing = ZigbeeDriver("zigbee_thing", zigbee_thing_template) diff --git a/drivers/SmartThings/zigbee-valve/src/ezex/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/ezex/can_handle.lua new file mode 100644 index 0000000000..f16f04858c --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/ezex/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function ezex_can_handle(opts, driver, device, ...) + local clusters = require "st.zigbee.zcl.clusters" + if device:get_model() == "E253-KR0B0ZX-HA" and not device:supports_server_cluster(clusters.PowerConfiguration.ID) then + return true, require("ezex") + end + return false +end + +return ezex_can_handle diff --git a/drivers/SmartThings/zigbee-valve/src/ezex/init.lua b/drivers/SmartThings/zigbee-valve/src/ezex/init.lua new file mode 100644 index 0000000000..c32fe60bac --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/ezex/init.lua @@ -0,0 +1,67 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local IASZone = clusters.IASZone +local Basic = clusters.Basic +local OnOff = clusters.OnOff + +local configuration = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 0, + maximum_interval = 3600, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1 + }, + { + cluster = Basic.ID, + attribute = Basic.attributes.PowerSource.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Basic.attributes.PowerSource.base_type, + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnOff.ID, + minimum_interval = 0, + maximum_interval = 600, + data_type = OnOff.attributes.OnOff.base_type + } +} + +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + -- this is cribbed from the DTH + if zone_status:is_battery_low_set() then + device:emit_event(capabilities.battery.battery(5)) + else + device:emit_event(capabilities.battery.battery(50)) + end +end + +local function device_init(driver, device) + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end +end + +local ezex_valve = { + NAME = "Ezex Valve", + zigbee_handlers = { + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, + } + }, + lifecycle_handlers = { + init = device_init + }, + can_handle = require("ezex.can_handle"), +} + +return ezex_valve diff --git a/drivers/SmartThings/zigbee-valve/src/init.lua b/drivers/SmartThings/zigbee-valve/src/init.lua index 693a0cf311..717012e2bb 100644 --- a/drivers/SmartThings/zigbee-valve/src/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -51,9 +41,9 @@ local zigbee_valve_driver_template = { lifecycle_handlers = { added = device_added }, - sub_drivers = { - require("sinope") - } + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_valve_driver_template, zigbee_valve_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-valve/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-valve/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-valve/src/sinope/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/sinope/can_handle.lua new file mode 100644 index 0000000000..f533d120e0 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sinope/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function sinope_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Sinope Technologies" then + return true, require("sinope") + end + return false +end + +return sinope_can_handle diff --git a/drivers/SmartThings/zigbee-valve/src/sinope/init.lua b/drivers/SmartThings/zigbee-valve/src/sinope/init.lua index 6a0075cd33..0c6994ad00 100644 --- a/drivers/SmartThings/zigbee-valve/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/sinope/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" @@ -22,7 +12,9 @@ local PowerConfiguration = clusters.PowerConfiguration local function device_init(driver, device) battery_defaults.use_battery_voltage_handling(device) -- according to the DTH, this attribute cannot be configured for reporting - device.thread:call_on_schedule(900, function() device:send(PowerConfiguration.attributes.BatteryVoltage:read()) end) + device.thread:call_on_schedule(900, function() + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + end) end local function battery_voltage_handler(driver, device, command) @@ -59,9 +51,7 @@ local sinope_valve = { lifecycle_handlers = { init = device_init }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Sinope Technologies" - end + can_handle = require("sinope.can_handle"), } return sinope_valve diff --git a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua new file mode 100644 index 0000000000..258579c7fb --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("sinope"), + lazy_load_if_possible("ezex"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua new file mode 100644 index 0000000000..d9db636eab --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua @@ -0,0 +1,360 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local Basic = clusters.Basic +local OnOff = clusters.OnOff +local IASZone = clusters.IASZone +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("valve-battery-powerSource.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "", + model = "E253-KR0B0ZX-HA", + server_clusters = {0x0000, 0x0006, 0x0500} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_message_test( + "OnOff(on) reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + } + }, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "OnOff(off) reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0008) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(5)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report - not low should return 50%", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "PowerSource(unknown) reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Basic.attributes.PowerSource:build_test_attr_report(mock_device, + 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "PowerSource(mains) reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Basic.attributes.PowerSource:build_test_attr_report(mock_device, + 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "PowerSource(battery) reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Basic.attributes.PowerSource:build_test_attr_report(mock_device, + 0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "PowerSource(dc) reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Basic.attributes.PowerSource:build_test_attr_report(mock_device, + 0x04) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability(valve) command(open) on should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Capability(valve) command(off) on should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doConfigure lifecycle should configure device", + function () + -- test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Basic.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:configure_reporting(mock_device, 30, 21600) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASZone.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 0, 3600, 1) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Device added event should refresh device states", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_device.id, "added" }, + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua index 8329d762f4..9ba28f751e 100644 --- a/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -36,9 +26,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -59,7 +47,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.PowerSource:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -104,7 +95,10 @@ test.register_coroutine_test( zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,7 +118,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.PowerSource:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -132,7 +129,10 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 55) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(20)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -140,7 +140,21 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 0) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(0)) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery voltage above max should clamp to 100 percent", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 65) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua index 9bd4f1aa7c..72038fdb37 100644 --- a/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua +++ b/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -37,9 +27,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -57,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 17 } ) @@ -75,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 17 } ) @@ -91,6 +85,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) @@ -108,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown()) } + }, + { + min_api_version = 17 } ) @@ -125,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 17 } ) @@ -142,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 17 } ) @@ -159,6 +165,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc()) } + }, + { + min_api_version = 17 } ) @@ -175,6 +184,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -191,6 +203,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -237,7 +252,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -277,7 +295,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -315,7 +334,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-vent/src/init.lua b/drivers/SmartThings/zigbee-vent/src/init.lua index bdd643d06f..3e5e261178 100644 --- a/drivers/SmartThings/zigbee-vent/src/init.lua +++ b/drivers/SmartThings/zigbee-vent/src/init.lua @@ -19,6 +19,11 @@ local defaults = require "st.zigbee.defaults" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + local KEEN_PRESSURE_ATTRIBUTE = 0x0020 local KEEN_MFG_CODE = 0x115B @@ -58,10 +63,35 @@ local function switch_on_handler(driver, device, command) device:send(clusters.Level.commands.MoveToLevelWithOnOff(device, last_level, 0xFFFF)) end +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + local function refresh_handler(driver, device, command) device:send(clusters.Level.attributes.CurrentLevel:read(device)) device:send(clusters.OnOff.attributes.OnOff:read(device)) device:send(clusters.TemperatureMeasurement.attributes.MeasuredValue:read(device)) + device:send(clusters.TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) + device:send(clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) device:send(clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) local pressure_read = cluster_base.read_manufacturer_specific_attribute(device, clusters.PressureMeasurement.ID, KEEN_PRESSURE_ATTRIBUTE, KEEN_MFG_CODE) @@ -102,6 +132,10 @@ local zigbee_vent_driver = { }, [clusters.Level.ID] = { [clusters.Level.attributes.CurrentLevel.ID] = level_report_handler + }, + [clusters.TemperatureMeasurement.ID] = { + [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), } } }, @@ -119,7 +153,8 @@ local zigbee_vent_driver = { lifecycle_handlers = { doConfigure = do_configure, added = refresh_handler - } + }, + health_check = false, } defaults.register_for_default_handlers(zigbee_vent_driver, zigbee_vent_driver.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua b/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua index dcb1efcb7c..26420c997b 100644 --- a/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua +++ b/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua @@ -17,6 +17,7 @@ local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local OnOff = clusters.OnOff local Level = clusters.Level +local TemperatureMeasurement = clusters.TemperatureMeasurement local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local base64 = require "st.base64" @@ -32,9 +33,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -60,6 +59,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -77,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -94,6 +99,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -109,7 +117,53 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, Level.attributes.CurrentLevel:read(mock_device)}) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -144,6 +198,22 @@ test.register_message_test( clusters.TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) } }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, { channel = "zigbee", direction = "send", @@ -170,7 +240,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -226,7 +297,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) @@ -244,7 +318,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.capability:__queue_receive({mock_device.id, { capability = "switch", component = "main", command = "on", args = {}}}) test.socket.zigbee:__expect_send({mock_device.id, Level.commands.MoveToLevelWithOnOff(mock_device, math.floor(83 / 100 * 254), 0xFFFF)}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -254,7 +331,10 @@ test.register_coroutine_test( mock_device, 50 )}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(50))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -263,7 +343,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({mock_device.id, zigbee_test_utils.build_attribute_report(mock_device, clusters.PressureMeasurement.ID, {{ KEEN_PRESSURE_ATTRIBUTE, data_types.Uint16.ID, 10000}}, KEEN_MFG_CODE)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({value = 1, unit = "kPa"}))) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml index 5b1c1e4977..e7007de4f7 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml @@ -113,4 +113,9 @@ zigbeeManufacturer: deviceLabel: NEO Water Leak Sensor manufacturer: NEO model: NAS_WS11 - deviceProfileName: water-battery \ No newline at end of file + deviceProfileName: water-battery + - id: MultIR/MIR-WA100 + deviceLabel: MultiIR Water Leak Sensor MIR-WA100 + manufacturer: MultIR + model: MIR-WA100 + deviceProfileName: water-battery-no-fw-update diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/profiles/water-battery-no-fw-update.yml b/drivers/SmartThings/zigbee-water-leak-sensor/profiles/water-battery-no-fw-update.yml new file mode 100644 index 0000000000..03a891c23b --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/profiles/water-battery-no-fw-update.yml @@ -0,0 +1,12 @@ +name: water-battery-no-fw-update +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..f0ba630b6f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.flood.agl02" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua index 7ad385a13a..595a10f64e 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -10,9 +13,6 @@ local MFG_CODE = 0x115F local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.flood.agl02" } -} local CONFIGURATIONS = { { @@ -25,14 +25,6 @@ local CONFIGURATIONS = { } } -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_added(driver, device) device:emit_event(capabilities.waterSensor.water.dry()) @@ -47,7 +39,6 @@ local function device_init(driver, device) for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -57,7 +48,7 @@ local aqara_contact_handler = { init = device_init, added = device_added }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_contact_handler diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua index a5086d979e..92b7ef226b 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local PowerConfiguration = clusters.PowerConfiguration diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/can_handle.lua new file mode 100644 index 0000000000..e7c5dd9c23 --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and device:get_model() == "FLSZB-110" then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua index b85b58eb7b..4357eb91b2 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local TemperatureMeasurement = clusters.TemperatureMeasurement @@ -26,7 +16,6 @@ local function device_init(driver, device) battery_defaults.build_linear_voltage_init(config.minV, config.maxV)(driver, device) elseif (config.cluster) then device:add_configured_attribute(config) - device:add_monitored_attribute(config) end end end @@ -44,9 +33,7 @@ local frient_water_leak_sensor = { init = device_init, doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" and device:get_model() == "FLSZB-110" - end + can_handle = require("frient.can_handle"), } return frient_water_leak_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua index d7969a8e98..6f4b879108 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -18,6 +8,35 @@ local defaults = require "st.zigbee.defaults" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local constants = require "st.zigbee.constants" local configurationMap = require "configurations" +local TemperatureMeasurement = (require "st.zigbee.zcl.clusters").TemperatureMeasurement + +local temperature_measurement_defaults = { + MIN_TEMP = "MIN_TEMP", + MAX_TEMP = "MAX_TEMP" +} + +local temperature_measurement_min_max_attr_handler = function(minOrMax) + return function(driver, device, value, zb_rx) + local raw_temp = value.value + local celc_temp = raw_temp / 100.0 + local temp_scale = "C" + + device:set_field(string.format("%s", minOrMax), celc_temp) + + local min = device:get_field(temperature_measurement_defaults.MIN_TEMP) + local max = device:get_field(temperature_measurement_defaults.MAX_TEMP) + + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = temp_scale })) + device:set_field(temperature_measurement_defaults.MIN_TEMP, nil) + device:set_field(temperature_measurement_defaults.MAX_TEMP, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) @@ -30,12 +49,16 @@ local function device_init(driver, device) battery_defaults.enable_battery_voltage_table(device, config.battery_voltage_table) elseif (config.cluster) then device:add_configured_attribute(config) - device:add_monitored_attribute(config) end end end end +local function added_handler(self, device) + device:send(TemperatureMeasurement.attributes.MaxMeasuredValue:read(device)) + device:send(TemperatureMeasurement.attributes.MinMeasuredValue:read(device)) +end + local zigbee_water_driver_template = { supported_capabilities = { capabilities.waterSensor, @@ -43,21 +66,25 @@ local zigbee_water_driver_template = { capabilities.temperatureMeasurement, capabilities.battery, }, + zigbee_handlers = { + attr = { + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MIN_TEMP), + [TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temperature_measurement_min_max_attr_handler(temperature_measurement_defaults.MAX_TEMP), + } + } + }, lifecycle_handlers = { - init = device_init + init = device_init, + added = added_handler }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, - sub_drivers = { - require("aqara"), - require("zigbee-water-freeze"), - require("leaksmart"), - require("frient"), - require("thirdreality"), - require("sengled"), - require("sinope") - }, + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(zigbee_water_driver_template, zigbee_water_driver_template.supported_capabilities) +defaults.register_for_default_handlers(zigbee_water_driver_template, + zigbee_water_driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) local zigbee_water_driver = ZigbeeDriver("zigbee-water", zigbee_water_driver_template) zigbee_water_driver:run() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/can_handle.lua new file mode 100644 index 0000000000..67c6f425ca --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function leaksmart_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "WAXMAN" and device:get_model() == "leakSMART Water Sensor V2" then + return true, require("leaksmart") + end + return false +end + +return leaksmart_can_handle diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua index 010379df0f..a8ab5212b5 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -58,9 +48,7 @@ local leaksmart_water_sensor = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "WAXMAN" and device:get_model() == "leakSMART Water Sensor V2" - end + can_handle = require("leaksmart.can_handle"), } return leaksmart_water_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/can_handle.lua new file mode 100644 index 0000000000..68d774fb8f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sengled_products = function(opts, driver, device, ...) + local FINGERPRINTS = require("sengled.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sengled") + end + end + return false +end + +return is_sengled_products diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/fingerprints.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/fingerprints.lua new file mode 100644 index 0000000000..2c3f527da6 --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "sengled", model = "E1L-G7K" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua index f34cd8181f..0010073033 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua @@ -1,12 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "sengled", model = "E1L-G7K" } -} local CONFIGURATIONS = { { @@ -27,21 +27,12 @@ local CONFIGURATIONS = { } } -local is_sengled_products = function(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) for _, attribute in ipairs(CONFIGURATIONS) do device:add_configured_attribute(attribute) - device:add_monitored_attribute(attribute) end end @@ -50,7 +41,7 @@ local sengled_water_leak_sensor_handler = { lifecycle_handlers = { init = device_init }, - can_handle = is_sengled_products + can_handle = require("sengled.can_handle"), } return sengled_water_leak_sensor_handler diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/can_handle.lua new file mode 100644 index 0000000000..757e3ca0db --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sinope_water_sensor = function(opts, driver, device) + local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" + if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then + return true, require("sinope") + else + return false + end +end + +return is_sinope_water_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua index 65cc802697..7bf6f8be82 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua @@ -1,23 +1,11 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local IASZone = zcl_clusters.IASZone -local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" - local generate_event_from_zone_status = function(driver, device, zone_status, zb_rx) local event if zone_status:is_alarm1_set() then @@ -39,13 +27,6 @@ local ias_zone_status_change_handler = function(driver, device, zb_rx) generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) end -local is_sinope_water_sensor = function(opts, driver, device) - if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then - return true - else - return false - end -end local sinope_water_sensor = { NAME = "Sinope Water Leak Sensor", @@ -61,7 +42,7 @@ local sinope_water_sensor = { } } }, - can_handle = is_sinope_water_sensor + can_handle = require("sinope.can_handle"), } -return sinope_water_sensor \ No newline at end of file +return sinope_water_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..79d572579e --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sub_drivers.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("zigbee-water-freeze"), + lazy_load_if_possible("leaksmart"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("thirdreality"), + lazy_load_if_possible("sengled"), + lazy_load_if_possible("sinope"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua index 4fb2910d50..51be73b5b3 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -61,7 +51,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE , data_types.Uint8, 1) }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -76,7 +69,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -92,7 +96,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -109,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua index 30258012d4..d92293d30e 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,13 +46,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -107,7 +108,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -123,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -139,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -155,6 +165,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -171,6 +184,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -186,7 +202,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -202,7 +229,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -223,7 +261,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua index 4cae9883ca..ba6361bc9d 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,13 +46,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -113,7 +114,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -129,6 +133,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -145,6 +152,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -161,6 +171,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -177,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -192,7 +208,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -208,7 +235,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -229,7 +267,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua index df7fb96ba5..140608bfcc 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -40,9 +30,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -52,7 +40,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x001181}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.wet())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -61,7 +52,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x000081}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -70,7 +64,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x000581}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -79,7 +76,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x001081}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -88,28 +88,22 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x001281}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "Health check should check all relevant attributes", + "Added lifecycle should emit water dry event", function() test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) test.socket.capability:__expect_send( - { - mock_device.id, - { - capability_id = "waterSensor", component_id = "main", - attribute_id = "water", state={value="dry"} - } - } + mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) ) end, { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end + min_api_version = 17 } ) @@ -173,7 +167,10 @@ test.register_coroutine_test( IASZone.attributes.ZoneStatus:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua index 352841bdd4..7b28197ad6 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,13 +46,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -107,7 +108,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -123,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -139,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) } + }, + { + min_api_version = 17 } ) @@ -154,7 +164,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -170,7 +191,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -191,7 +223,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua index e1e57db4fc..1e7c70eba1 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +9,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement local IASCIEAddress = IASZone.attributes.IASCIEAddress local EnrollResponseCode = IASZone.types.EnrollResponseCode @@ -52,13 +43,24 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -92,7 +94,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua index 33f6c28f24..2a6fd6be79 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -40,9 +30,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -58,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -74,6 +65,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -90,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -106,6 +103,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -122,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -137,7 +140,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -154,49 +168,68 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) - test.wait_for_events() +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) +-- }) +-- test.wait_for_events() - test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send( - { - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - IASZone.attributes.ZoneStatus:read(mock_device) - } - ) - end, - { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end - } -) +-- test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- IASZone.attributes.ZoneStatus:read(mock_device) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) test.register_coroutine_test( "Configure should configure all necessary attributes", function () test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) test.socket.zigbee:__expect_send({ mock_device.id, @@ -278,7 +311,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -289,6 +325,22 @@ test.register_message_test( direction = "receive", message = {mock_device.id, "added"} }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, { channel = "capability", direction = "receive", @@ -323,7 +375,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua index 5cf7801637..e550d7d246 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,13 +46,24 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -107,7 +108,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -137,7 +141,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -152,7 +159,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -168,7 +186,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -189,7 +218,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua index d32f94e255..e3946056f4 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -46,6 +36,17 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Added lifecycle should read ApplicationVersion", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + test.socket.zigbee:__expect_send({mock_device.id, Basic.attributes.ApplicationVersion:read(mock_device)}) + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Refresh necessary attributes", function() @@ -53,7 +54,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -75,7 +79,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -97,7 +104,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -119,7 +129,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -141,7 +154,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua index b202dd450c..bf6bb7c4a9 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -30,9 +20,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -47,7 +35,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -63,7 +62,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -79,7 +89,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -95,7 +116,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } + } + }, + }, + { + min_api_version = 17 } ) @@ -111,10 +143,51 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) + } + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) + } + }, + { + min_api_version = 17 + } +) + test.register_message_test( "Battery percentage report should be handled", { @@ -128,49 +201,68 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 17 } ) -test.register_coroutine_test( - "Health check should check all relevant attributes", - function() - test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) - test.wait_for_events() +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) +-- }) +-- test.socket.zigbee:__expect_send({ +-- mock_device.id, +-- TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) +-- }) +-- test.wait_for_events() - test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.zigbee:__expect_send( - { - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - IASZone.attributes.ZoneStatus:read(mock_device) - } - ) - end, - { - test_init = function() - test.mock_device.add_test_device(mock_device) - test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") - end - } -) +-- test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval +-- test.socket.zigbee:__set_channel_ordering("relaxed") +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) +-- } +-- ) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device.id, +-- IASZone.attributes.ZoneStatus:read(mock_device) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) test.register_coroutine_test( "Configure should configure all necessary attributes", function () test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) test.socket.zigbee:__expect_send({ mock_device.id, @@ -252,7 +344,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -263,6 +358,22 @@ test.register_message_test( direction = "receive", message = {mock_device.id, "added"} }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, { channel = "capability", direction = "receive", @@ -297,7 +408,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua index ed03d10974..a8e951661f 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -41,9 +31,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_message_test( @@ -64,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 17 } ) @@ -85,7 +76,34 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = -25.0, unit = "C" })) } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Minimum & Maximum Temperature report should be handled (C)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:build_test_attr_report(mock_device, 2000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_attr_report(mock_device, 3000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -93,6 +111,14 @@ test.register_coroutine_test( function () test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure"}) test.socket.zigbee:__expect_send({ mock_device.id, @@ -198,7 +224,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -209,6 +238,22 @@ test.register_message_test( direction = "receive", message = {mock_device.id, "added"} }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MaxMeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) + } + }, { channel = "capability", direction = "receive", @@ -243,7 +288,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/can_handle.lua new file mode 100644 index 0000000000..044050b14f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_third_reality_water_leak_sensor(opts, driver, device) + local FINGERPRINTS = require("thirdreality.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("thirdreality") + end + end + return false +end + +return can_handle_third_reality_water_leak_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/fingerprints.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/fingerprints.lua new file mode 100644 index 0000000000..f56a027f4f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS = { + { mfr = "Third Reality, Inc", model = "3RWS18BZ"}, + { mfr = "THIRDREALITY", model = "3RWS18BZ"} +} + +return THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua index 99f14294e9..8a3819ac44 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local utils = require "st.utils" @@ -20,19 +10,7 @@ local PowerConfiguration = zcl_clusters.PowerConfiguration local APPLICATION_VERSION = "application_version" -local THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS = { - { mfr = "Third Reality, Inc", model = "3RWS18BZ"}, - { mfr = "THIRDREALITY", model = "3RWS18BZ"} -} -local function can_handle_third_reality_water_leak_sensor(opts, driver, device) - for _, fingerprint in ipairs(THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_added(driver, device) device:set_field(APPLICATION_VERSION, 0) @@ -73,7 +51,7 @@ local third_reality_water_leak_sensor = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_third_reality_water_leak_sensor + can_handle = require("thirdreality.can_handle"), } -return third_reality_water_leak_sensor \ No newline at end of file +return third_reality_water_leak_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/can_handle.lua new file mode 100644 index 0000000000..5850a8b11c --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function zigbee_water_freeze_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Ecolink" and device:get_model() == "FLZB1-ECO" then + return true, require("zigbee-water-freeze") + end + return false +end + +return zigbee_water_freeze_can_handle diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua index 68bd2ba9e0..82aca1b710 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -68,9 +58,7 @@ local zigbee_water_freeze = { init = battery_defaults.build_linear_voltage_init(2.2, 3.0), doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Ecolink" and device:get_model() == "FLZB1-ECO" - end + can_handle = require("zigbee-water-freeze.can_handle"), } return zigbee_water_freeze diff --git a/drivers/SmartThings/zigbee-watering-kit/config.yml b/drivers/SmartThings/zigbee-watering-kit/config.yml new file mode 100644 index 0000000000..b6b99bd8c4 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/config.yml @@ -0,0 +1,6 @@ +name: 'Zigbee Watering Kit' +packageKey: 'zigbee-watering-kit' +permissions: + zigbee: {} +description: "SmartThings driver for Zigbee Watering Kit devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/zigbee-watering-kit/fingerprints.yml b/drivers/SmartThings/zigbee-watering-kit/fingerprints.yml new file mode 100644 index 0000000000..c86afa526a --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/fingerprints.yml @@ -0,0 +1,6 @@ +zigbeeManufacturer: + - id: Third Reality/3RWK0148Z + deviceLabel: ThirdReality Smart Watering Kit + manufacturer: Third Reality, Inc + model: 3RWK0148Z + deviceProfileName: watering-kit-thirdreality \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-watering-kit/profiles/watering-kit-thirdreality.yml b/drivers/SmartThings/zigbee-watering-kit/profiles/watering-kit-thirdreality.yml new file mode 100644 index 0000000000..ace5ce767c --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/profiles/watering-kit-thirdreality.yml @@ -0,0 +1,23 @@ +name: watering-kit-thirdreality +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: battery + version: 1 + - id: hardwareFault + version: 1 + - id: fanSpeed + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Irrigation +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-ThirdReality_watering_Kit diff --git a/drivers/SmartThings/zigbee-watering-kit/src/init.lua b/drivers/SmartThings/zigbee-watering-kit/src/init.lua new file mode 100644 index 0000000000..7e23c2fef9 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/init.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" + +local zigbee_water_driver_template = { + supported_capabilities = { + capabilities.switch, + capabilities.battery, + capabilities.hardwareFault, + capabilities.fanSpeed, + capabilities.mode + }, + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, +} + +defaults.register_for_default_handlers(zigbee_water_driver_template, zigbee_water_driver_template.supported_capabilities) +local driver = ZigbeeDriver("zigbee-watering-kit", zigbee_water_driver_template) +driver:run() diff --git a/drivers/SmartThings/zigbee-watering-kit/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-watering-kit/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-watering-kit/src/sub_drivers.lua b/drivers/SmartThings/zigbee-watering-kit/src/sub_drivers.lua new file mode 100644 index 0000000000..e33b31c978 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("thirdreality"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua b/drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua new file mode 100644 index 0000000000..53118b5bff --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua @@ -0,0 +1,318 @@ +-- 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 zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" + +local IASZone = clusters.IASZone +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration + +local THIRDREALITY_WATERING_CLUSTER = 0xFFF2 +local WATERING_TIME_ATTR = 0x0000 +local WATERING_INTERVAL_ATTR = 0x0001 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("watering-kit-thirdreality.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Third Reality, Inc", + model = "3RWK0148Z", + server_clusters = {0x0001, 0x0006, 0x0500, 0xFFF2} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(10))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("0"))) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 100) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled : on", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported on off status should be handled : off", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + " Switch OnOff command : On ", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + " Switch OnOff command : Off ", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported hardwareFault should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Reported hardwareFault should be handled: clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "fanspeed reported should be handled: 10", + function() + local attr_report_data = { + { WATERING_TIME_ATTR, data_types.Uint16.ID, 10} + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(10))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "fanspeed reported should be handled: 30", + function() + local attr_report_data = { + { WATERING_TIME_ATTR, data_types.Uint16.ID, 30} + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(30))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "mode reported should be handled: 4", + function() + local attr_report_data = { + { WATERING_INTERVAL_ATTR, data_types.OctetString.ID, "4"} + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("4"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setFanSpeed: 20", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = "fanSpeed", component = "main", command = "setFanSpeed", args = { 20 } } }) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, + THIRDREALITY_WATERING_CLUSTER, WATERING_TIME_ATTR, 0x1407, data_types.Uint16, 20) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode: 2", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = "mode", component = "main", command = "setMode", args = { "2" } } }) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, + THIRDREALITY_WATERING_CLUSTER, WATERING_INTERVAL_ATTR, 0x1407, data_types.Uint8, 2) }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "fanspeed reported should be clamped to 0 when value >= 1000", + function() + local attr_report_data = { + { WATERING_TIME_ATTR, data_types.Uint16.ID, 1000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(0))) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/can_handle.lua b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/can_handle.lua new file mode 100644 index 0000000000..12afaa7d98 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function thirdreality_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Third Reality, Inc" and device:get_model() == "3RWK0148Z" then + return true, require("thirdreality") + end + return false +end + +return thirdreality_can_handle diff --git a/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua new file mode 100644 index 0000000000..5e36e15624 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua @@ -0,0 +1,106 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local IASZone = clusters.IASZone +local ZONE_STATUS_ATTR = IASZone.attributes.ZoneStatus + +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local THIRDREALITY_WATERING_CLUSTER = 0xFFF2 +local WATERING_TIME = 0x0000 +local WATERING_INTERVAL = 0x0001 + +local function device_added(driver, device) + device:emit_event(capabilities.hardwareFault.hardwareFault.clear()) + device:emit_event(capabilities.fanSpeed.fanSpeed(10)) + device:emit_event(capabilities.mode.mode("0")) +end + +local generate_event_from_zone_status = function(driver, device, zone_status, zb_rx) + local event + if zone_status:is_alarm1_set() then + event = capabilities.hardwareFault.hardwareFault.detected() + else + event = capabilities.hardwareFault.hardwareFault.clear() + end + if event ~= nil then + device:emit_event(event) + end +end + +local ias_zone_status_attr_handler = function(driver, device, zone_status, zb_rx) + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local ias_zone_status_change_handler = function(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +local function set_watering_time(device, speed) + local watering_time = speed + device:send(cluster_base.write_manufacturer_specific_attribute(device, THIRDREALITY_WATERING_CLUSTER, WATERING_TIME, 0x1407, data_types.Uint16, watering_time)) +end + +local function set_watering_interval(device, speed) + local watering_interval = math.tointeger(speed) + device:send(cluster_base.write_manufacturer_specific_attribute(device, THIRDREALITY_WATERING_CLUSTER, WATERING_INTERVAL, 0x1407, data_types.Uint8, watering_interval)) +end + +local function fan_speed_handler(driver, device, command) + set_watering_time(device, command.args.speed) +end + +local function mode_handler(driver, device, command) + local mode_value = tonumber(command.args.mode) + set_watering_interval(device, mode_value) +end + +local function watering_time_handler(driver, device, value, zb_rx) + local fan_speed_value = value.value + if fan_speed_value >= 1000 then + fan_speed_value = 0 + end + device:emit_event(capabilities.fanSpeed.fanSpeed(fan_speed_value)) +end + +local function watering_interval_handler(driver, device, value, zb_rx) + local interval_value = value.value + device:emit_event(capabilities.mode.mode(tostring(interval_value))) +end + +local thirdreality_device_handler = { + NAME = "ThirdReality Smart Watering Kit", + zigbee_handlers = { + attr = { + [IASZone.ID] = { + [ZONE_STATUS_ATTR.ID] = ias_zone_status_attr_handler + }, + [THIRDREALITY_WATERING_CLUSTER] = { + [WATERING_TIME] = watering_time_handler, + [WATERING_INTERVAL] = watering_interval_handler + } + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + } + }, + capability_handlers = { + [capabilities.fanSpeed.ID] = { + [capabilities.fanSpeed.commands.setFanSpeed.NAME] = fan_speed_handler + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = mode_handler + } + }, + lifecycle_handlers = { + added = device_added + }, + can_handle = require("thirdreality.can_handle"), +} + +return thirdreality_device_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/capabilities/chargingState.yaml b/drivers/SmartThings/zigbee-window-treatment/capabilities/chargingState.yaml new file mode 100644 index 0000000000..dc5844b9ac --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/capabilities/chargingState.yaml @@ -0,0 +1,42 @@ +id: stse.chargingState +version: 1 +status: proposed +name: Charging State +ephemeral: false +attributes: + supportedChargingStates: + schema: + type: object + properties: + value: + type: array + items: + title: ChargingState + type: string + enum: + - charging + - discharging + - stopped + - fullyCharged + - error + additionalProperties: false + required: [] + enumCommands: [] + chargingState: + schema: + type: object + properties: + value: + title: ChargingState + type: string + enum: + - charging + - discharging + - stopped + - fullyCharged + - error + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-window-treatment/capabilities/hookLockState.yaml b/drivers/SmartThings/zigbee-window-treatment/capabilities/hookLockState.yaml new file mode 100644 index 0000000000..94057f38d6 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/capabilities/hookLockState.yaml @@ -0,0 +1,32 @@ +id: stse.hookLockState +version: 1 +status: proposed +name: Hook Lock State +ephemeral: false +attributes: + hookLockState: + schema: + type: object + properties: + value: + type: string + enum: + - unlocked + - locked + - locking + - unlocking + additionalProperties: false + required: + - value + enumCommands: + - command: hookLock + value: locking + - command: hookUnlock + value: unlocking +commands: + hookLock: + name: hookLock + arguments: [] + hookUnlock: + name: hookUnlock + arguments: [] \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml index 12536b215d..021d59d2d4 100644 --- a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml @@ -24,6 +24,11 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.curtain.v1 deviceProfileName: window-treatment-aqara + - id: "LUMI/lumi.curtain.agl001" + deviceLabel: Aqara Curtain Driver E1 + manufacturer: LUMI + model: lumi.curtain.agl001 + deviceProfileName: window-treatment-aqara-curtain-driver-e1 - id: "REXENSE/KG0001" deviceLabel: Window Treatment manufacturer: REXENSE @@ -94,7 +99,7 @@ zigbeeManufacturer: model: Gear deviceProfileName: window-treatment-battery - id: "_TZE204_fzbskaga/TS0601" - deviceLabel: Hanssem Window Treatment + deviceLabel: Window Treatment manufacturer: _TZE204_fzbskaga model: TS0601 deviceProfileName: window-treatment-hanssem @@ -108,6 +113,46 @@ zigbeeManufacturer: manufacturer: Insta GmbH model: NEXENTRO Blinds Actuator deviceProfileName: window-treatment-profile + - id: "ScreenInnovations/WM25/L-Z" + deviceLabel: Screen Innovations Window Treatment + manufacturer: Screen Innovations + model: WM25/L-Z + deviceProfileName: window-treatment-powerSource + - id: "Shade Revolution/Indoor Shade Motors" + deviceLabel: Smart Shade + manufacturer: Shade Revolution + model: Indoor Shade Motors + deviceProfileName: window-treatment-powerSource + - id: "VIVIDSTORM/VWSDSTUST120H" + deviceLabel: VIVIDSTORM Smart Screen VWSDSTUST120H + manufacturer: VIVIDSTORM + model: VWSDSTUST120H + deviceProfileName: projector-screen-VWSDSTUST120H + - id: "NodOn/SIN-4-RS-20" + deviceLabel: Zigbee Roller Shutter Relay Switch + manufacturer: NodOn + model: SIN-4-RS-20 + deviceProfileName: window-treatment-profile + - id: "HOPOsmart/A2230011" + deviceLabel: HOPOsmart Window Opener A2230011 + manufacturer: HOPOsmart + model: A2230011 + deviceProfileName: window-shade-only + - id: "Sombra Shades/WM25/L-Z" + deviceLabel: Sombra Shades Window Treatment + manufacturer: Sombra Shades + model: WM25/L-Z + deviceProfileName: window-treatment-battery + - id: "Sombra Shades/SS25/L-Z" + deviceLabel: Sombra Automated Shades and Blinds + manufacturer: Sombra Shades + model: SS25/L-Z + deviceProfileName: window-treatment-battery + - id: "Sombra Shades/SOMBRA/Z-M" + deviceLabel: Sombra Automated Shades + manufacturer: Sombra Shades + model: SOMBRA/Z-M + deviceProfileName: window-treatment-powerSource zigbeeGeneric: - id: "genericShade" diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/projector-screen-VWSDSTUST120H.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/projector-screen-VWSDSTUST120H.yml new file mode 100755 index 0000000000..85519e9f31 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/projector-screen-VWSDSTUST120H.yml @@ -0,0 +1,23 @@ +name: projector-screen-VWSDSTUST120H +components: + - label: " " + id: main + capabilities: + - id: windowShade + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Projector + - label: " " + id: hardwareFault + capabilities: + - id: hardwareFault + version: 1 +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-VIVIDSTORM_Projector_Screen diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-shade-only.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-shade-only.yml new file mode 100755 index 0000000000..7adffe309e --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-shade-only.yml @@ -0,0 +1,12 @@ +name: window-shade-only +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WindowOpener diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml new file mode 100644 index 0000000000..05e3fe0085 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml @@ -0,0 +1,32 @@ +name: window-treatment-aqara-curtain-driver-e1 +components: + - id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeLevel + version: 1 + - id: statelessWindowShadeLevelStep + version: 1 + - id: stse.initializedStateWithGuide + version: 1 + - id: stse.hookLockState + version: 1 + - id: battery + version: 1 + - id: stse.chargingState + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: stse.reverseCurtainDirection + explicit: true + - preferenceId: stse.softTouch + explicit: true +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_Curtain_Driver_E1 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml index c454c4d590..e7f5e8d8af 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.initializedStateWithGuide version: 1 - id: stse.shadeRotateState diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml index 6c2cc967e7..4dc959ef93 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.deviceInitialization version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml index 275697cb27..e9b6178ff8 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate @@ -16,6 +18,3 @@ components: version: 1 categories: - name: Blind -preferences: - - preferenceId: presetPosition - explicit: true diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml index 7a65434c46..67ba3c186d 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml new file mode 100644 index 0000000000..d72dc33308 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml @@ -0,0 +1,22 @@ +name: window-treatment-powerSource +components: + - id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeLevel + version: 1 + - id: statelessWindowShadeLevelStep + version: 1 + - id: windowShadePreset + version: 1 + - id: powerSource + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: Blind \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml index ded16d963b..dfeeed7aa1 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml @@ -8,10 +8,9 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: refresh version: 1 categories: - - name: Blind -preferences: - - preferenceId: presetPosition - explicit: true + - name: Blind \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml index 933dd85321..c9fce872c0 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml @@ -8,13 +8,11 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh version: 1 categories: - name: Blind -preferences: - - preferenceId: presetPosition - explicit: true - diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml index 91b21811f9..54b4312210 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadePreset version: 1 - id: firmwareUpdate @@ -18,5 +20,3 @@ components: preferences: - preferenceId: reverse explicit: true - - preferenceId: presetPosition - explicit: true diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/can_handle.lua new file mode 100644 index 0000000000..85db6c0447 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("HOPOsmart.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("HOPOsmart") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua new file mode 100755 index 0000000000..b0394aa3fe --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local data_types = require "st.zigbee.data_types" + +local custom_clusters = { + motor = { + id = 0xFCC8, + mfg_specific_code = 0x1235, + attributes = { + state_value = { + id = 0x0000, + value_type = data_types.Uint8, + } + } + } +} + +return custom_clusters diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/fingerprints.lua new file mode 100644 index 0000000000..abf5e54ae5 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "HOPOsmart", model = "A2230011" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua new file mode 100755 index 0000000000..3267eefc33 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua @@ -0,0 +1,68 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local custom_clusters = require "HOPOsmart/custom_clusters" +local cluster_base = require "st.zigbee.cluster_base" + + + + +local function send_read_attr_request(device, cluster, attr) + device:send( + cluster_base.read_manufacturer_specific_attribute( + device, + cluster.id, + attr.id, + cluster.mfg_specific_code + ) + ) +end + +local function state_value_attr_handler(driver, device, value, zb_rx) + if value.value == 0 then + device:emit_event(capabilities.windowShade.windowShade.open()) + elseif value.value == 1 then + device:emit_event(capabilities.windowShade.windowShade.opening()) + elseif value.value == 2 then + device:emit_event(capabilities.windowShade.windowShade.closed()) + elseif value.value == 3 then + device:emit_event(capabilities.windowShade.windowShade.closing()) + elseif value.value == 4 then + device:emit_event(capabilities.windowShade.windowShade.partially_open()) + end +end + +local function do_refresh(driver, device) + send_read_attr_request(device, custom_clusters.motor, custom_clusters.motor.attributes.state_value) +end + +local function added_handler(self, device) + do_refresh(self, device) +end + +local HOPOsmart_handler = { + NAME = "HOPOsmart Device Handler", + supported_capabilities = { + capabilities.refresh + }, + lifecycle_handlers = { + added = added_handler + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zigbee_handlers = { + attr = { + [custom_clusters.motor.id] = { + [custom_clusters.motor.attributes.state_value.id] = state_value_attr_handler + } + } + }, + can_handle = require("HOPOsmart.can_handle"), +} + +return HOPOsmart_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/can_handle.lua new file mode 100644 index 0000000000..d6907690c3 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("VIVIDSTORM.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("VIVIDSTORM") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua new file mode 100755 index 0000000000..8efbc0e654 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua @@ -0,0 +1,24 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local data_types = require "st.zigbee.data_types" + +local custom_clusters = { + motor = { + id = 0xFCC9, + mfg_specific_code = 0x1235, + attributes = { + mode_value = { + id = 0x0000, + value_type = data_types.Uint8, + }, + hardwareFault = { + id = 0x0001, + value_type = data_types.Uint8, + } + } + } +} + +return custom_clusters diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/fingerprints.lua new file mode 100644 index 0000000000..ff1bbcb00d --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "VIVIDSTORM", model = "VWSDSTUST120H" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua new file mode 100755 index 0000000000..106115edee --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua @@ -0,0 +1,141 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local custom_clusters = require "VIVIDSTORM/custom_clusters" +local cluster_base = require "st.zigbee.cluster_base" +local WindowCovering = zcl_clusters.WindowCovering + +local MOST_RECENT_SETLEVEL = "windowShade_recent_setlevel" +local TIMER = "liftPercentage_timer" + + + + +local function send_read_attr_request(device, cluster, attr) + device:send( + cluster_base.read_manufacturer_specific_attribute( + device, + cluster.id, + attr.id, + cluster.mfg_specific_code + ) + ) +end + +local mode_str = { "Delete upper limit","Set the upper limit","Delete lower limit","Set the lower limit" } + +local function mode_attr_handler(driver, device, value, zb_rx) + if value.value <= 3 then + local value = mode_str[value.value+1] + if value ~= nil then + device:emit_component_event(device.profile.components.main,capabilities.mode.mode(value)) + end + end +end + + +local function liftPercentage_attr_handler(driver, device, value, zb_rx) + local windowShade = capabilities.windowShade.windowShade + local components = device.profile.components.main + local most_recent_setlevel = device:get_field(MOST_RECENT_SETLEVEL) + if value.value and most_recent_setlevel and value.value ~= most_recent_setlevel then + if value.value > most_recent_setlevel then + device:emit_component_event(components,windowShade.opening()) + elseif value.value < most_recent_setlevel then + device:emit_component_event(components,windowShade.closing()) + end + end + device:set_field(MOST_RECENT_SETLEVEL, value.value) + + local timer = device:get_field(TIMER) + if timer ~= nil then driver:cancel_timer(timer) end + timer = device.thread:call_with_delay(5, function(d) + if most_recent_setlevel == 0 then + device:emit_component_event(components,windowShade.closed()) + elseif most_recent_setlevel == 100 then + device:emit_component_event(components,windowShade.open()) + else + device:emit_component_event(components,windowShade.partially_open()) + end + end + ) + device:set_field(TIMER, timer) +end + +local function hardwareFault_attr_handler(driver, device, value, zb_rx) + if value.value == 1 then + device:emit_component_event(device.profile.components.hardwareFault,capabilities.hardwareFault.hardwareFault.detected()) + elseif value.value == 0 then + device:emit_component_event(device.profile.components.hardwareFault,capabilities.hardwareFault.hardwareFault.clear()) + end +end + +local function capabilities_mode_handler(driver, device, command) + local value = 0 + if command.args.mode == "Delete upper limit" then + value = 0 + elseif command.args.mode == "Set the upper limit" then + value = 1 + elseif command.args.mode == "Delete lower limit" then + value = 2 + elseif command.args.mode == "Set the lower limit" then + value = 3 + end + + device:send( + cluster_base.write_manufacturer_specific_attribute( + device, + custom_clusters.motor.id, + custom_clusters.motor.attributes.mode_value.id, + custom_clusters.motor.mfg_specific_code, + custom_clusters.motor.attributes.mode_value.value_type, + value + ) + ) +end + +local function do_refresh(driver, device) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device):to_endpoint(0x01)) + send_read_attr_request(device, custom_clusters.motor, custom_clusters.motor.attributes.mode_value) + send_read_attr_request(device, custom_clusters.motor, custom_clusters.motor.attributes.hardwareFault) +end + +local function added_handler(self, device) + device:emit_component_event(device.profile.components.hardwareFault,capabilities.hardwareFault.hardwareFault.clear()) + do_refresh(self, device) +end + +local screen_handler = { + NAME = "VWSDSTUST120H Device Handler", + supported_capabilities = { + capabilities.refresh + }, + lifecycle_handlers = { + added = added_handler + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = capabilities_mode_handler + }, + }, + zigbee_handlers = { + attr = { + [WindowCovering.ID] = { + [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = liftPercentage_attr_handler + }, + [custom_clusters.motor.id] = { + [custom_clusters.motor.attributes.mode_value.id] = mode_attr_handler, + [custom_clusters.motor.attributes.hardwareFault.id] = hardwareFault_attr_handler + } + } + }, + can_handle = require("VIVIDSTORM.can_handle"), +} + +return screen_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/aqara_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/aqara_utils.lua index def2689289..9ecfc6a605 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/aqara_utils.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/aqara_utils.lua @@ -1,6 +1,9 @@ local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" local utils = require "st.utils" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" local WindowCovering = clusters.WindowCovering local AnalogOutput = clusters.AnalogOutput @@ -60,6 +63,18 @@ local function emit_shade_event(device, value) end end +local function custom_write_attribute(device, cluster, attribute, data_type, value, mfg_code) + local data = data_types.validate_or_build_type(value, data_type) + local message = cluster_base.write_attribute(device, data_types.ClusterId(cluster), attribute, data) + if mfg_code ~= nil then + message.body.zcl_header.frame_ctrl:set_mfg_specific() + message.body.zcl_header.mfg_code = data_types.validate_or_build_type(mfg_code, data_types.Uint16, "mfg_code") + else + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + end + return message +end + local function emit_shade_level_event(device, value) local level = value.value if level > 100 then @@ -85,5 +100,6 @@ aqara_utils.emit_shade_event = emit_shade_event aqara_utils.emit_shade_event_by_state = emit_shade_event_by_state aqara_utils.emit_shade_level_event = emit_shade_level_event aqara_utils.shade_level_cmd = shade_level_cmd +aqara_utils.custom_write_attribute = custom_write_attribute return aqara_utils diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/can_handle.lua new file mode 100644 index 0000000000..7eb40a7c10 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function curtain_driver_e1_can_handle(opts, driver, device, ...) + if device:get_model() == "lumi.curtain.agl001" then + return true, require("aqara.curtain-driver-e1") + end + return false +end + +return curtain_driver_e1_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua new file mode 100644 index 0000000000..03e651b679 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua @@ -0,0 +1,220 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local aqara_utils = require "aqara/aqara_utils" +local window_treatment_utils = require "window_treatment_utils" + +local Groups = clusters.Groups +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration +local PRIVATE_CURTAIN_MANUAL_ATTRIBUTE_ID = 0x0401 +local PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID = 0x0402 +local PRIVATE_CURTAIN_STATUS_ATTRIBUTE_ID = 0x0421 +local PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID = 0x0427 +local PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID = 0x0428 + +local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] +local reverseCurtainDirection = "stse.reverseCurtainDirection" +local hookLockState = capabilities["stse.hookLockState"] +local chargingState = capabilities["stse.chargingState"] +local softTouch = "stse.softTouch" +local hookUnlockCommandName = "hookUnlock" +local hookLockCommandName = "hookLock" + +local SHADE_STATE_CLOSE = 0 +local SHADE_STATE_OPEN = 1 +local SHADE_STATE_STOP = 2 + +local function device_added(driver, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadeLevel, capabilities.windowShadeLevel.shadeLevel.NAME, capabilities.windowShadeLevel.shadeLevel(0)) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShade, capabilities.windowShade.windowShade.NAME, capabilities.windowShade.windowShade.closed()) + device:emit_event(initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + device:emit_event(hookLockState.hookLockState.unlocked()) + device:emit_event(chargingState.chargingState.stopped()) + device:emit_event(capabilities.battery.battery(100)) +end + +local function do_refresh(self, device) + device:send(cluster_base.read_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID, aqara_utils.MFG_CODE)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID, aqara_utils.MFG_CODE)) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) +end + +local function do_configure(self, device) + device:configure() + device:send(Groups.server.commands.RemoveAllGroups(device)) -- required + do_refresh(self, device) +end + +local CONFIGURATIONS = { + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 30, + maximum_interval = 3600, + data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 1 + } +} + +local function device_init(driver, device) + for _, attribute in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(attribute) + end +end + +local function device_info_changed(driver, device, event, args) + if device.preferences ~= nil then + local reverseCurtainDirectionPrefValue = device.preferences[reverseCurtainDirection] + local softTouchPrefValue = device.preferences[softTouch] + + -- reverse direction + if reverseCurtainDirectionPrefValue ~= nil and + reverseCurtainDirectionPrefValue ~= args.old_st_store.preferences[reverseCurtainDirection] then + local raw_value = reverseCurtainDirectionPrefValue and 0x01 or 0x00 + device:send(aqara_utils.custom_write_attribute(device, WindowCovering.ID, WindowCovering.attributes.Mode.ID, + data_types.Bitmap8, raw_value, nil)) + end + + -- soft touch + if softTouchPrefValue ~= nil and + softTouchPrefValue ~= args.old_st_store.preferences[softTouch] then + device:send(cluster_base.write_manufacturer_specific_attribute(device, + aqara_utils.PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_MANUAL_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Boolean, (not softTouchPrefValue))) + end + end +end + +local function shade_state_report_handler(driver, device, value, zb_rx) + local state = value.value + -- update state ui + if state == SHADE_STATE_STOP then + -- read shade position to update the UI + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) + elseif state == SHADE_STATE_OPEN then + device:emit_event(capabilities.windowShade.windowShade.opening()) + elseif state == SHADE_STATE_CLOSE then + device:emit_event(capabilities.windowShade.windowShade.closing()) + end +end + +local function curtain_range_report_handler(driver, device, value, zb_rx) + -- initializedState + if value.value == true then + device:emit_event(initializedStateWithGuide.initializedStateWithGuide.initialized()) + elseif value.value == false then + device:emit_event(initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + end +end + +local function curtain_state_of_charge_report_handler(driver, device, value, zb_rx) + if value.value == 3 then + device:emit_event(chargingState.chargingState.stopped()) + elseif value.value == 4 then + device:emit_event(chargingState.chargingState.charging()) + elseif value.value == 7 then + device:emit_event(chargingState.chargingState.fullyCharged()) + end +end + +local function battery_energy_status_handler(driver, device, value, zb_rx) + device:emit_event(capabilities.battery.battery(math.floor(value.value / 2.0 + 0.5))) +end + +local function window_locking_status_handler(driver, device, value, zb_rx) + if value.value == 0 then + device:emit_event(hookLockState.hookLockState.unlocked()) + elseif value.value == 1 then + device:emit_event(hookLockState.hookLockState.locked()) + elseif value.value == 2 then + device:emit_event(hookLockState.hookLockState.locking()) + elseif value.value == 3 then + device:emit_event(hookLockState.hookLockState.unlocking()) + end +end + +local function window_shade_open_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + device:send_to_component(command.component, WindowCovering.server.commands.UpOrOpen(device)) + end +end + +local function window_shade_pause_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + device:send_to_component(command.component, WindowCovering.server.commands.Stop(device)) + end +end + +local function window_shade_close_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + device:send_to_component(command.component, WindowCovering.server.commands.DownOrClose(device)) + end +end + +local function hook_lock_cmd(driver, device, command) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + aqara_utils.PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint8, 0x01)) +end + +local function hook_unlock_cmd(driver, device, command) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + aqara_utils.PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint8, 0x00)) +end + +local aqara_curtain_driver_e1_handler = { + NAME = "Aqara Curtain Driver E1 Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + infoChanged = device_info_changed + }, + capability_handlers = { + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.open.NAME] = window_shade_open_cmd, + [capabilities.windowShade.commands.pause.NAME] = window_shade_pause_cmd, + [capabilities.windowShade.commands.close.NAME] = window_shade_close_cmd, + }, + [hookLockState.ID] = { + [hookLockCommandName] = hook_lock_cmd, + [hookUnlockCommandName] = hook_unlock_cmd, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zigbee_handlers = { + attr = { + [Basic.ID] = { + [Basic.attributes.PowerSource.ID] = curtain_state_of_charge_report_handler + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_energy_status_handler + }, + [aqara_utils.PRIVATE_CLUSTER_ID] = { + [PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID] = curtain_range_report_handler, + [PRIVATE_CURTAIN_STATUS_ATTRIBUTE_ID] = shade_state_report_handler, + [PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID] = window_locking_status_handler + } + } + }, + can_handle = require("aqara.curtain-driver-e1.can_handle"), +} + +return aqara_curtain_driver_e1_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..8ad05530a5 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.curtain" }, + { mfr = "LUMI", model = "lumi.curtain.v1" }, + { mfr = "LUMI", model = "lumi.curtain.aq2" }, + { mfr = "LUMI", model = "lumi.curtain.agl001" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua index 0eab6bb66f..1b66236038 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua @@ -1,8 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local aqara_utils = require "aqara/aqara_utils" +local window_treatment_utils = require "window_treatment_utils" local Basic = clusters.Basic local WindowCovering = clusters.WindowCovering @@ -10,8 +15,8 @@ local AnalogOutput = clusters.AnalogOutput local Groups = clusters.Groups local deviceInitialization = capabilities["stse.deviceInitialization"] -local reverseCurtainDirection = capabilities["stse.reverseCurtainDirection"] -local softTouch = capabilities["stse.softTouch"] +local reverseCurtainDirection = "stse.reverseCurtainDirection" +local softTouch = "stse.softTouch" local setInitializedStateCommandName = "setInitializedState" local INIT_STATE = "initState" @@ -26,20 +31,7 @@ local PREF_SOFT_TOUCH_ON = "\x00\x08\x00\x00\x00\x00\x00" local APPLICATION_VERSION = "application_version" -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.curtain" }, - { mfr = "LUMI", model = "lumi.curtain.v1" }, - { mfr = "LUMI", model = "lumi.curtain.aq2" } -} -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function window_shade_level_cmd(driver, device, command) aqara_utils.shade_level_cmd(driver, device, command) @@ -131,12 +123,12 @@ end local function device_info_changed(driver, device, event, args) if device.preferences ~= nil then - local reverseCurtainDirectionPrefValue = device.preferences[reverseCurtainDirection.ID] - local softTouchPrefValue = device.preferences[softTouch.ID] + local reverseCurtainDirectionPrefValue = device.preferences[reverseCurtainDirection] + local softTouchPrefValue = device.preferences[softTouch] -- reverse direction if reverseCurtainDirectionPrefValue ~= nil and - reverseCurtainDirectionPrefValue ~= args.old_st_store.preferences[reverseCurtainDirection.ID] then + reverseCurtainDirectionPrefValue ~= args.old_st_store.preferences[reverseCurtainDirection] then local raw_value = reverseCurtainDirectionPrefValue and aqara_utils.PREF_REVERSE_ON or aqara_utils.PREF_REVERSE_OFF device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.CharString, raw_value)) @@ -150,7 +142,7 @@ local function device_info_changed(driver, device, event, args) -- soft touch if softTouchPrefValue ~= nil and - softTouchPrefValue ~= args.old_st_store.preferences[softTouch.ID] then + softTouchPrefValue ~= args.old_st_store.preferences[softTouch] then local raw_value = softTouchPrefValue and PREF_SOFT_TOUCH_ON or PREF_SOFT_TOUCH_OFF device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.CharString, raw_value)) @@ -168,8 +160,8 @@ end local function device_added(driver, device) device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) device:emit_event(deviceInitialization.supportedInitializedState({ "notInitialized", "initializing", "initialized" }, {visibility = {displayed = false}})) - device:emit_event(capabilities.windowShadeLevel.shadeLevel(0)) - device:emit_event(capabilities.windowShade.windowShade.closed()) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadeLevel, capabilities.windowShadeLevel.shadeLevel.NAME, capabilities.windowShadeLevel.shadeLevel(0)) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShade, capabilities.windowShade.windowShade.NAME, capabilities.windowShade.windowShade.closed()) device:emit_event(deviceInitialization.initializedState.notInitialized()) device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, @@ -215,11 +207,8 @@ local aqara_window_treatment_handler = { } } }, - sub_drivers = { - require("aqara.roller-shade"), - require("aqara.version") - }, - can_handle = is_aqara_products + sub_drivers = require("aqara.sub_drivers"), + can_handle = require("aqara.can_handle"), } return aqara_window_treatment_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/can_handle.lua new file mode 100644 index 0000000000..2e91fa090d --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function roller_shade_can_handle(opts, driver, device, ...) + if device:get_model() == "lumi.curtain.aq2" then + return true, require("aqara.roller-shade") + end + return false +end + +return roller_shade_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua index e88d76d517..909b65dd3e 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua @@ -1,140 +1,142 @@ -local capabilities = require "st.capabilities" -local clusters = require "st.zigbee.zcl.clusters" -local cluster_base = require "st.zigbee.cluster_base" -local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" -local data_types = require "st.zigbee.data_types" -local aqara_utils = require "aqara/aqara_utils" - -local Basic = clusters.Basic -local WindowCovering = clusters.WindowCovering - -local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] -local reverseRollerShadeDir = capabilities["stse.reverseRollerShadeDir"] -local shadeRotateState = capabilities["stse.shadeRotateState"] -local setRotateStateCommandName = "setRotateState" - -local MULTISTATE_CLUSTER_ID = 0x0013 -local MULTISTATE_ATTRIBUTE_ID = 0x0055 -local ROTATE_UP_VALUE = 0x0004 -local ROTATE_DOWN_VALUE = 0x0005 - - -local function window_shade_level_cmd(driver, device, command) - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - aqara_utils.shade_level_cmd(driver, device, command) - end -end - -local function window_shade_open_cmd(driver, device, command) - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 100)) - end -end - -local function window_shade_close_cmd(driver, device, command) - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 0)) - end -end - -local function set_rotate_command_handler(driver, device, command) - device:emit_event(shadeRotateState.rotateState.idle({state_change = true})) -- update UI - - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - local state = command.args.state - if state == "rotateUp" then - local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, - MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_UP_VALUE) - message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) - device:send(message) - elseif state == "rotateDown" then - local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, - MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_DOWN_VALUE) - message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) - device:send(message) - end - end -end - -local function shade_state_report_handler(driver, device, value, zb_rx) - aqara_utils.emit_shade_event_by_state(device, value) -end - -local function pref_report_handler(driver, device, value, zb_rx) - -- initializedState - local initialized = string.byte(value.value, 3) & 0xFF - device:emit_event(initialized == 1 and initializedStateWithGuide.initializedStateWithGuide.initialized() or - initializedStateWithGuide.initializedStateWithGuide.notInitialized()) -end - -local function device_info_changed(driver, device, event, args) - if device.preferences ~= nil then - local reverseRollerShadeDirPrefValue = device.preferences[reverseRollerShadeDir.ID] - if reverseRollerShadeDirPrefValue ~= nil and - reverseRollerShadeDirPrefValue ~= args.old_st_store.preferences[reverseRollerShadeDir.ID] then - local raw_value = reverseRollerShadeDirPrefValue and aqara_utils.PREF_REVERSE_ON or aqara_utils.PREF_REVERSE_OFF - device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, - aqara_utils.MFG_CODE, data_types.CharString, raw_value)) - end - end -end - -local function device_added(driver, device) - device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) - device:emit_event(capabilities.windowShadeLevel.shadeLevel(0)) - device:emit_event(capabilities.windowShade.windowShade.closed()) - device:emit_event(initializedStateWithGuide.initializedStateWithGuide.notInitialized()) - device:emit_event(shadeRotateState.rotateState.idle()) - - device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, - aqara_utils.PRIVATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint8, 1)) - - -- Initial default settings - device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, - aqara_utils.MFG_CODE, data_types.CharString, aqara_utils.PREF_REVERSE_OFF)) -end - -local aqara_roller_shade_handler = { - NAME = "Aqara Roller Shade Handler", - lifecycle_handlers = { - added = device_added, - infoChanged = device_info_changed - }, - capability_handlers = { - [capabilities.windowShadeLevel.ID] = { - [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd - }, - [capabilities.windowShade.ID] = { - [capabilities.windowShade.commands.open.NAME] = window_shade_open_cmd, - [capabilities.windowShade.commands.close.NAME] = window_shade_close_cmd, - }, - [shadeRotateState.ID] = { - [setRotateStateCommandName] = set_rotate_command_handler - } - }, - zigbee_handlers = { - attr = { - [Basic.ID] = { - [aqara_utils.SHADE_STATE_ATTRIBUTE_ID] = shade_state_report_handler, - [aqara_utils.PREF_ATTRIBUTE_ID] = pref_report_handler - } - } - }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "lumi.curtain.aq2" - end -} - -return aqara_roller_shade_handler +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" +local data_types = require "st.zigbee.data_types" +local aqara_utils = require "aqara/aqara_utils" +local window_treatment_utils = require "window_treatment_utils" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering + +local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] +local reverseRollerShadeDir = capabilities["stse.reverseRollerShadeDir"] +local shadeRotateState = capabilities["stse.shadeRotateState"] +local setRotateStateCommandName = "setRotateState" + +local MULTISTATE_CLUSTER_ID = 0x0013 +local MULTISTATE_ATTRIBUTE_ID = 0x0055 +local ROTATE_UP_VALUE = 0x0004 +local ROTATE_DOWN_VALUE = 0x0005 + + +local function window_shade_level_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + aqara_utils.shade_level_cmd(driver, device, command) + end +end + +local function window_shade_open_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 100)) + end +end + +local function window_shade_close_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 0)) + end +end + +local function set_rotate_command_handler(driver, device, command) + device:emit_event(shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }})) -- update UI + + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + local state = command.args.state + if state == "rotateUp" then + local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, + MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_UP_VALUE) + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + device:send(message) + elseif state == "rotateDown" then + local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, + MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_DOWN_VALUE) + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + device:send(message) + end + end +end + +local function shade_state_report_handler(driver, device, value, zb_rx) + aqara_utils.emit_shade_event_by_state(device, value) +end + +local function pref_report_handler(driver, device, value, zb_rx) + -- initializedState + local initialized = string.byte(value.value, 3) & 0xFF + device:emit_event(initialized == 1 and initializedStateWithGuide.initializedStateWithGuide.initialized() or + initializedStateWithGuide.initializedStateWithGuide.notInitialized()) +end + +local function device_info_changed(driver, device, event, args) + if device.preferences ~= nil then + local reverseRollerShadeDirPrefValue = device.preferences[reverseRollerShadeDir.ID] + if reverseRollerShadeDirPrefValue ~= nil and + reverseRollerShadeDirPrefValue ~= args.old_st_store.preferences[reverseRollerShadeDir.ID] then + local raw_value = reverseRollerShadeDirPrefValue and aqara_utils.PREF_REVERSE_ON or aqara_utils.PREF_REVERSE_OFF + device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, + aqara_utils.MFG_CODE, data_types.CharString, raw_value)) + end + end +end + +local function device_added(driver, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadeLevel, capabilities.windowShadeLevel.shadeLevel.NAME, capabilities.windowShadeLevel.shadeLevel(0)) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShade, capabilities.windowShade.windowShade.NAME, capabilities.windowShade.windowShade.closed()) + device:emit_event(initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + device:emit_event(shadeRotateState.rotateState.idle({ visibility = { displayed = false }})) + + device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, + aqara_utils.PRIVATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint8, 1)) + + -- Initial default settings + device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, + aqara_utils.MFG_CODE, data_types.CharString, aqara_utils.PREF_REVERSE_OFF)) +end + +local aqara_roller_shade_handler = { + NAME = "Aqara Roller Shade Handler", + lifecycle_handlers = { + added = device_added, + infoChanged = device_info_changed + }, + capability_handlers = { + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.open.NAME] = window_shade_open_cmd, + [capabilities.windowShade.commands.close.NAME] = window_shade_close_cmd, + }, + [shadeRotateState.ID] = { + [setRotateStateCommandName] = set_rotate_command_handler + } + }, + zigbee_handlers = { + attr = { + [Basic.ID] = { + [aqara_utils.SHADE_STATE_ATTRIBUTE_ID] = shade_state_report_handler, + [aqara_utils.PREF_ATTRIBUTE_ID] = pref_report_handler + } + } + }, + can_handle = require("aqara.roller-shade.can_handle"), +} + +return aqara_roller_shade_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/sub_drivers.lua new file mode 100644 index 0000000000..297f29d970 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara.roller-shade"), + lazy_load_if_possible("aqara.curtain-driver-e1"), + lazy_load_if_possible("aqara.version"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/can_handle.lua new file mode 100644 index 0000000000..5d9f7f0135 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function version_can_handle(opts, driver, device) + local APPLICATION_VERSION = "application_version" + local softwareVersion = device:get_field(APPLICATION_VERSION) + if softwareVersion and softwareVersion ~= 34 then + return true, require("aqara.version") + end + return false +end + +return version_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua index 10182ee928..ae1887d79a 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua @@ -1,9 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local WindowCovering = clusters.WindowCovering -local APPLICATION_VERSION = "application_version" - local function shade_level_report_legacy_handler(driver, device, value, zb_rx) -- not implemented end @@ -17,10 +18,7 @@ local aqara_window_treatment_version_handler = { } } }, - can_handle = function(opts, driver, device) - local softwareVersion = device:get_field(APPLICATION_VERSION) - return softwareVersion and softwareVersion ~= 34 - end + can_handle = require("aqara.version.can_handle"), } return aqara_window_treatment_version_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/can_handle.lua new file mode 100644 index 0000000000..828eb170fa --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_axis_gear_version = function(opts, driver, device) + local SOFTWARE_VERSION = "software_version" + local MIN_WINDOW_COVERING_VERSION = 1093 + local version = device:get_field(SOFTWARE_VERSION) or 0 + + if version >= MIN_WINDOW_COVERING_VERSION then + return true, require("axis.axis_version") + end + return false +end + +return is_axis_gear_version diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua index 1bc09cbb6b..cbb2930bc3 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua @@ -1,19 +1,9 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" -local window_preset_defaults = require "st.zigbee.defaults.windowShadePreset_defaults" +local window_shade_utils = require "window_shade_utils" local zcl_clusters = require "st.zigbee.zcl.clusters" local utils = require "st.utils" @@ -22,18 +12,8 @@ local Level = zcl_clusters.Level local PowerConfiguration = zcl_clusters.PowerConfiguration local WindowCovering = zcl_clusters.WindowCovering -local SOFTWARE_VERSION = "software_version" -local MIN_WINDOW_COVERING_VERSION = 1093 local DEFAULT_LEVEL = 0 -local is_axis_gear_version = function(opts, driver, device) - local version = device:get_field(SOFTWARE_VERSION) or 0 - - if version >= MIN_WINDOW_COVERING_VERSION then - return true - end - return false -end -- Commands local function window_shade_set_level(device, command, level) @@ -52,7 +32,7 @@ local function window_shade_level_cmd_handler(driver, device, command) end local function window_shade_preset_cmd(driver, device, command) - local level = device.preferences and device.preferences.presetPosition or window_preset_defaults.PRESET_LEVEL + local level = window_shade_utils.get_preset_level(device, command.component) window_shade_set_level(device, command, level) end @@ -141,7 +121,7 @@ local axis_handler_version = { } } }, - can_handle = is_axis_gear_version, + can_handle = require("axis.axis_version.can_handle"), } return axis_handler_version diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/can_handle.lua new file mode 100644 index 0000000000..049e47acb5 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + if device:get_manufacturer() == "AXIS" then + return true, require("axis") + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua index acd67d2d32..ec7c96b975 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua @@ -1,20 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" local device_management = require "st.zigbee.device_management" -local window_preset_defaults = require "st.zigbee.defaults.windowShadePreset_defaults" +local window_shade_utils = require "window_shade_utils" local zcl_clusters = require "st.zigbee.zcl.clusters" local utils = require "st.utils" @@ -26,12 +17,6 @@ local WindowCovering = zcl_clusters.WindowCovering local SOFTWARE_VERSION = "software_version" local DEFAULT_LEVEL = 0 -local is_zigbee_window_shade = function(opts, driver, device) - if device:get_manufacturer() == "AXIS" then - return true - end - return false -end -- Commands local function window_shade_set_level(device, command, level) @@ -50,7 +35,7 @@ local function window_shade_level_cmd_handler(driver, device, command) end local function window_shade_preset_cmd(driver, device, command) - local level = device.preferences and device.preferences.presetPosition or window_preset_defaults.PRESET_LEVEL + local level = window_shade_utils.get_preset_level(device, command.component) window_shade_set_level(device, command, level) end @@ -110,6 +95,7 @@ local do_configure = function(self, device) device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(device, 1, 3600, 1)) device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 1, 3600, 1)) + device:refresh() end local device_added = function(self, device) @@ -150,8 +136,8 @@ local axis_handler = { added = device_added, doConfigure = do_configure, }, - sub_drivers = { require("axis.axis_version") }, - can_handle = is_zigbee_window_shade, + sub_drivers = require("axis.sub_drivers"), + can_handle = require("axis.can_handle"), } return axis_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/sub_drivers.lua new file mode 100644 index 0000000000..e3ea740478 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require("lazy_load_subdriver") + +return { + lazy_load_if_possible("axis.axis_version") +} diff --git a/drivers/SmartThings/zigbee-window-treatment/src/feibit/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/feibit/can_handle.lua new file mode 100644 index 0000000000..32457dde8a --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/feibit/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("feibit.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("feibit") + end + end + + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/feibit/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/feibit/fingerprints.lua new file mode 100644 index 0000000000..95781ff992 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/feibit/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.6" }, + { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.8" }, +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua index 0c1d20be11..1e97fbc4ce 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua @@ -1,38 +1,15 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" -local window_preset_defaults = require "st.zigbee.defaults.windowShadePreset_defaults" +local window_shade_utils = require "window_shade_utils" local window_shade_defaults = require "st.zigbee.defaults.windowShade_defaults" local device_management = require "st.zigbee.device_management" local Level = zcl_clusters.Level -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.6" }, - { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.8" }, -} - -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function set_shade_level(device, value, component) local level = math.floor(value / 100.0 * 254) @@ -50,7 +27,7 @@ local function level_attr_handler(driver, device, value, zb_rx) end local function window_shade_preset_cmd(driver, device, command) - local level = device.preferences.presetPosition or device:get_field(window_preset_defaults.PRESET_LEVEL_KEY) or window_preset_defaults.PRESET_LEVEL + local level = window_shade_utils.get_preset_level(device, command.component) set_shade_level(device, level, command.component) end @@ -61,6 +38,7 @@ end local do_configure = function(self, device) device:send(device_management.build_bind_request(device, Level.ID, self.environment_info.hub_zigbee_eui)) device:send(Level.attributes.CurrentLevel:configure_reporting(device, 1, 3600, 1)) + device:refresh() end local feibit_handler = { @@ -86,7 +64,7 @@ local feibit_handler = { lifecycle_handlers = { doConfigure = do_configure, }, - can_handle = is_zigbee_window_shade, + can_handle = require("feibit.can_handle"), } return feibit_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/can_handle.lua new file mode 100644 index 0000000000..65f1a6dc75 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function hanssem_can_handle(opts, driver, device, ...) + if device:get_model() == "TS0601" then + return true, require("hanssem") + end + return false +end + +return hanssem_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua index bc974706b6..41b4eb1cac 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2021-2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- -- Based on https://github.com/iquix/ST-Edge-Driver/blob/master/tuya-window-shade/src/init.lua -- Copyright 2021-2022 Jaewon Park (iquix) @@ -16,7 +19,7 @@ local Messages = require "st.zigbee.messages" local data_types = require "st.zigbee.data_types" local ZigbeeConstants = require "st.zigbee.constants" local generic_body = require "st.zigbee.generic_body" -local window_preset_defaults = require "st.zigbee.defaults.windowShadePreset_defaults" +local window_shade_utils = require "window_shade_utils" local TUYA_CLUSTER = 0xEF00 local DP_TYPE_VALUE = "\x02" @@ -148,7 +151,7 @@ local function SetShadeLevelHandler(driver, device, capability_command) end local function PresetPositionHandler(driver, device, capability_command) - local level = device.preferences.presetPosition or device:get_field(window_preset_defaults.PRESET_LEVEL_KEY) or window_preset_defaults.PRESET_LEVEL + local level = window_shade_utils.get_preset_level(device, capability_command.component) SetShadeLevelHandler(driver, device, {args = { shadeLevel = level }}) end @@ -241,9 +244,7 @@ local hanssem_window_treatment = { added = device_added, infoChanged = device_info_changed }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "TS0601" - end + can_handle = require("hanssem.can_handle"), } -return hanssem_window_treatment \ No newline at end of file +return hanssem_window_treatment diff --git a/drivers/SmartThings/zigbee-window-treatment/src/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/init.lua index e996de998e..b58c20a5be 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/init.lua @@ -1,20 +1,28 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" +local window_shade_utils = require "window_shade_utils" + +local function init_handler(self, device) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then + + -- These should only ever be nil once (and at the same time) for already-installed devices + -- It can be relocated to `added` after migration is complete + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + + local preset_position = device:get_field(window_shade_utils.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + window_shade_utils.PRESET_LEVEL + + device:emit_event(capabilities.windowShadePreset.position(preset_position, { visibility = {displayed = false}})) + device:set_field(window_shade_utils.PRESET_LEVEL_KEY, preset_position, {persist = true}) + end +end local function added_handler(self, device) device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, { visibility = { displayed = false }})) @@ -25,21 +33,26 @@ local zigbee_window_treatment_driver_template = { capabilities.windowShade, capabilities.windowShadePreset, capabilities.windowShadeLevel, + capabilities.statelessWindowShadeLevelStep, + capabilities.powerSource, capabilities.battery }, - sub_drivers = { - require("vimar"), - require("aqara"), - require("feibit"), - require("somfy"), - require("invert-lift-percentage"), - require("rooms-beautiful"), - require("axis"), - require("yoolax"), - require("hanssem") }, + capability_handlers = { + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = window_shade_utils.set_preset_position_cmd, + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_utils.window_shade_preset_cmd, + }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = window_shade_utils.step_shade_level_handler + }, + }, lifecycle_handlers = { + init = init_handler, added = added_handler - } + }, + sub_drivers = require("sub_drivers"), + health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_window_treatment_driver_template, zigbee_window_treatment_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua new file mode 100644 index 0000000000..69cfd4393a --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function invert_lift_percentage_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "IKEA of Sweden" or + device:get_manufacturer() == "Smartwings" or + device:get_manufacturer() == "Insta GmbH" + then + return true, require("invert-lift-percentage") + end + return false +end + +return invert_lift_percentage_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua index 338e1faaf3..b586459b9a 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua @@ -1,19 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" +local window_shade_utils = require "window_shade_utils" local WindowCovering = zcl_clusters.WindowCovering @@ -50,6 +41,7 @@ local function current_position_attr_handler(driver, device, value, zb_rx) device:set_field(SHADE_SET_STATUS, nil) end local set_window_shade_status = function() + device:set_field(SHADE_SET_STATUS, nil) local current_level = device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) if current_level == 0 then device:emit_event(windowShade.closed()) @@ -74,9 +66,8 @@ local function window_shade_level_cmd(driver, device, command) end local function window_shade_preset_cmd(driver, device, command) - if device.preferences ~= nil and device.preferences.presetPosition ~= nil then - set_shade_level(device, device.preferences.presetPosition, command) - end + local level = window_shade_utils.get_preset_level(device, command.component) + set_shade_level(device, level, command) end local ikea_window_treatment = { @@ -96,11 +87,7 @@ local ikea_window_treatment = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "IKEA of Sweden" or - device:get_manufacturer() == "Smartwings" or - device:get_manufacturer() == "Insta GmbH" - end + can_handle = require("invert-lift-percentage.can_handle"), } return ikea_window_treatment diff --git a/drivers/SmartThings/zigbee-window-treatment/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-window-treatment/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua new file mode 100644 index 0000000000..6bc25d2f91 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("rooms-beautiful.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("rooms-beautiful") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua new file mode 100644 index 0000000000..71ece32b8e --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Rooms Beautiful", model = "C001" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua index fc4883aa7f..bb868a8716 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -21,22 +11,11 @@ local PowerConfiguration = zcl_clusters.PowerConfiguration local OnOff = zcl_clusters.OnOff local WindowCovering = zcl_clusters.WindowCovering -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Rooms Beautiful", model = "C001" } -} local INVERT_CLUSTER = 0xFC00 local INVERT_CLUSTER_ATTRIBUTE = 0x0000 local PREV_TIME = "shadeLevelCmdTime" -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function invert_preference_handler(device) local window_level = device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) or 0 @@ -129,7 +108,7 @@ local rooms_beautiful_handler = { init = battery_defaults.build_linear_voltage_init(2.5, 3.0), infoChanged = info_changed }, - can_handle = is_zigbee_window_shade, + can_handle = require("rooms-beautiful.can_handle"), } return rooms_beautiful_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/can_handle.lua new file mode 100644 index 0000000000..df291c2612 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function screen_innovations_can_handle(opts, driver, device, ...) + if device:get_model() == "WM25/L-Z" then + return true, require("screen-innovations") + end + return false +end + +return screen_innovations_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua new file mode 100644 index 0000000000..49397a5369 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua @@ -0,0 +1,170 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- require st provided libraries +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local window_shade_utils = require "window_shade_utils" +local device_management = require "st.zigbee.device_management" +local utils = require "st.utils" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +-- manufacturer specific cluster details +local CUS_CLU = 0xFCCC +local RUN_DIR_ATTR = 0x0012 + +local MOTOR_STATE = "motorState" +local MOTOR_STATE_IDLE = "idle" +local MOTOR_STATE_OPENING = "opening" +local MOTOR_STATE_CLOSING = "closing" + +----------------------------------------------------------------- +-- local functions +----------------------------------------------------------------- + +-- this is do_refresh +local do_refresh = function(self, device) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) + device:send(Basic.attributes.PowerSource:read(device)) +end + +-- this is window_shade_level_cmd +local function window_shade_level_cmd(driver, device, command) + local go_to_level = command.args.shadeLevel + -- send levels without inverting as: 0% closed (i.e., open) to 100% closed + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, go_to_level)) +end + +-- this is window_shade_preset_cmd +local function window_shade_preset_cmd(driver, device, command) + local level = window_shade_utils.get_preset_level(device, command.component) + -- send levels without inverting as: 0% closed (i.e., open) to 100% closed + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) +end + +-- this is device_added +local function device_added(self, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + -- initialize motor state + device:set_field(MOTOR_STATE, MOTOR_STATE_IDLE) + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end + +-- this is current_position_attr_handler +local function current_position_attr_handler(driver, device, value, zb_rx) + local level = value.value + local event = nil + local motor_state_value = device:get_field(MOTOR_STATE) or MOTOR_STATE_IDLE + + -- when the device is in action + if motor_state_value == MOTOR_STATE_OPENING then + event = capabilities.windowShade.windowShade.opening() + end + + if motor_state_value == MOTOR_STATE_CLOSING then + event = capabilities.windowShade.windowShade.closing() + end + + -- when the device is in idle + if motor_state_value == MOTOR_STATE_IDLE then + if level == 0 then + event = capabilities.windowShade.windowShade.open() + elseif level == 100 then + event = capabilities.windowShade.windowShade.closed() + else + event = capabilities.windowShade.windowShade.partially_open() + end + end + + -- update status + if event ~= nil then + device:emit_event(event) + end + + -- update level + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) +end + +-- this is motor running_direction_attr_handler +local function running_direction_attr_handler(driver, device, value, zb_rx) + local status = value.value + if status == 1 then + device:set_field(MOTOR_STATE, MOTOR_STATE_OPENING) + elseif status == 2 then + device:set_field(MOTOR_STATE, MOTOR_STATE_CLOSING) + else + device:set_field(MOTOR_STATE, MOTOR_STATE_IDLE) + end +end + +-- this is do_configure +local function do_configure(self, device) + -- configure elements + device:send(device_management.build_bind_request(device, WindowCovering.ID, self.environment_info.hub_zigbee_eui)) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, Basic.ID, self.environment_info.hub_zigbee_eui)) + device:send(Basic.attributes.PowerSource:configure_reporting(device, 1, 3600)) + + -- read elements + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end + +-- this is battery_perc_attr_handler +local function battery_perc_attr_handler(driver, device, value, zb_rx) + local converted_value = value.value / 2 + converted_value = utils.round(converted_value) + local motor_state_value = device:get_field(MOTOR_STATE) or "" + -- update battery percentage only motor is in idle state + if motor_state_value == MOTOR_STATE_IDLE then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.battery.battery(utils.clamp_value(converted_value, 0, 100))) + end +end + +-- create the handler object +local screeninnovations_roller_shade_handler = { + NAME = "screeninnovations_roller_shade_handler", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + capability_handlers = { + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd + }, + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + zigbee_handlers = { + attr = { + [WindowCovering.ID] = { + [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = current_position_attr_handler, + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_perc_attr_handler, + }, + [CUS_CLU] = { + [RUN_DIR_ATTR] = running_direction_attr_handler, + } + } + }, + can_handle = require("screen-innovations.can_handle"), +} + +-- return the handler +return screeninnovations_roller_shade_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua new file mode 100644 index 0000000000..e407ea7198 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("sombra.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sombra") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua new file mode 100644 index 0000000000..bac50c21bb --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Sombra Shades", model = "SOMBRA/Z-M" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua new file mode 100644 index 0000000000..634d7372e3 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua @@ -0,0 +1,175 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- require st provided libraries +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local window_shade_utils = require "window_shade_utils" +local device_management = require "st.zigbee.device_management" +local utils = require "st.utils" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +-- Sombra motors report position in the shade-industry convention +-- (0% = open, 100% = closed), which is passed straight through to +-- windowShadeLevel.shadeLevel with no inversion. +-- +-- Opening/Closing status is inferred from the direction the position is +-- changing, so it works on every Sombra motor variant -- battery and DC -- +-- regardless of any manufacturer-specific motion cluster. When position +-- reports stop arriving, a short settle timer emits the resting state. +local SETTLE_DELAY = 3 +local SETTLE_TIMER = "shade_settle_timer" + +----------------------------------------------------------------- +-- local functions +----------------------------------------------------------------- + +-- emit the resting windowShade state for a level (0 = open, 100 = closed) +local function emit_resting_state(device, level) + if level <= 0 then + device:emit_event(capabilities.windowShade.windowShade.open()) + elseif level >= 100 then + device:emit_event(capabilities.windowShade.windowShade.closed()) + else + device:emit_event(capabilities.windowShade.windowShade.partially_open()) + end +end + +-- cancel any pending settle timer +local function cancel_settle_timer(device) + local timer = device:get_field(SETTLE_TIMER) + if timer then + device.thread:cancel_timer(timer) + device:set_field(SETTLE_TIMER, nil) + end +end + +-- (re)start the settle timer; when it fires without a newer position report +-- the shade has stopped moving, so emit the resting state +local function schedule_settle(device) + cancel_settle_timer(device) + local timer = device.thread:call_with_delay(SETTLE_DELAY, function() + device:set_field(SETTLE_TIMER, nil) + local level = device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + emit_resting_state(device, level) + end) + device:set_field(SETTLE_TIMER, timer) +end + +-- this is do_refresh +local do_refresh = function(self, device) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) + device:send(Basic.attributes.PowerSource:read(device)) +end + +-- this is window_shade_level_cmd +local function window_shade_level_cmd(driver, device, command) + local go_to_level = command.args.shadeLevel + -- send levels without inverting as: 0% lift (open) to 100% lift (closed) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, go_to_level)) +end + +-- this is window_shade_preset_cmd +local function window_shade_preset_cmd(driver, device, command) + local level = window_shade_utils.get_preset_level(device, command.component) + -- send levels without inverting as: 0% lift (open) to 100% lift (closed) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) +end + +-- this is device_added +local function device_added(self, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end + +-- this is current_position_attr_handler +-- position is 0 = open, 100 = closed; direction is inferred from the change +local function current_position_attr_handler(driver, device, value, zb_rx) + local level = value.value + local previous = device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + local windowShade = capabilities.windowShade.windowShade + + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + + if level <= 0 then + cancel_settle_timer(device) + device:emit_event(windowShade.open()) + elseif level >= 100 then + cancel_settle_timer(device) + device:emit_event(windowShade.closed()) + else + if level < previous then + device:emit_event(windowShade.opening()) + elseif level > previous then + device:emit_event(windowShade.closing()) + end + schedule_settle(device) + end +end + +-- this is do_configure +local function do_configure(self, device) + -- configure elements + device:send(device_management.build_bind_request(device, WindowCovering.ID, self.environment_info.hub_zigbee_eui)) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, Basic.ID, self.environment_info.hub_zigbee_eui)) + device:send(Basic.attributes.PowerSource:configure_reporting(device, 1, 3600)) + + -- read elements + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end + +-- this is battery_perc_attr_handler +local function battery_perc_attr_handler(driver, device, value, zb_rx) + local converted_value = value.value / 2 + converted_value = utils.round(converted_value) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.battery.battery(utils.clamp_value(converted_value, 0, 100))) +end + +-- create the handler object +local sombra_roller_shade_handler = { + NAME = "sombra_roller_shade_handler", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + capability_handlers = { + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd + }, + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + zigbee_handlers = { + attr = { + [WindowCovering.ID] = { + [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = current_position_attr_handler, + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_perc_attr_handler, + } + } + }, + can_handle = require("sombra.can_handle"), +} + +-- return the handler +return sombra_roller_shade_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua new file mode 100644 index 0000000000..27a6c83ca3 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("somfy.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("somfy") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua new file mode 100644 index 0000000000..ce6094564c --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "SOMFY", model = "Glydea Ultra Curtain" }, + { mfr = "SOMFY", model = "Sonesse 30 WF Roller" }, + { mfr = "SOMFY", model = "Sonesse 40 Roller" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua index 2bddcae2b5..da416ba9ea 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua @@ -1,42 +1,19 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local utils = require "st.utils" -local window_preset_defaults = require "st.zigbee.defaults.windowShadePreset_defaults" +local window_shade_utils = require "window_shade_utils" local zcl_clusters = require "st.zigbee.zcl.clusters" local WindowCovering = zcl_clusters.WindowCovering local GLYDEA_MOVE_THRESHOLD = 3 -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "SOMFY", model = "Glydea Ultra Curtain" }, - { mfr = "SOMFY", model = "Sonesse 30 WF Roller" }, - { mfr = "SOMFY", model = "Sonesse 40 Roller" } -} local MOVE_LESS_THAN_THRESHOLD = "_sameLevelEvent" local FINAL_STATE_POLL_TIMER = "_finalStatePollTimer" -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function overwrite_existing_timer_if_needed(device, new_timer) local old_timer = device:get_field(FINAL_STATE_POLL_TIMER) @@ -64,6 +41,7 @@ local function current_position_attr_handler(driver, device, value, zb_rx) elseif current_level ~= level then event = current_level < level and windowShade.opening() or windowShade.closing() local timer = device.thread:call_with_delay(2, function(d) + device:set_field(FINAL_STATE_POLL_TIMER, nil) local current_level = device:get_latest_state(device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) if current_level > 0 and current_level < 100 then @@ -108,7 +86,7 @@ local function window_shade_level_cmd(driver, device, command) end local function window_shade_preset_cmd(driver, device, command) - local level = device.preferences.presetPosition or device:get_field(window_preset_defaults.PRESET_LEVEL_KEY) or window_preset_defaults.PRESET_LEVEL + local level = window_shade_utils.get_preset_level(device, command.component) command.args.shadeLevel = level window_shade_level_cmd(driver, device, command) end @@ -131,7 +109,7 @@ local somfy_handler = { } } }, - can_handle = is_zigbee_window_shade, + can_handle = require("somfy.can_handle"), } return somfy_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua new file mode 100644 index 0000000000..3353c87fd4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("vimar"), + lazy_load_if_possible("aqara"), + lazy_load_if_possible("feibit"), + lazy_load_if_possible("somfy"), + lazy_load_if_possible("sombra"), + lazy_load_if_possible("invert-lift-percentage"), + lazy_load_if_possible("rooms-beautiful"), + lazy_load_if_possible("axis"), + lazy_load_if_possible("yoolax"), + lazy_load_if_possible("hanssem"), + lazy_load_if_possible("screen-innovations"), + lazy_load_if_possible("VIVIDSTORM"), + lazy_load_if_possible("HOPOsmart"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua index 5893ec10b8..26a08b1a84 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -38,7 +28,12 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -59,7 +54,32 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "State transition to unknown", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 255) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.unknown()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -78,7 +98,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -108,7 +131,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -158,13 +184,22 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "windowShadePreset capability should be handled", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 30}})) + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {30}} + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadePreset.position(30))) test.wait_for_events() test.socket.capability:__queue_receive( { @@ -176,7 +211,148 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 30) }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SetShadeLevel command handler", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 }} + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cancel existing set-status timer when a new partial level report arrives", + function() + -- First attr: level 90 sets T1 + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr arrives before T1 fires: should cancel T1 and create T2 + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 15) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 85 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + -- T2 fires; T1 was cancelled so only partially_open from T2 + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Timer callback emits closed when shade reaches level 0", + function() + -- First attr starts partial movement and arms a 1-second status timer + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr reports fully closed (level=0); goes through elseif branch, T1 still pending + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + -- T1 fires; get_latest_state returns 0 so the callback emits closed() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Timer callback emits open when shade reaches level 100", + function() + -- First attr starts partial movement and arms a 1-second status timer + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr reports fully open (level=100); goes through elseif branch, T1 still pending + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + -- T1 fires; get_latest_state returns 100 so the callback emits open() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua index 98d8ccb44f..f505e0d661 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -68,7 +58,12 @@ end zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -89,7 +84,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(99)) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,7 +122,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -138,11 +139,15 @@ test.register_coroutine_test( { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) + -- newly added devices will ignore the preference test.socket.zigbee:__expect_send({ mock_device.id, - WindowCovering.server.commands.GoToLiftPercentage(mock_device, 70) + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -160,7 +165,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -196,7 +204,10 @@ test.register_coroutine_test( mock_device.id, clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -229,7 +240,10 @@ test.register_coroutine_test( test.mock_time.advance_time(30) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -277,7 +291,10 @@ test.register_coroutine_test( clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -323,7 +340,124 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Default response emits opening when current level is higher than target", + function() + -- Establish a partially-closed shade state (zigbee value 90 → shadeLevel 10) + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 90) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(10)) + ) + test.wait_for_events() + -- Send open command: MOST_RECENT_SETLEVEL = 0 (level = 100 - 100 = 0) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + test.wait_for_events() + -- Default response: current_level_zigbee=90, most_recent=0 → 90 > 0 → opening() + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(WindowCovering.ID, WindowCovering.server.commands.GoToLiftPercentage.ID, Status.SUCCESS) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Attr handler emits partially_open when report matches most-recent set level", + function() + -- Send presetPosition; preset level = 50 so MOST_RECENT_SETLEVEL = 50 (100-50=50) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + }) + test.wait_for_events() + -- Attr report value=50 matches MOST_RECENT_SETLEVEL; shade stops at partial level + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + -- current_level was nil → partially_open from the nil-check branch + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + -- most_recent matches and value is partial → partially_open again (from the match branch) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Spontaneous level report towards open emits opening event", + function() + -- First attr establishes a partial shade level (value=10 → shadeLevel=90) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(90)) + ) + -- Second attr moves toward open (value=5 < current zigbee 10 → opening) + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 5) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(95)) + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua new file mode 100644 index 0000000000..71dc5c81a4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua @@ -0,0 +1,215 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" + +local PRIVATE_CLUSTER_ID = 0xFCC8 +local MFG_CODE = 0x1235 + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("window-shade-only.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "HOPOsmart", + model = "A2230011", + server_clusters = {0x0000, 0xFCC8} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade open command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShade", component = "main", command = "open", args = {} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade close command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShade", component = "main", command = "close", args = {} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.DownOrClose(mock_device) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade pause command", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "windowShade", component = "main", command = "pause", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.Stop(mock_device) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported 0 and driver emit windowShade.open", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.windowShade.windowShade.open())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported 1 and driver emit windowShade.opening", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.windowShade.windowShade.opening())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported 2 and driver emit windowShade.closed", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 2 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.windowShade.windowShade.closed())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported 3 and driver emit windowShade.closeing", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 3 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.windowShade.windowShade.closing())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported 4 and driver emit windowShade.partially_open", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 4 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.windowShade.windowShade.partially_open())) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua index 33cacf598e..145876ff64 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -26,7 +16,12 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -58,7 +53,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -108,7 +106,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -129,6 +130,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -153,6 +157,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -172,6 +179,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -197,6 +207,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 33) } } + }, + { + min_api_version = 17 } ) @@ -221,7 +234,45 @@ test.register_message_test( mock_device.id, clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) } - } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShadePreset", component = "main", + command = "setPresetPosition", args = {20} + } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShadePreset.position(20)) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 20) + } + }, + }, + { + min_api_version = 17 } ) @@ -245,7 +296,10 @@ test.register_coroutine_test( mock_device.id, clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -277,7 +331,265 @@ test.register_coroutine_test( clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - positive step", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 60) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - negative step", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 30) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 30 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 30) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - accumulated steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 60) + }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 55) + }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 15 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 70) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 90) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 90) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 10 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 10) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - zero stepSize", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +-- Test step_shade_level_handler with zero step +test.register_coroutine_test( + "step_shade_level_handler returns early when stepSize is zero", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + -- Send zero step - should return early without sending any command + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua new file mode 100644 index 0000000000..ecf2586a5c --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua @@ -0,0 +1,368 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" + +local PRIVATE_CLUSTER_ID = 0xFCC9 +local MFG_CODE = 0x1235 + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("projector-screen-VWSDSTUST120H.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "VIVIDSTORM", + model = "VWSDSTUST120H", + server_clusters = {0x0000, 0x0102, 0xFCC9} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "capability - refresh", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + + local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) + local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) + test.socket.zigbee:__expect_send({mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("hardwareFault", capabilities.hardwareFault.hardwareFault.clear())) + + local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) + local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) + test.socket.zigbee:__expect_send({mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade open command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShade", component = "main", command = "open", args = {} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade close command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShade", component = "main", command = "close", args = {} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.DownOrClose(mock_device) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade pause command", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "windowShade", component = "main", command = "pause", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.Stop(mock_device) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Setlimit Delete upper limit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "main", command ="setMode" , args = {"Delete upper limit"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Setlimit Set the upper limit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "main", command ="setMode" , args = {"Set the upper limit"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Setlimit Delete lower limit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "main", command ="setMode" , args = {"Delete lower limit"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Setlimit Set the lower limit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "main", command ="setMode" , args = {"Set the lower limit"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 3) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported mode 0 and driver emit Delete upper limit", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.mode.mode("Delete upper limit"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported mode 1 and driver emit Set the upper limit", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.mode.mode("Set the upper limit"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported mode 2 and driver emit Delete lower limit", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 2 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.mode.mode("Delete lower limit"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported mode 3 and driver emit Set the lower limit", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 3 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.mode.mode("Set the lower limit"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported hardwareFault 0 and driver emit capabilities.hardwareFault.hardwareFault.clear()", + function() + local attr_report_data = { + { 0x0001, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("hardwareFault", + capabilities.hardwareFault.hardwareFault.clear())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device reported hardwareFault 1 and driver emit capabilities.hardwareFault.hardwareFault.detected()", + function() + local attr_report_data = { + { 0x0001, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("hardwareFault", + capabilities.hardwareFault.hardwareFault.detected())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering CurrentPositionLiftPercentage report 5 emit closing", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 5) + } + ) + test.mock_time.advance_time(5) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering CurrentPositionLiftPercentage report 0 emit closed", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) + test.mock_time.advance_time(5) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua index 71885fe291..08c71ec85a 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local base64 = require "st.base64" local capabilities = require "st.capabilities" @@ -39,6 +29,8 @@ local PREF_REVERSE_ON = "\x00\x02\x00\x01\x00\x00\x00" local PREF_SOFT_TOUCH_OFF = "\x00\x08\x00\x00\x00\x01\x00" local PREF_SOFT_TOUCH_ON = "\x00\x08\x00\x00\x00\x00\x00" +local SHADE_STATE_ATTRIBUTE_ID = 0x0404 + local APPLICATION_VERSION = "application_version" local mock_device = test.mock_device.build_test_zigbee_device( @@ -74,15 +66,14 @@ local mock_version_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - test.mock_device.add_test_device(mock_version_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_version_device)end test.set_test_init_function(test_init) test.register_coroutine_test( "Handle added lifecycle", function() + -- The initial window shade event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", @@ -119,7 +110,41 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, data_types.CharString, PREF_SOFT_TOUCH_ON) }) - end + -- Avoid sending the initial window shade event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "stse.deviceInitialization", component_id = "main", + attribute_id = "supportedInitializedState", + state = { value = { "notInitialized", "initializing", "initialized" } }, + visibility = { displayed = false } + } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", deviceInitialization.initializedState.notInitialized()) + ) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE + , + data_types.Uint8, + 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, + data_types.CharString, + PREF_REVERSE_OFF) }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, + data_types.CharString, + PREF_SOFT_TOUCH_ON) }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -151,7 +176,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_read(mock_device, Basic.ID, { PREF_ATTRIBUTE_ID }, MFG_CODE) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -170,7 +198,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -189,7 +220,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -208,7 +242,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -224,7 +261,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 'open') }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -240,7 +280,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.DownOrClose(mock_device, 'close') }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -256,7 +299,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.Stop(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -300,7 +346,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", deviceInitialization.initializedState.initialized())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -329,7 +378,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, data_types.CharString, PREF_SOFT_TOUCH_OFF) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -350,11 +402,12 @@ test.register_coroutine_test( mock_device.id, AnalogOutput.attributes.PresentValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) --- mock_version_device - test.register_coroutine_test( "Window shade state closed with application version handler", function() @@ -379,7 +432,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_version_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -406,7 +462,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_version_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -433,7 +492,171 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_version_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set Level handler", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 }} + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "shade state attribute handler - initial state open", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + mock_device:set_field("initState", "open") + local attr_report_data = { + { SHADE_STATE_ATTRIBUTE_ID, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + AnalogOutput.attributes.PresentValue:read(mock_device) + } + ) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "shade state attribute handler - initial state close", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + mock_device:set_field("initState", "close") + local attr_report_data = { + { SHADE_STATE_ATTRIBUTE_ID, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + AnalogOutput.attributes.PresentValue:read(mock_device) + } + ) + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "shade state attribute handler - initial state reverse", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + mock_device:set_field("initState", "reverse") + local attr_report_data = { + { SHADE_STATE_ATTRIBUTE_ID, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + AnalogOutput.attributes.PresentValue:read(mock_device) + } + ) + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, + MFG_CODE) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "shade state attribute handler - open", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + local attr_report_data = { + { SHADE_STATE_ATTRIBUTE_ID, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + end, + { + min_api_version = 17 + } ) +test.register_coroutine_test( + "shade state attribute handler - close", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + local attr_report_data = { + { SHADE_STATE_ATTRIBUTE_ID, data_types.Uint8.ID, 2 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + end, + { + min_api_version = 17 + } +) + + + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua new file mode 100644 index 0000000000..0177c71467 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua @@ -0,0 +1,452 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local test = require "integration_test" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" + +local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] +local chargingState = capabilities["stse.chargingState"] +local hookLockState = capabilities["stse.hookLockState"] +local hookLockStateId = "stse.hookLockState" +test.add_package_capability("initializedStateWithGuide.yaml") +test.add_package_capability("hookLockState.yaml") +test.add_package_capability("chargingState.yaml") + +local Groups = clusters.Groups +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration +local PRIVATE_CURTAIN_MANUAL_ATTRIBUTE_ID = 0x0401 +local PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID = 0x0402 +local PRIVATE_CURTAIN_STATUS_ATTRIBUTE_ID = 0x0421 +local PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID = 0x0427 +local PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID = 0x0428 +local PRIVATE_CLUSTER_ID = 0xFCC0 +local MFG_CODE = 0x115F + + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-aqara-curtain-driver-e1.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.curtain.agl001", + server_clusters = { PRIVATE_CLUSTER_ID, Groups.ID, Basic.ID, PowerConfiguration.ID, WindowCovering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +local function custom_write_attribute(device, cluster, attribute, data_type, value, mfg_code) + local data = data_types.validate_or_build_type(value, data_type) + local message = cluster_base.write_attribute(device, data_types.ClusterId(cluster), attribute, data) + if mfg_code ~= nil then + message.body.zcl_header.frame_ctrl:set_mfg_specific() + message.body.zcl_header.mfg_code = data_types.validate_or_build_type(mfg_code, data_types.Uint16, "mfg_code") + else + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + end + return message +end + +test.register_coroutine_test( + "Handle added lifecycle", + function() + -- The initial window shade event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", hookLockState.hookLockState.unlocked()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", chargingState.chargingState.stopped()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(100)) + ) + -- Avoid sending the initial window shade event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", hookLockState.hookLockState.unlocked()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", chargingState.chargingState.stopped()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(100)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle doConfigure lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, WindowCovering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(mock_device, 0, 600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Groups.server.commands.RemoveAllGroups(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_attribute_read(mock_device, PRIVATE_CLUSTER_ID, { PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID }, MFG_CODE) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_attribute_read(mock_device, PRIVATE_CLUSTER_ID, { PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID }, MFG_CODE) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle reverseCurtainDirection in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.reverseCurtainDirection"] = true } + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + custom_write_attribute(mock_device , WindowCovering.ID, WindowCovering.attributes.Mode.ID, + data_types.Bitmap8, 0x01, nil) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle softTouch in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.softTouch"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_CURTAIN_MANUAL_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, false) }) + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "Window shade open cmd handler (initialized)", + function() + local attr_report_data = { + { PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.initialized()) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.UpOrOpen(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Window shade close cmd handler (notInitialized)", + function() + local attr_report_data = { + { PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + ) + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "close", args = {} } + } + ) + -- test.socket.zigbee:__expect_send({ + -- mock_device.id, + -- WindowCovering.server.commands.DownOrClose(mock_device) + -- }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Window shade pause cmd handler(partially open)(initialized)", + function() + local attr_report_data = { + { PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.initialized()) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "pause", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.Stop(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "hook LocK Lock cmd handler", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = hookLockStateId, component = "main", command = "hookLock", args = {} } + } + ) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "hook LocK Unlock cmd handler", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = hookLockStateId, component = "main", command = "hookUnlock", args = {} } + } + ) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x00) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "refresh", component = "main", command = "refresh", args = {} + } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_attribute_read(mock_device, PRIVATE_CLUSTER_ID, { PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID }, MFG_CODE) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_attribute_read(mock_device, PRIVATE_CLUSTER_ID, { PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID }, MFG_CODE) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "curtain charging state report should be handled", + function() + local attr_report_data = { + { Basic.attributes.PowerSource.ID, data_types.Enum8.ID, 4 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", chargingState.chargingState.charging()) + ) + end, + { + min_api_version = 17 + } +) + + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 200) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "curtain range flag report should be handled", + function() + local attr_report_data = { + { PRIVATE_CURTAIN_RANGE_FLAG_ATTRIBUTE_ID, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.initialized()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Shade state report should be handled", + function() + local attr_report_data = { + { PRIVATE_CURTAIN_STATUS_ATTRIBUTE_ID, data_types.Uint8.ID, 0x01 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "hook lock state report should be handled", + function() + local attr_report_data = { + { PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", hookLockState.hookLockState.locking()) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua index 9da4ffe60f..3e1e013c38 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local base64 = require "st.base64" local capabilities = require "st.capabilities" @@ -21,6 +11,7 @@ local SinglePrecisionFloat = require "st.zigbee.data_types".SinglePrecisionFloat local t_utils = require "integration_test.utils" local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] local shadeRotateState = capabilities["stse.shadeRotateState"] @@ -39,10 +30,16 @@ local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F local PREF_ATTRIBUTE_ID = 0x0401 +local SHADE_STATE_ATTRIBUTE_ID = 0x0404 local PREF_REVERSE_OFF = "\x00\x02\x00\x00\x00\x00\x00" local PREF_REVERSE_ON = "\x00\x02\x00\x01\x00\x00\x00" +local MULTISTATE_CLUSTER_ID = 0x0013 +local MULTISTATE_ATTRIBUTE_ID = 0x0055 +local ROTATE_UP_VALUE = 0x0004 +local ROTATE_DOWN_VALUE = 0x0005 + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("window-treatment-aqara-roller-shade-rotate.yml"), @@ -60,15 +57,14 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) test.register_coroutine_test( "Handle added lifecycle", function() + -- The initial window shade event should be send during the device's first time onboarding test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", @@ -84,7 +80,29 @@ test.register_coroutine_test( mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.notInitialized()) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", shadeRotateState.rotateState.idle()) + mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({visibility = { displayed = false }})) + ) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE + , + data_types.Uint8, + 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, + data_types.CharString, + PREF_REVERSE_OFF) }) + -- Avoid sending the initial window shade event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({visibility = { displayed = false }})) ) test.socket.zigbee:__expect_send({ mock_device.id, @@ -96,7 +114,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, data_types.CharString, PREF_REVERSE_OFF) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -128,7 +149,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_read(mock_device, Basic.ID, { PREF_ATTRIBUTE_ID }, MFG_CODE) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -147,7 +171,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +193,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -185,31 +215,68 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Window shade open cmd handler", function() + local attr_report_data = { + { PREF_ATTRIBUTE_ID, data_types.CharString.ID, "\x00\x00\x01\x00\x00\x00\x00" } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + initializedStateWithGuide.initializedStateWithGuide.initialized())) + test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, { capability = "windowShade", component = "main", command = "open", args = {} } } ) - end + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) + }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Window shade close cmd handler", function() + local attr_report_data = { + { PREF_ATTRIBUTE_ID, data_types.CharString.ID, "\x00\x00\x01\x00\x00\x00\x00" } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + initializedStateWithGuide.initializedStateWithGuide.initialized())) + test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, { capability = "windowShade", component = "main", command = "close", args = {} } } ) - end + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -225,7 +292,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.Stop(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -239,9 +309,12 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({state_change = true})) + mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -256,9 +329,12 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({state_change = true})) + mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -274,6 +350,8 @@ test.register_coroutine_test( } updates.preferences["stse.reverseRollerShadeDir"] = true test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + test.mock_time.advance_time(1) + test.socket.zigbee:__expect_send( { mock_device.id, @@ -291,7 +369,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.initialized())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -312,7 +393,123 @@ test.register_coroutine_test( mock_device.id, AnalogOutput.attributes.PresentValue:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) +test.register_coroutine_test( + "SetShadeLevel command handler", + function() + local attr_report_data = { + { PREF_ATTRIBUTE_ID, data_types.CharString.ID, "\x00\x00\x01\x00\x00\x00\x00" } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + initializedStateWithGuide.initializedStateWithGuide.initialized())) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 }} + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + }) + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "PREF_ATTRIBUTE_ID attribute handler - notInitialized", + function() + local attr_report_data = { + { PREF_ATTRIBUTE_ID, data_types.CharString.ID, "\x00\x00\x00\x00\x00\x00\x00" } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + initializedStateWithGuide.initializedStateWithGuide.notInitialized())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SHADE_STATE_ATTRIBUTE_ID attribute handler", + function() + local attr_report_data = { + { SHADE_STATE_ATTRIBUTE_ID, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + AnalogOutput.attributes.PresentValue:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle sensitivity adjustment capability", + function() + local attr_report_data = { + { PREF_ATTRIBUTE_ID, data_types.CharString.ID, "\x00\x00\x01\x00\x00\x00\x00" } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Basic.ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + initializedStateWithGuide.initializedStateWithGuide.initialized())) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.shadeRotateState", component = "main", command = "setRotateState", args = {"rotateUp"} }}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }}) )) + + local message = cluster_base.write_manufacturer_specific_attribute(mock_device, MULTISTATE_CLUSTER_ID, + MULTISTATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint16, ROTATE_UP_VALUE) + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + + test.socket.zigbee:__expect_send({ mock_device.id, message }) + + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.shadeRotateState", component = "main", command = "setRotateState", args = {"rotateDown"} }}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }}) )) + + local message = cluster_base.write_manufacturer_specific_attribute(mock_device, MULTISTATE_CLUSTER_ID, + MULTISTATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint16, ROTATE_DOWN_VALUE) + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + + test.socket.zigbee:__expect_send({ mock_device.id, message }) + end, + { + min_api_version = 17 + } +) + + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua old mode 100755 new mode 100644 index e52b1ef1cd..fb63dd121d --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local base64 = require "st.base64" @@ -44,7 +34,12 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -85,7 +80,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -123,7 +121,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -173,7 +174,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -198,7 +202,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -227,7 +234,10 @@ test.register_coroutine_test( WindowCovering.server.commands.DownOrClose(mock_device) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -252,7 +262,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, 0xFE) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -281,7 +294,10 @@ test.register_coroutine_test( WindowCovering.server.commands.UpOrOpen(mock_device, 0x64) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -318,7 +334,10 @@ test.register_coroutine_test( mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -357,7 +376,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.Stop(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -382,7 +404,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, 84) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -414,7 +439,10 @@ test.register_coroutine_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 33) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -439,7 +467,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, 127) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -460,8 +491,9 @@ test.register_coroutine_test( } ) test.socket.capability:__set_channel_ordering("relaxed") + -- freshly joined devices will ignore the preference value test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(30)) + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) @@ -469,10 +501,13 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send( { mock_device.id, - WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 30) + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 50) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -504,7 +539,10 @@ test.register_coroutine_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 50) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -546,7 +584,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.SWBuildID:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -581,7 +622,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.SWBuildID:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -631,7 +675,12 @@ test.register_coroutine_test( zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua index b77034e66d..01f2e98556 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -20,6 +10,7 @@ local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local Level = clusters.Level +local WindowCovering = clusters.WindowCovering local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("window-treatment-profile.yml"), @@ -38,7 +29,12 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -70,7 +66,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -120,7 +119,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -141,6 +143,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -165,6 +170,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -184,6 +192,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -209,6 +220,9 @@ test.register_message_test( Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(33/100 * 254)) } } + }, + { + min_api_version = 17 } ) @@ -223,11 +237,15 @@ test.register_coroutine_test( { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) + -- newly joined devices will ignore the preference test.socket.zigbee:__expect_send({ mock_device.id, - Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(30/100 * 254)) + Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(50/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -245,7 +263,10 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(50/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -259,17 +280,30 @@ test.register_coroutine_test( { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) + -- newly joined devices will ignore the preference test.socket.zigbee:__expect_send({ mock_device.id, - Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(100/100 * 254)) + Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(50/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "windowShadePreset capability should be handled with preset value = 1 ", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 1}})) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 10}})) + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {1} } + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(1)) + ) test.wait_for_events() test.socket.capability:__queue_receive( { @@ -281,13 +315,25 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(1/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "windowShadePreset capability should be handled with a positive preset value of < 1", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 0}})) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 1}})) + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {0} } + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(0)) + ) test.wait_for_events() test.socket.capability:__queue_receive( { @@ -299,7 +345,10 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(0/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -324,6 +373,9 @@ test.register_message_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, math.floor(50 / 100 * 254)) } } + }, + { + min_api_version = 17 } ) @@ -347,7 +399,10 @@ test.register_coroutine_test( mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -371,8 +426,12 @@ test.register_coroutine_test( zigbee_test_utils.mock_hub_eui, Level.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua index 73be8ebabf..15cae0e344 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -44,7 +34,12 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -83,6 +78,7 @@ end test.register_coroutine_test( "Device Added ", function() + SeqNum = 0 test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send( @@ -95,12 +91,16 @@ test.register_coroutine_test( mock_device.id, build_tx_message(mock_device,"\x02\x02\x00\x04\x00\x00\x00\x32") }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Open handler", function() + SeqNum = 0 test.socket.zigbee:__queue_receive({ mock_device.id, build_rx_message(mock_device,"\x03\x02\x00\x04\x00\x00\x00\x00") @@ -136,12 +136,16 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Close handler", function() + SeqNum = 0 test.socket.zigbee:__queue_receive({ mock_device.id, build_rx_message(mock_device,"\x03\x02\x00\x04\x00\x00\x00\x64") @@ -178,12 +182,16 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Pause handler", function() + SeqNum = 0 test.socket.zigbee:__queue_receive({ mock_device.id, build_rx_message(mock_device,"\x03\x02\x00\x04\x00\x00\x00\x64") @@ -232,12 +240,16 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Set Level handler", function() + SeqNum = 0 test.socket.zigbee:__queue_receive({ mock_device.id, build_rx_message(mock_device,"\x03\x02\x00\x04\x00\x00\x00\x64") @@ -274,13 +286,23 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Preset position handler", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 30}})) + SeqNum = 0 + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {30}} + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadePreset.position(30))) test.wait_for_events() test.socket.zigbee:__queue_receive({ @@ -319,12 +341,16 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(30))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Information changed : Reverse", function() + SeqNum = 0 test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.zigbee:__queue_receive({ mock_device.id, @@ -350,7 +376,10 @@ test.register_coroutine_test( mock_device.id, build_tx_message(mock_device,"\x05\x04\x00\x01\x01") }) - end + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua new file mode 100644 index 0000000000..3c7c0cbee2 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua @@ -0,0 +1,346 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Test for statelessWindowShadeLevelStep capability with Somfy (reverse) devices +-- Tests devices like Somfy Glydea that use inverted lift percentage + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local WindowCovering = clusters.WindowCovering + +-- Create mock Somfy Glydea device using WindowCovering cluster with inverted logic +-- This device matches the somfy sub-driver which inverts the lift percentage +local somfy_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SOMFY", + model = "Glydea Ultra Curtain", + server_clusters = { WindowCovering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(somfy_device) + test.socket.capability:__expect_send( + somfy_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + somfy_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +-- Test 1: Boundary condition - stepSize = 0 should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - stepSize 0 does not send command", + function() + -- Set initial state via WindowCovering cluster report: 50% (inverted: device reports 50 for 50% shade) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 0 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + + -- Should NOT send any Zigbee command (stepSize 0 is ignored) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 2: Normal step up operation with Somfy (reverse) device +-- Somfy inverts the value: shadeLevel 60 means device goes to 100-60=40 +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - normal step up", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 10 + -- new_target_level = clamp(50 + 10, 0, 100) = 60 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 60 = 40 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 40) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Normal step down operation with Somfy (reverse) device +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - normal step down", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 25 (inverted: 100-25=75 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 25) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 75) + + -- Send stepShadeLevel command with stepSize = -25 + -- new_target_level = clamp(75 + (-25), 0, 100) = 50 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -25 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 50 = 50 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 50) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Boundary condition - value clamped to 100 (maximum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - value clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 10 (inverted: 100-10=90 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 10) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 90) + + -- Send stepShadeLevel command with stepSize = 50 + -- new_target_level = clamp(90 + 50, 0, 100) = 100 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 100 = 0 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 0) + }) + end, + { min_api_version = 17 } +) + +-- Test 5: Boundary condition - value clamped to 0 (minimum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - value clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 80 (inverted: 100-80=20 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 80) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 20 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 20) + + -- Send stepShadeLevel command with stepSize = -50 + -- new_target_level = clamp(20 + (-50), 0, 100) = 0 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 0 = 100 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 100) + }) + end, + { min_api_version = 17 } +) + +-- Test 6: Continuous step operations with mixed directions +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - continuous steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Step 1: stepSize = 10 (up) -> 60% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 60 = 40 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 40) + }) + test.wait_for_events() + + -- Step 2: stepSize = -5 (down) -> 55% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 55 = 45 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 45) + }) + test.wait_for_events() + + -- Step 3: stepSize = 20 (up) -> 75% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 75 = 25 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 25) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 7: nil stepSize should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - nil stepSize does not send command", + function() + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with nil stepSize + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } + }) + + -- Should NOT send any Zigbee command + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua new file mode 100644 index 0000000000..602aa34bd4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua @@ -0,0 +1,400 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Test for statelessWindowShadeLevelStep capability with Level cluster devices +-- Tests devices like AXIS Gear that use Level cluster for window covering control + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local Level = clusters.Level +local WindowCovering = clusters.WindowCovering + +-- Create mock AXIS Gear device using Level cluster +-- This device matches the axis sub-driver which uses Level cluster +local axis_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "AXIS", + model = "Gear", + server_clusters = { Level.ID, WindowCovering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(axis_device) + test.socket.capability:__expect_send( + axis_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + axis_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +-- Test 1: Boundary condition - stepSize = 0 should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - stepSize 0 does not send command", + function() + -- Set initial state via Level cluster report: 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 0 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + + -- Should NOT send any Zigbee command (stepSize 0 is ignored) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 2: Normal step up operation with Level cluster +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - normal step up", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 10 + -- new_target_level = clamp(50 + 10, 0, 100) = 60 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 60 } } + }) + -- AXIS sub-driver also emits windowShade event (opening because 60 > 50) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + + -- Should send MoveToLevelWithOnOff with level 60 (152 out of 254, rounded from 152.4) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 152) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Normal step down operation with Level cluster +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - normal step down", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 75% (191 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 191) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 75) + + -- Send stepShadeLevel command with stepSize = -25 + -- new_target_level = clamp(75 + (-25), 0, 100) = 50 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -25 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- AXIS sub-driver also emits windowShade event (closing because 50 < 75) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + + -- Should send MoveToLevelWithOnOff with level 50 (127 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 127) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Boundary condition - value clamped to 100 (maximum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - value clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 90% (229 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 229) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 90) + + -- Send stepShadeLevel command with stepSize = 50 + -- new_target_level = clamp(90 + 50, 0, 100) = 100 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 100 } } + }) + -- AXIS sub-driver also emits windowShade event (opening because 100 > 90) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + + -- Should send MoveToLevelWithOnOff with level 100 (254 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 254) + }) + end, + { min_api_version = 17 } +) + +-- Test 5: Boundary condition - value clamped to 0 (minimum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - value clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 20% (51 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 51) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 20 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 20) + + -- Send stepShadeLevel command with stepSize = -50 + -- new_target_level = clamp(20 + (-50), 0, 100) = 0 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 0 } } + }) + -- AXIS sub-driver also emits windowShade event (closing because 0 < 20) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + + -- Should send MoveToLevelWithOnOff with level 0 (0 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 0) + }) + end, + { min_api_version = 17 } +) + +-- Test 6: Continuous step operations with mixed directions +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - continuous steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Step 1: stepSize = 10 (up) -> 60% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 60 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 152) + }) + test.wait_for_events() + + -- Step 2: stepSize = -5 (down) -> 55% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 55 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 140) + }) + test.wait_for_events() + + -- Step 3: stepSize = 20 (up) -> 75% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 191) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 7: nil stepSize should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - nil stepSize does not send command", + function() + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with nil stepSize + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } + }) + + -- Should NOT send any Zigbee command + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua index 393a508f01..9f80bfdc80 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local base64 = require "st.base64" @@ -46,9 +36,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end + test.mock_device.add_test_device(mock_device)end test.set_test_init_function(test_init) @@ -68,7 +56,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -87,7 +78,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -106,7 +100,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(25)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -125,7 +122,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -144,7 +144,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -164,7 +167,10 @@ test.register_coroutine_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device,100 - 33) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -181,7 +187,10 @@ test.register_coroutine_test( OnOff.server.commands.On(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -198,7 +207,10 @@ test.register_coroutine_test( OnOff.server.commands.Off(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -215,7 +227,10 @@ test.register_coroutine_test( WindowCovering.server.commands.Stop(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -234,7 +249,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -276,7 +294,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, INVERT_CLUSTER, INVERT_CLUSTER_ATTRIBUTE, MFG_CODE, data_types.Boolean, updates.preferences.invert)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -311,7 +333,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, INVERT_CLUSTER, {INVERT_CLUSTER_ATTRIBUTE}, 0x0000) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -357,7 +382,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_read(mock_device, INVERT_CLUSTER, {INVERT_CLUSTER_ATTRIBUTE}, 0x0000) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua new file mode 100644 index 0000000000..a929eb4e98 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua @@ -0,0 +1,464 @@ +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +local MFG_CODE = 0x1228 +local PRIVATE_CLUSTER_ID = 0xFCCC +local PRIVATE_ATTRIBUTE_ID = 0x0012 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-powerSource.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Screen Innovations", + model = "WM25/L-Z", + server_clusters = {0x0000, 0x0001, 0x0102} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Window Shade state open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Window Shade state closed", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motor direction idle with Window Shade state partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 0 }-- device sends 0 for idle + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 25) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(25)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade open cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.UpOrOpen(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade close cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "close", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.DownOrClose(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade pause cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "pause", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.Stop(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery Percentage Remaining test cases", + function() + mock_device:set_field("motorState", "idle") + local battery_test_map = { + [200] = 100, + [100] = 50, + [0] = 0 + } + for bat_perc_rem, batt_perc_out in pairs(battery_test_map) do + test.socket.zigbee:__queue_receive({ mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, bat_perc_rem) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc_out)) ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should generate expected messages", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doConfigure should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, WindowCovering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Basic.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:configure_reporting(mock_device, 1, 3600) + }) + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "added should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motor direction opening with window_shade_level_cmd", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 1 }-- device sends 1 for opening + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 45 } } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 45) + } + ) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 45) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(45)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motor direction closing with window_shade_level_cmd", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 2 }-- device sends 2 for closing + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 } } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 85) + } + ) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 85) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(85)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motor direction closing with window_shade_preset_cmd", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 2 }-- device sends 2 for closing + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShadePreset", component = "main", command = "presetPosition", args = {}}, + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + } + ) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Power Source test cases", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 3)}) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 4)}) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 0)}) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown())) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua new file mode 100644 index 0000000000..35ab9eab24 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua @@ -0,0 +1,434 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-powerSource.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Sombra Shades", + model = "SOMBRA/Z-M", + server_clusters = {0x0000, 0x0001, 0x0102} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Window Shade state open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Window Shade state closed", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "A falling position reports opening, then settles to partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + -- establish a known starting position (fully closed) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.wait_for_events() + -- a lower position than before => opening + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 60) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(60)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.wait_for_events() + -- no further reports for the settle delay => partially open + test.mock_time.advance_time(3) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "A rising position reports closing, then settles to partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + -- establish a known starting position (fully open) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.wait_for_events() + -- a higher position than before => closing + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 40) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.wait_for_events() + -- no further reports for the settle delay => partially open + test.mock_time.advance_time(3) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade open cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.UpOrOpen(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade close cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "close", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.DownOrClose(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade pause cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "pause", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.Stop(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set shade level command", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 45 } } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 45) + } + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Preset position command", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + } + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery Percentage Remaining test cases", + function() + local battery_test_map = { + [200] = 100, + [100] = 50, + [0] = 0 + } + for bat_perc_rem, batt_perc_out in pairs(battery_test_map) do + test.socket.zigbee:__queue_receive({ mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, bat_perc_rem) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc_out)) ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Power Source test cases", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 3) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 0) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown())) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should generate expected messages", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doConfigure should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, WindowCovering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Basic.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:configure_reporting(mock_device, 1, 3600) + }) + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "added should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) + ) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua index 54e8ad0b2a..e772b46bb9 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -39,7 +29,12 @@ local mock_device = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -71,7 +66,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -121,7 +119,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -158,7 +159,10 @@ test.register_coroutine_test( }) test.mock_time.advance_time(3) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -237,7 +241,10 @@ test.register_coroutine_test( } ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -258,6 +265,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -282,6 +292,9 @@ test.register_message_test( WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -301,6 +314,9 @@ test.register_message_test( WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -326,6 +342,9 @@ test.register_message_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 33) } } + }, + { + min_api_version = 17 } ) @@ -351,6 +370,9 @@ test.register_message_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) } } + }, + { + min_api_version = 17 } ) @@ -358,7 +380,13 @@ test.register_message_test( test.register_coroutine_test( "windowShadePreset capability should be handled with preset value of 1", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 1}})) + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {1}} + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadePreset.position(1))) test.wait_for_events() test.socket.capability:__queue_receive( { @@ -370,13 +398,22 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 99) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "windowShadePreset capability should be handled with preset value of 100", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 100}})) + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {100}} + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadePreset.position(100))) test.wait_for_events() test.socket.capability:__queue_receive( { @@ -388,7 +425,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -406,31 +446,22 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "windowShadePreset capability should be handled with preset value of > 100", + "windowShadePreset capability should be handled with preset value of < 1 (but positive)", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 101}})) - test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, - { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {0}} } ) - test.socket.zigbee:__expect_send({ - mock_device.id, - WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) - }) - end -) - -test.register_coroutine_test( - "windowShadePreset capability should be handled with preset value of < 1 (but positive)", - function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {presetPosition = 0}})) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadePreset.position(0))) test.wait_for_events() test.socket.capability:__queue_receive( { @@ -442,7 +473,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -464,7 +498,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -497,7 +534,136 @@ test.register_coroutine_test( WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "PhysicalClosedLimitLift attribute handler", + function() + --test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.PhysicalClosedLimitLift:build_test_attr_report(mock_device, 10) + } + ) + test.socket.capability:__expect_send( + { + mock_device.id, + { + capability_id = "windowShadeLevel", component_id = "main", + attribute_id = "shadeLevel", state = { value = 0 } + } + } + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Attribute handler reports closed when level is 0", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 0 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Attribute handler reports open when level is 100", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 100 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Cancel existing poll timer when a new partial level report arrives", + function() + -- First attr: level 90 creates T1 via overwrite_existing_timer_if_needed + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr before T1 fires: overwrite_existing_timer_if_needed cancels T1 and stores T2 + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 15) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 85 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + -- T2 fires; T1 was cancelled so only one partially_open + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua new file mode 100644 index 0000000000..40e10d6b3b --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua @@ -0,0 +1,475 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile-no-firmware-update.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Vimar", + model = "Window_Cov_Module_v1.0", + server_clusters = {0x000, 0x0003, 0x0004, 0x0005, 0x0102} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "State transnsition from opening to partially open", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 99) + } + ) + test.socket.capability:__expect_send( + { + mock_device.id, + { + capability_id = "windowShadeLevel", component_id = "main", + attribute_id = "shadeLevel", state = { value = 1 } + } + } + ) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "State transnsition from opening to closing", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 90) + } + ) + test.socket.capability:__expect_send( + { + mock_device.id, + { + capability_id = "windowShadeLevel", component_id = "main", + attribute_id = "shadeLevel", state = { value = 10 } + } + } + ) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 95) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "windowShadeLevel", component_id = "main", + attribute_id = "shadeLevel", state = { value = 5 } + } + }) + test.mock_time.advance_time(3) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade open command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShade", component = "main", command = "open", args = {} + } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade close command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShade", component = "main", command = "close", args = {} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window shade pause command", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "windowShade", component = "main", command = "pause", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.Stop(mock_device) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window Shade level command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShadeLevel", component = "main", + command = "setShadeLevel", args = { 33 } + } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(33)) + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 67) + } + }, + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window Shade Preset command", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) + ) + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "refresh", component = "main", command = "refresh", args = {} + } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) + ) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(mock_device, + 0, + 600, + 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + clusters.WindowCovering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Attribute handler reports closed when shade reaches level 0", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 0 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Attribute handler reports open when shade reaches level 100", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 100 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SetLevel command emits closing when requested level is below current level", + function() + -- Attr report sets current shade level to 90 (inverted value=10) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.wait_for_events() + -- Now both vimar flags are false; requesting level 30 (< 90) triggers closing branch + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 30 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(30)) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 70) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SetLevel command is ignored (early return) when shades are already moving", + function() + -- Open command: current=0 < 100 → opening, sets VIMAR_SHADES_OPENING=true + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + test.wait_for_events() + -- While opening, a different setShadeLevel is requested: ignored with current level re-emitted + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua new file mode 100644 index 0000000000..1d72817eae --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("vimar.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("vimar") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua new file mode 100644 index 0000000000..ea7f4cd3bf --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Vimar", model = "Window_Cov_v1.0" }, + { mfr = "Vimar", model = "Window_Cov_Module_v1.0" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua index 6d2d6bb11d..dd5ea15aed 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua @@ -1,20 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local utils = require "st.utils" -local window_preset_defaults = require "st.zigbee.defaults.windowShadePreset_defaults" +local window_shade_utils = require "window_shade_utils" local zcl_clusters = require "st.zigbee.zcl.clusters" local WindowCovering = zcl_clusters.WindowCovering local windowShade = capabilities.windowShade.windowShade @@ -27,20 +17,8 @@ local windowShade = capabilities.windowShade.windowShade local VIMAR_SHADES_OPENING = "_vimarShadesOpening" local VIMAR_SHADES_CLOSING = "_vimarShadesClosing" -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Vimar", model = "Window_Cov_v1.0" }, - { mfr = "Vimar", model = "Window_Cov_Module_v1.0" } -} -- UTILS to check manufacturer details -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end -- ATTRIBUTE HANDLER FOR CurrentPositionLiftPercentage local function current_position_attr_handler(driver, device, value, zb_rx) @@ -124,7 +102,7 @@ end -- COMMAND HANDLER for PresetPosition local function window_shade_preset_handler(driver, device, command) - local level = device.preferences.presetPosition or device:get_field(window_preset_defaults.PRESET_LEVEL_KEY) or window_preset_defaults.PRESET_LEVEL + local level = window_shade_utils.get_preset_level(device, command.component) command.args.shadeLevel = level window_shade_set_level_handler(driver, device, command) end @@ -134,6 +112,20 @@ local device_init = function(self, device) -- Reset Status device:set_field(VIMAR_SHADES_CLOSING, false) device:set_field(VIMAR_SHADES_OPENING, false) + + -- for windowshadepreset update migration + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then + + -- These should only ever be nil once (and at the same time) for already-installed devices + -- It can be removed after migration is complete + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + + local preset_position = window_shade_utils.get_preset_level(device, "main") + + device:emit_event(capabilities.windowShadePreset.position(preset_position, { visibility = {displayed = false}})) + device:set_field(window_shade_utils.PRESET_LEVEL_KEY, preset_position, {persist = true}) + end end -- DRIVER HANDLER CONFIGURATION @@ -162,7 +154,7 @@ local vimar_handler = { lifecycle_handlers = { init = device_init }, - can_handle = is_zigbee_window_shade, + can_handle = require("vimar.can_handle"), } return vimar_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua new file mode 100644 index 0000000000..fd07648021 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua @@ -0,0 +1,74 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local utils = {} + +utils.PRESET_LEVEL = 50 +utils.PRESET_LEVEL_KEY = "_presetLevel" + +-- LATEST_TARGET_LEVEL stores the latest target position during stateless step operations. +-- It is cleared after TARGET_LEVEL_TIME_OUT_SECONDS timeout to allow accumulated steps +-- within a short time frame while preventing indefinite accumulation. +local LATEST_TARGET_LEVEL = "_latestTargetLevel" +-- Timeout in seconds for the stateless step accumulation window +local TARGET_LEVEL_TIME_OUT_SECONDS = 15 +-- Field name for the timer that clears LATEST_TARGET_LEVEL after timeout +local TARGET_LEVEL_TIME_OUT = "_targetLevelTimeOut" + +utils.get_preset_level = function(device, component) + local level = device:get_latest_state(component, "windowShadePreset", "position") or + device:get_field(utils.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + utils.PRESET_LEVEL + + return level +end + +utils.window_shade_preset_cmd = function(driver, device, command) + local level = device:get_latest_state(command.component, "windowShadePreset", "position") or + device:get_field(utils.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + utils.PRESET_LEVEL + device:send_to_component(command.component, zcl_clusters.WindowCovering.server.commands.GoToLiftPercentage(device, level)) +end + +utils.set_preset_position_cmd = function(driver, device, command) + device:emit_component_event({id = command.component}, capabilities.windowShadePreset.position(command.args.position)) + device:set_field(utils.PRESET_LEVEL_KEY, command.args.position, {persist = true}) +end + +-- Step shade level handler for statelessWindowShadeLevelStep capability +utils.step_shade_level_handler = function(driver, device, command) + local step = command.args.stepSize or 0 + if step == 0 then return end + + -- Step from the latest target level if it exists, or from the current shade level + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME, 0) + local new_target_level = st_utils.clamp_value(latest_target_level + step, 0, 100) + + -- Cancel any previous timer and set a 15 second timeout timer to ensure LATEST_TARGET_LEVEL is cleared + -- This is to prevent the stateless step from accumulating indefinitely, while ensuring that a single + -- "scroll" action can accumulate multiple steps within a short time frame + if device:get_field(TARGET_LEVEL_TIME_OUT) then + device.thread:cancel_timer(device:get_field(TARGET_LEVEL_TIME_OUT)) + end + local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + device:set_field(LATEST_TARGET_LEVEL, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + device:set_field(LATEST_TARGET_LEVEL, new_target_level) + + driver:inject_capability_command(device, { + capability = capabilities.windowShadeLevel.ID, + component = command.component, + command = capabilities.windowShadeLevel.commands.setShadeLevel.NAME, + named_args = { shadeLevel = new_target_level } + }) +end + +return utils diff --git a/drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua new file mode 100644 index 0000000000..5b2ec304e6 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local window_treatment_utils = {} + +window_treatment_utils.emit_event_if_latest_state_missing = function(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +return window_treatment_utils diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua new file mode 100644 index 0000000000..006fa3e1bb --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_yoolax_window_shade(opts, driver, device) + local FINGERPRINTS = require("yoolax.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("yoolax") + end + end + return false +end + +return is_yoolax_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua new file mode 100644 index 0000000000..30e0dd4c62 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local YOOLAX_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Yookee", model = "D10110" }, -- Yookee Window Treatment + { mfr = "yooksmart", model = "D10110" } -- yooksmart Window Treatment +} + +return YOOLAX_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua index d98924f54a..5a593cdf2c 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua @@ -1,41 +1,20 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local zcl_global_commands = require "st.zigbee.zcl.global_commands" local Status = require "st.zigbee.generated.types.ZclStatus" local WindowCovering = zcl_clusters.WindowCovering +local window_shade_utils = require "window_shade_utils" local device_management = require "st.zigbee.device_management" local LEVEL_UPDATE_TIMEOUT = "__level_update_timeout" local MOST_RECENT_SETLEVEL = "__most_recent_setlevel" -local YOOLAX_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Yookee", model = "D10110" }, -- Yookee Window Treatment - { mfr = "yooksmart", model = "D10110" } -- yooksmart Window Treatment -} -local function is_yoolax_window_shade(opts, driver, device) - for _, fingerprint in ipairs(YOOLAX_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function default_response_handler(driver, device, zb_message) local is_success = zb_message.body.zcl_body.status.value @@ -79,7 +58,8 @@ local function window_shade_level_cmd(driver, device, command) end local function window_shade_preset_cmd(driver, device, command) - set_shade_level(driver, device, device.preferences.presetPosition, command) + local level = window_shade_utils.get_preset_level(device, command.component) + set_shade_level(driver, device, level, command) end local function set_window_shade_level(level) @@ -158,7 +138,7 @@ local yoolax_window_shade = { } }, }, - can_handle = is_yoolax_window_shade + can_handle = require("yoolax.can_handle"), } return yoolax_window_shade diff --git a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/can_handle.lua b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/can_handle.lua new file mode 100644 index 0000000000..6ff48d2fe2 --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeon_multiwhite_bulb(opts, driver, device, ...) + local FINGERPRINTS = require("aeon-multiwhite-bulb.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeon-multiwhite-bulb") + end + end + return false +end + +return can_handle_aeon_multiwhite_bulb diff --git a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/fingerprints.lua b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/fingerprints.lua new file mode 100644 index 0000000000..6ec474efa9 --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEON_MULTIWHITE_BULB_FINGERPRINTS = { + {mfr = 0x0371, prod = 0x0103, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White US + {mfr = 0x0371, prod = 0x0003, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White EU + {mfr = 0x0300, prod = 0x0003, model = 0x0004} -- ilumin Tunable White +} + +return AEON_MULTIWHITE_BULB_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua index 9907fefbb5..4a9cc277ca 100644 --- a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.utils @@ -26,25 +16,12 @@ local SwitchColor = (require "st.zwave.CommandClass.SwitchColor")({ version = 3 --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version = 4 }) -local AEON_MULTIWHITE_BULB_FINGERPRINTS = { - {mfr = 0x0371, prod = 0x0103, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White US - {mfr = 0x0371, prod = 0x0003, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White EU - {mfr = 0x0300, prod = 0x0003, model = 0x0004} -- ilumin Tunable White -} local WARM_WHITE_CONFIG = 0x51 local COLD_WHITE_CONFIG = 0x52 local SWITCH_COLOR_QUERY_DELAY = 2 local DEFAULT_COLOR_TEMPERATURE = 2700 -local function can_handle_aeon_multiwhite_bulb(opts, driver, device, ...) - for _, fingerprint in ipairs(AEON_MULTIWHITE_BULB_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function onoff_level_report_handler(self, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value @@ -126,7 +103,7 @@ local aeon_multiwhite_bulb = { [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temperature } }, - can_handle = can_handle_aeon_multiwhite_bulb, + can_handle = require("aeon-multiwhite-bulb.can_handle"), lifecycle_handlers = { added = device_added } diff --git a/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/can_handle.lua b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/can_handle.lua new file mode 100644 index 0000000000..ebeafc2c5c --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/can_handle.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is an Aeotec LED Bulb 6. +--- +--- @param driver Driver driver instance +--- @param device Device device isntance +--- @return boolean true if the device is an Aeotec LED Bulb 6, else false +local function is_aeotec_led_bulb_6(opts, driver, device, ...) + local AEOTEC_MFR_ID = 0x0371 + local AEOTEC_LED_BULB_6_PRODUCT_TYPE_US = 0x0103 + local AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU = 0x0003 + local AEOTEC_LED_BULB_6_PRODUCT_ID = 0x0002 + if device:id_match( + AEOTEC_MFR_ID, + { AEOTEC_LED_BULB_6_PRODUCT_TYPE_US, AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU }, + AEOTEC_LED_BULB_6_PRODUCT_ID) then + return true, require("aeotec-led-bulb-6") + end + return false +end + +return is_aeotec_led_bulb_6 diff --git a/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua index 67755878db..e4886c5897 100644 --- a/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.utils @@ -26,10 +16,6 @@ local SwitchColor = (require "st.zwave.CommandClass.SwitchColor")({ version=3 }) --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=4 }) -local AEOTEC_MFR_ID = 0x0371 -local AEOTEC_LED_BULB_6_PRODUCT_TYPE_US = 0x0103 -local AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU = 0x0003 -local AEOTEC_LED_BULB_6_PRODUCT_ID = 0x0002 local WARM_WHITE_CONFIG = 0x51 local COLD_WHITE_CONFIG = 0x52 @@ -102,18 +88,6 @@ function capability_handlers.refresh(driver, device) device:send(Configuration:Get({ parameter_number=COLD_WHITE_CONFIG })) end ---- Determine whether the passed device is an Aeotec LED Bulb 6. ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device is an Aeotec LED Bulb 6, else false -local function is_aeotec_led_bulb_6(opts, driver, device, ...) - return device:id_match( - AEOTEC_MFR_ID, - { AEOTEC_LED_BULB_6_PRODUCT_TYPE_US, AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU }, - AEOTEC_LED_BULB_6_PRODUCT_ID) -end - local aeotec_led_bulb_6 = { NAME = "Aeotec LED Bulb 6", zwave_handlers = { @@ -129,7 +103,7 @@ local aeotec_led_bulb_6 = { [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh } }, - can_handle = is_aeotec_led_bulb_6, + can_handle = require("aeotec-led-bulb-6.can_handle"), } return aeotec_led_bulb_6 diff --git a/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/can_handle.lua b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/can_handle.lua new file mode 100644 index 0000000000..aeef24b0ea --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_fibaro_rgbw_controller(opts, driver, device, ...) + local FIBARO_MFR_ID = 0x010F + local FIBARO_RGBW_CONTROLLER_PROD_TYPE = 0x0900 + local FIBARO_RGBW_CONTROLLER_PROD_ID_US = 0x2000 + local FIBARO_RGBW_CONTROLLER_PROD_ID_EU = 0x1000 + + if device:id_match( + FIBARO_MFR_ID, + FIBARO_RGBW_CONTROLLER_PROD_TYPE, + {FIBARO_RGBW_CONTROLLER_PROD_ID_US, FIBARO_RGBW_CONTROLLER_PROD_ID_EU} + ) then + return true, require("fibaro-rgbw-controller") + end + return false +end + +return is_fibaro_rgbw_controller diff --git a/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua index 80dc8d4dc7..b83be97c23 100644 --- a/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.Association @@ -33,19 +23,6 @@ local CAP_CACHE_KEY = "st.capabilities." .. capabilities.colorControl.ID local LAST_COLOR_SWITCH_CMD_FIELD = "lastColorSwitchCmd" local FAKE_RGB_ENDPOINT = 10 -local FIBARO_MFR_ID = 0x010F -local FIBARO_RGBW_CONTROLLER_PROD_TYPE = 0x0900 -local FIBARO_RGBW_CONTROLLER_PROD_ID_US = 0x2000 -local FIBARO_RGBW_CONTROLLER_PROD_ID_EU = 0x1000 - -local function is_fibaro_rgbw_controller(opts, driver, device, ...) - return device:id_match( - FIBARO_MFR_ID, - FIBARO_RGBW_CONTROLLER_PROD_TYPE, - {FIBARO_RGBW_CONTROLLER_PROD_ID_US, FIBARO_RGBW_CONTROLLER_PROD_ID_EU} - ) -end - -- This handler is copied from defaults with scraped of sets for both WHITE channels local function set_color(driver, device, command) local r, g, b = utils.hsl_to_rgb(command.args.color.hue, command.args.color.saturation, command.args.color.lightness) @@ -201,7 +178,7 @@ local fibaro_rgbw_controller = { added = device_added, init = device_init }, - can_handle = is_fibaro_rgbw_controller, + can_handle = require("fibaro-rgbw-controller.can_handle"), } return fibaro_rgbw_controller diff --git a/drivers/SmartThings/zwave-bulb/src/init.lua b/drivers/SmartThings/zwave-bulb/src/init.lua index 581f6ca466..fd6c9a9b8f 100644 --- a/drivers/SmartThings/zwave-bulb/src/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.Driver @@ -30,14 +20,11 @@ local driver_template = { capabilities.colorTemperature, capabilities.powerMeter }, - sub_drivers = { - require("aeotec-led-bulb-6"), - require("aeon-multiwhite-bulb"), - require("fibaro-rgbw-controller") - }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) +defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities, {native_capability_cmds_enabled = true}) --- @type st.zwave.Driver local bulb = ZwaveDriver("zwave_bulb", driver_template) bulb:run() diff --git a/drivers/SmartThings/zwave-bulb/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-bulb/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-bulb/src/sub_drivers.lua b/drivers/SmartThings/zwave-bulb/src/sub_drivers.lua new file mode 100644 index 0000000000..842e2056bd --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aeotec-led-bulb-6"), + lazy_load_if_possible("aeon-multiwhite-bulb"), + lazy_load_if_possible("fibaro-rgbw-controller"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua b/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua index aec2a815ed..8ef8ceea42 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -70,7 +60,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = level})) } + }, + { + min_api_version = 17 } + ) end @@ -87,6 +81,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(2700)) } + }, + { + min_api_version = 17 } ) @@ -108,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 100})) } + }, + { + min_api_version = 17 } ) @@ -129,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 17 } ) @@ -152,7 +155,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = level})) } + }, + { + min_api_version = 17 } + ) end @@ -174,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 100})) } + }, + { + min_api_version = 17 } ) @@ -195,6 +205,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 17 } ) @@ -217,6 +230,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 17 } ) @@ -239,6 +255,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 100})) } + }, + { + min_api_version = 17 } ) @@ -260,6 +279,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 17 } ) @@ -289,7 +311,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = level})) } + }, + { + min_api_version = 17 } + ) end @@ -319,6 +345,9 @@ test.register_message_test( Configuration:Get({parameter_number = 0x52}) ) } + }, + { + min_api_version = 17 } ) @@ -348,6 +377,9 @@ test.register_message_test( Configuration:Get({parameter_number = 0x51}) ) } + }, + { + min_api_version = 17 } ) @@ -374,7 +406,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature({value = temp})) } + }, + { + min_api_version = 17 } + ) end @@ -384,6 +420,7 @@ test.register_coroutine_test( function () test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "switch", component = "main", command = "on", args = { } }}) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -399,7 +436,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -407,6 +447,7 @@ test.register_coroutine_test( function () test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "switch", component = "main", command = "off", args = { } }}) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -422,7 +463,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -431,6 +475,7 @@ test.register_coroutine_test( local level = math.random(1,100) test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.capability:__queue_receive({mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { level, 10 } }}) + mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -446,7 +491,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -520,7 +568,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.COLD_WHITE }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua b/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua index 0a1fa7e955..faa5125ebe 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -114,7 +104,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -166,7 +157,10 @@ test.register_coroutine_test( Configuration:Get({ parameter_number=parameter_number }) ) ) - end + end, + { + min_api_version = 17 + } ) do @@ -192,7 +186,11 @@ do direction = "send", message = mock_aeotec_bulb:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) } + }, + { + min_api_version = 17 } + ) end diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua b/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua index 0fd3e98995..0fc5140c4e 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -108,7 +98,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -178,7 +169,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -208,7 +200,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.WARM_WHITE }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -237,7 +232,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.WARM_WHITE }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -267,6 +265,7 @@ test.register_message_test( } }, { + min_api_version = 17 } ) @@ -298,6 +297,7 @@ test.register_coroutine_test( ) end, { + min_api_version = 17 } ) @@ -319,6 +319,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -344,7 +347,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 17 } + ) end @@ -389,7 +396,11 @@ do SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -465,7 +476,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("rgb", capabilities.colorControl.saturation(sat)) } + }, + { + min_api_version = 17 } + ) end @@ -492,7 +507,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("white", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } + ) end @@ -519,7 +538,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("white", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } + ) end diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua b/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua index 90dff12406..a658806774 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -109,7 +99,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -121,6 +112,8 @@ test.register_coroutine_test( mock_zwave_bulb.id, { capability = "switch", command = "off", args = {} } }) + mock_zwave_bulb:expect_native_cmd_handler_registration("switch", "off") + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_zwave_bulb, @@ -138,7 +131,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -165,6 +161,7 @@ test.register_message_test( } }, { + min_api_version = 17 } ) @@ -177,6 +174,8 @@ test.register_coroutine_test( mock_zwave_bulb.id, { capability = "switchLevel", command = "setLevel", args = { level } } }) + mock_zwave_bulb:expect_native_cmd_handler_registration("switchLevel", "setLevel") + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_zwave_bulb, @@ -196,6 +195,7 @@ test.register_coroutine_test( ) end, { + min_api_version = 17 } ) @@ -207,6 +207,8 @@ test.register_coroutine_test( mock_zwave_bulb.id, { capability = "switch", command = "on", args = {} } }) + mock_zwave_bulb:expect_native_cmd_handler_registration("switch", "on") + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_zwave_bulb, @@ -226,6 +228,7 @@ test.register_coroutine_test( ) end, { + min_api_version = 17 } ) @@ -258,7 +261,11 @@ do direction = "send", message = mock_zwave_bulb:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 17 } + ) end @@ -306,7 +313,11 @@ do SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -373,7 +384,11 @@ do direction = "send", message = mock_zwave_bulb:generate_test_message("main", capabilities.colorControl.saturation(sat)) } + }, + { + min_api_version = 17 } + ) end @@ -414,7 +429,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.WARM_WHITE }) ) ) - end + end, + { + min_api_version = 17 + } ) do @@ -459,6 +477,9 @@ do direction = "send", message = mock_zwave_bulb:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) } + }, + { + min_api_version = 17 } ) end diff --git a/drivers/SmartThings/zwave-button/fingerprints.yml b/drivers/SmartThings/zwave-button/fingerprints.yml index 89d5c9bca7..6a2995f5cc 100644 --- a/drivers/SmartThings/zwave-button/fingerprints.yml +++ b/drivers/SmartThings/zwave-button/fingerprints.yml @@ -67,3 +67,15 @@ zwaveManufacturer: manufacturerId: 0x0086 productId: 0x0026 deviceProfileName: button-generic + - id: Wave_i4 + deviceLabel: Wave i4 + manufacturerId: 0x0460 + productType: 0x0009 + productId: 0x0081 + deviceProfileName: multi-button-4-no-battery + - id: Wave_i4DC + deviceLabel: Wave i4 DC + manufacturerId: 0x0460 + productType: 0x0009 + productId: 0x0082 + deviceProfileName: multi-button-4-no-battery \ No newline at end of file diff --git a/drivers/SmartThings/zwave-button/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-button/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-button/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-button/src/configurations.lua b/drivers/SmartThings/zwave-button/src/configurations.lua index c9936f48cf..2c93b5075c 100644 --- a/drivers/SmartThings/zwave-button/src/configurations.lua +++ b/drivers/SmartThings/zwave-button/src/configurations.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { AEOTEC_NANOMOTE_ONE = { @@ -133,6 +123,28 @@ local devices = { number_of_buttons = 1, supported_button_values = {"pushed", "held"} } + }, + SHELLY_WAVE_i4 = { + MATCHING_MATRIX = { + mfrs = 0x0460, + product_types = {0x0009}, + product_ids = 0x0081 + }, + CONFIGS = { + number_of_buttons = 4, + supported_button_values = {"pushed", "held", "down_hold", "double"} + } + }, + SHELLY_WAVE_i4DC = { + MATCHING_MATRIX = { + mfrs = 0x0460, + product_types = {0x0009}, + product_ids = 0x0082 + }, + CONFIGS = { + number_of_buttons = 4, + supported_button_values = {"pushed", "held", "down_hold", "double"} + } } } diff --git a/drivers/SmartThings/zwave-button/src/init.lua b/drivers/SmartThings/zwave-button/src/init.lua index b369197a5b..18469ca56b 100644 --- a/drivers/SmartThings/zwave-button/src/init.lua +++ b/drivers/SmartThings/zwave-button/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -41,10 +31,8 @@ local driver_template = { lifecycle_handlers = { added = added_handler, }, - sub_drivers = { - require("zwave-multi-button"), - require("apiv6_bugfix"), - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-button/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-button/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-button/src/sub_drivers.lua b/drivers/SmartThings/zwave-button/src/sub_drivers.lua new file mode 100644 index 0000000000..64a9813468 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-multi-button"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua index 3788947ef4..88bf890686 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -93,6 +86,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -120,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -146,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -172,6 +174,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -198,6 +203,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -224,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -250,6 +261,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -307,7 +321,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 140, size = 4, configuration_value = 26017792}) --payload="��" )) mock_aeotec_minimote:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -345,7 +362,10 @@ test.register_coroutine_test( ) end end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua index 5aad3b2547..7df9c6cada 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -66,7 +56,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua index 9cd4532bb1..664d048010 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -57,6 +47,9 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -73,6 +66,9 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.held({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -91,6 +87,9 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -109,7 +108,11 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 17 } + ) test.register_coroutine_test( @@ -136,7 +139,11 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } + ) @@ -164,6 +171,9 @@ test.register_coroutine_test( Battery:Get({}) ) }, + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua index a71e136998..5418917ee5 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -65,7 +55,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua index 27c9b851f9..b88a85a800 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -116,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -142,6 +135,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -165,7 +161,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_everspring:generate_test_message( "main", capabilities.button.button.double({state_change = true}))) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -191,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -217,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -240,7 +245,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_everspring:generate_test_message( "main", capabilities.button.button.double({state_change = true}))) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -266,6 +274,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -292,6 +303,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -318,6 +332,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -340,7 +357,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_aeotec_wallmote_quad:generate_test_message( "main", capabilities.button.button.held({state_change = true}))) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -356,6 +376,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -383,6 +406,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -409,6 +435,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -435,6 +464,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -461,6 +493,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -483,7 +518,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 1, node_ids = {}}) )) mock_aeotec_keyfob_button:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -568,7 +606,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) --configuration for fibaro keyfob @@ -606,7 +647,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 26, size = 1, configuration_value = 15}) )) mock_fibaro_keyfob_button:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -719,7 +763,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -804,7 +851,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -861,7 +911,39 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Central scene notification with scene_number beyond profile buttons falls back to main component", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_aeotec_wallmote_quad.id, + zw_test_utils.zwave_test_build_receive_command( + CentralScene:Notification({ key_attributes = CentralScene.key_attributes.KEY_PRESSED_1_TIME, scene_number = 5 }) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + } + }, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/can_handle.lua new file mode 100644 index 0000000000..5a2fec217c --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_keyfob(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.aeotec-keyfob.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.aeotec-keyfob") + end + end + return false +end + +return can_handle_aeotec_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/fingerprints.lua new file mode 100644 index 0000000000..dd6a9c8219 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US + {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU + {mfr = 0x0086, prod = 0x0001, model = 0x0026} -- Aeotec Panic Button +} + +return ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua index c8b655bff2..13096c0579 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua @@ -1,37 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) --- @type st.zwave.CommandClass.Association local Association = (require "st.zwave.CommandClass.Association")({ version=1 }) -local ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US - {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU - {mfr = 0x0086, prod = 0x0001, model = 0x0026} -- Aeotec Panic Button -} - -local function can_handle_aeotec_keyfob(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local do_configure = function(self, device) device:refresh() device:send(Configuration:Set({ configuration_value = 1, parameter_number = 250, size = 1 })) @@ -43,7 +17,8 @@ local aeotec_keyfob = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_aeotec_keyfob, + can_handle = require("zwave-multi-button.aeotec-keyfob.can_handle"), + shared_device_thread_enabled = true, } return aeotec_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/can_handle.lua new file mode 100644 index 0000000000..fb39fa8f70 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_minimote(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.aeotec-minimote.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.aeotec-minimote") + end + end + return false +end + +return can_handle_aeotec_minimote diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/fingerprints.lua new file mode 100644 index 0000000000..b63e217394 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0001, model = 0x0003} -- Aeotec Mimimote +} + +return ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua index 814bcb775b..3b8a04d90c 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,18 +10,7 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) -local ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0001, model = 0x0003} -- Aeotec Mimimote -} -local function can_handle_aeotec_minimote(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function basic_set_handler(self, device, cmd) local button = cmd.args.value // 40 + 1 @@ -59,7 +38,8 @@ local aeotec_minimote = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_aeotec_minimote, + can_handle = require("zwave-multi-button.aeotec-minimote.can_handle"), + shared_device_thread_enabled = true, } return aeotec_minimote diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/can_handle.lua new file mode 100644 index 0000000000..a9d7d24be2 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zwave_multi_button(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button") + end + end + return false +end + +return can_handle_zwave_multi_button diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/can_handle.lua new file mode 100644 index 0000000000..055e4f1eff --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_keyfob(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.fibaro-keyfob.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.fibaro-keyfob") + end + end + return false +end + +return can_handle_fibaro_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/fingerprints.lua new file mode 100644 index 0000000000..269d136689 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_FIBARO_KEYFOB_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU + {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US + {mfr = 0x010F, prod = 0x1001, model = 0x3000} -- Fibaro KeyFob AU +} + +return ZWAVE_FIBARO_KEYFOB_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua index 653cd21ddd..178d440783 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua @@ -1,34 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) -local ZWAVE_FIBARO_KEYFOB_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU - {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US - {mfr = 0x010F, prod = 0x1001, model = 0x3000} -- Fibaro KeyFob AU -} -local function can_handle_fibaro_keyfob(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_FIBARO_KEYFOB_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function do_configure(self, device) device:refresh() @@ -46,7 +23,8 @@ local fibaro_keyfob = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_fibaro_keyfob, + can_handle = require("zwave-multi-button.fibaro-keyfob.can_handle"), + shared_device_thread_enabled = true, } return fibaro_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fingerprints.lua new file mode 100644 index 0000000000..4e539c90ab --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fingerprints.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_MULTI_BUTTON_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU + {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US + {mfr = 0x010F, prod = 0x1001, model = 0x3000}, -- Fibaro KeyFob AU + {mfr = 0x0371, prod = 0x0002, model = 0x0003}, -- Aeotec NanoMote Quad EU + {mfr = 0x0371, prod = 0x0102, model = 0x0003}, -- Aeotec NanoMote Quad US + {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU + {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US + {mfr = 0x0086, prod = 0x0002, model = 0x0082}, -- Aeotec Wallmote Quad EU + {mfr = 0x0086, prod = 0x0102, model = 0x0082}, -- Aeotec Wallmote Quad US + {mfr = 0x0086, prod = 0x0002, model = 0x0081}, -- Aeotec Wallmote EU + {mfr = 0x0086, prod = 0x0102, model = 0x0081}, -- Aeotec Wallmote US + {mfr = 0x0060, prod = 0x000A, model = 0x0003}, -- Everspring Remote Control + {mfr = 0x0086, prod = 0x0001, model = 0x0003}, -- Aeotec Mimimote, + {mfr = 0x0371, prod = 0x0102, model = 0x0016}, -- Aeotec illumino Wallmote 7, + {mfr = 0x0460, prod = 0x0009, model = 0x0081}, -- Shelly Wave i4, + {mfr = 0x0460, prod = 0x0009, model = 0x0082} -- Shelly Wave i4DC, +} + +return ZWAVE_MULTI_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua index a193358898..57b9d42be4 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,31 +11,7 @@ local CentralScene = (require "st.zwave.CommandClass.CentralScene")({ version=1 --- @type st.zwave.CommandClass.SceneActivation local SceneActivation = (require "st.zwave.CommandClass.SceneActivation")({ version=1 }) -local ZWAVE_MULTI_BUTTON_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU - {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US - {mfr = 0x010F, prod = 0x1001, model = 0x3000}, -- Fibaro KeyFob AU - {mfr = 0x0371, prod = 0x0002, model = 0x0003}, -- Aeotec NanoMote Quad EU - {mfr = 0x0371, prod = 0x0102, model = 0x0003}, -- Aeotec NanoMote Quad US - {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU - {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US - {mfr = 0x0086, prod = 0x0002, model = 0x0082}, -- Aeotec Wallmote Quad EU - {mfr = 0x0086, prod = 0x0102, model = 0x0082}, -- Aeotec Wallmote Quad US - {mfr = 0x0086, prod = 0x0002, model = 0x0081}, -- Aeotec Wallmote EU - {mfr = 0x0086, prod = 0x0102, model = 0x0081}, -- Aeotec Wallmote US - {mfr = 0x0060, prod = 0x000A, model = 0x0003}, -- Everspring Remote Control - {mfr = 0x0086, prod = 0x0001, model = 0x0003}, -- Aeotec Mimimote, - {mfr = 0x0371, prod = 0x0102, model = 0x0016} -- Aeotec illumino Wallmote 7, -} -local function can_handle_zwave_multi_button(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_MULTI_BUTTON_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local map_key_attribute_to_capability = { [CentralScene.key_attributes.KEY_PRESSED_1_TIME] = capabilities.button.button.pushed, @@ -113,12 +80,9 @@ local zwave_multi_button = { lifecycle_handlers = { init = device_init }, - can_handle = can_handle_zwave_multi_button, - sub_drivers = { - require("zwave-multi-button/aeotec-keyfob"), - require("zwave-multi-button/fibaro-keyfob"), - require("zwave-multi-button/aeotec-minimote") - } + can_handle = require("zwave-multi-button.can_handle"), + sub_drivers = require("zwave-multi-button.sub_drivers"), + shared_device_thread_enabled = true, } return zwave_multi_button diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/can_handle.lua new file mode 100644 index 0000000000..6f532ab2af --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_shelly_wave_i4(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.shelly_wave_i4.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.shelly_wave_i4") + end + end + return false +end + +return can_handle_shelly_wave_i4 diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/fingerprints.lua new file mode 100644 index 0000000000..459a811d9a --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SHELLY_WAVE_i4_FINGERPRINTS = { + {mfr = 0x0460, prod = 0x0009, model = 0x0081}, -- Shelly Wave i4 + {mfr = 0x0460, prod = 0x0009, model = 0x0082} -- Shelly Wave i4 DC +} + +return SHELLY_WAVE_i4_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua new file mode 100644 index 0000000000..4b78cffac1 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua @@ -0,0 +1,30 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- @type st.zwave.CommandClass.Configuration +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +-- @type st.zwave.CommandClass.Association +local Association = (require "st.zwave.CommandClass.Association")({ version=2 }) + + + +local do_configure = function(self, device) + device:refresh() + device:send(Configuration:Set({ configuration_value = 0, parameter_number = 1, size = 1 })) + device:send(Configuration:Set({ configuration_value = 0, parameter_number = 2, size = 1 })) + device:send(Configuration:Set({ configuration_value = 0, parameter_number = 3, size = 1 })) + device:send(Configuration:Set({ configuration_value = 0, parameter_number = 4, size = 1 })) + device:send(Association:Set({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local shelly_wave_i4 = { + NAME = "Shelly Wave i4", + lifecycle_handlers = { + doConfigure = do_configure + }, + can_handle = require("zwave-multi-button.shelly_wave_i4.can_handle"), + shared_device_thread_enabled = true, +} + +return shelly_wave_i4 diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/sub_drivers.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/sub_drivers.lua new file mode 100644 index 0000000000..7ec5622dea --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-multi-button/aeotec-keyfob"), + lazy_load_if_possible("zwave-multi-button/fibaro-keyfob"), + lazy_load_if_possible("zwave-multi-button/aeotec-minimote"), + lazy_load_if_possible("zwave-multi-button/shelly_wave_i4"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-electric-meter/fingerprints.yml b/drivers/SmartThings/zwave-electric-meter/fingerprints.yml index 7c32d646e3..c580510daa 100644 --- a/drivers/SmartThings/zwave-electric-meter/fingerprints.yml +++ b/drivers/SmartThings/zwave-electric-meter/fingerprints.yml @@ -35,6 +35,22 @@ zwaveManufacturer: productType: 0x0002 productId: 0x0001 deviceProfileName: base-electric-meter + - id: "0x0371/0x0003/0x0033" #HEM Gen8 1 Phase EU, AU + deviceLabel: Aeotec Home Energy Meter Gen8 Consumption + manufacturerId: 0x0371 + productId: 0x0033 + deviceProfileName: aeotec-home-energy-meter-gen8-1-phase-con + - id: "0x0371/0x0003/0x0034" # HEM Gen8 3 Phase EU, AU + deviceLabel: Aeotec Home Energy Meter Gen8 Consumption + manufacturerId: 0x0371 + productId: 0x0034 + deviceProfileName: aeotec-home-energy-meter-gen8-3-phase-con + - id: "0x0371/0x0103/0x002E" # HEM Gen8 2 Phase US + deviceLabel: Aeotec Home Energy Meter Gen8 Consumption + manufacturerId: 0x0371 + productType: 0x0103 + productId: 0x002E + deviceProfileName: aeotec-home-energy-meter-gen8-2-phase-con zwaveGeneric: - id: "GenericEnergyMeter" deviceLabel: Energy Monitor diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-con.yml new file mode 100644 index 0000000000..b4de02cb6a --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-con.yml @@ -0,0 +1,107 @@ +name: aeotec-home-energy-meter-gen8-1-phase-con +components: +- id: main + label: "Sum Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp1 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - name: thresholdCheck + title: "3. Threshold Check Enable/Disable" + description: "Enable selective reporting only when power change reaches a certain threshold or percentage set in 4 -19 below. This is used to reduce network traffic." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 1 + - name: imWThresholdTotal + title: "4. Import W threshold (total)" + description: "Threshold change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWThresholdPhaseA + title: "5. Import W threshold (Phase A)" + description: "Threshold change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdTotal + title: "8. Export W threshold (total)" + description: "Threshold change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseA + title: "9. Export W threshold (Phase A)" + description: "Threshold change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWPctThresholdTotal + title: "12. Import W threshold (total)" + description: "Percentage change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseA + title: "13. Import W threshold (Phase A)" + description: "Percentage change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdTotal + title: "16. Export W threshold (total)" + description: "Percentage change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseA + title: "17. Export W threshold (Phase A)" + description: "Percentage change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: autoRootDeviceReport + title: "32. Auto report of root device" + description: "Enable automatic report of root device." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-pro.yml new file mode 100644 index 0000000000..9510e27037 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-pro.yml @@ -0,0 +1,22 @@ +name: aeotec-home-energy-meter-gen8-1-phase-pro +components: +- id: main + label: "Sum Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp2 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-con.yml new file mode 100644 index 0000000000..64b2a510f9 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-con.yml @@ -0,0 +1,148 @@ +name: aeotec-home-energy-meter-gen8-2-phase-con +components: +- id: main + label: "Sum Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp1 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp3 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - name: thresholdCheck + title: "3. Threshold Check Enable/Disable" + description: "Enable selective reporting only when power change reaches a certain threshold or percentage set in 4 -19 below. This is used to reduce network traffic." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 1 + - name: imWThresholdTotal + title: "4. Import W threshold (total)" + description: "Threshold change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWThresholdPhaseA + title: "5. Import W threshold (Phase A)" + description: "Threshold change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWhresholdPhaseB + title: "6. Import W threshold (Phase B)" + description: "Threshold change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWhresholdTotal + title: "8. Export W threshold (total)" + description: "Threshold change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseA + title: "9. Export W threshold (Phase A)" + description: "Threshold change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseB + title: "10. Export W threshold (Phase B)" + description: "Threshold change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWPctThresholdTotal + title: "12. Import W threshold (total)" + description: "Percentage change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseA + title: "13. Import W threshold (Phase A)" + description: "Percentage change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseB + title: "14. Import W threshold (Phase B)." + description: "Percentage change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdTotal + title: "16. Export W threshold (total)" + description: "Percentage change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseA + title: "17. Export W threshold (Phase A)" + description: "Percentage change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseB + title: "18. Export W threshold (Phase B)" + description: "Percentage change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: autoRootDeviceReport + title: "32. Auto report of root device" + description: "Enable automatic report of root device." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-pro.yml new file mode 100644 index 0000000000..be4adf21c5 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-pro.yml @@ -0,0 +1,31 @@ +name: aeotec-home-energy-meter-gen8-2-phase-pro +components: +- id: main + label: "Sum Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp2 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp4 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-con.yml new file mode 100644 index 0000000000..e1d44731bf --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-con.yml @@ -0,0 +1,189 @@ +name: aeotec-home-energy-meter-gen8-3-phase-con +components: +- id: main + label: "Sum Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp1 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp3 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp5 + label: "Clamp 3" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - name: thresholdCheck + title: "3. Threshold Check Enable/Disable" + description: "Enable selective reporting only when power change reaches a certain threshold or percentage set in 4 -19 below. This is used to reduce network traffic." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 1 + - name: imWThresholdTotal + title: "4. Import W threshold (total)" + description: "Threshold change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWThresholdPhaseA + title: "5. Import W threshold (Phase A)" + description: "Threshold change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWhresholdPhaseB + title: "6. Import W threshold (Phase B)" + description: "Threshold change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imtWThresholdPhaseC + title: "7. Import W threshold (Phase C)" + description: "Threshold change in import wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWhresholdTotal + title: "8. Export W threshold (total)" + description: "Threshold change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseA + title: "9. Export W threshold (Phase A)" + description: "Threshold change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseB + title: "10. Export W threshold (Phase B)" + description: "Threshold change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseC + title: "11. Export W threshold (Phase C)" + description: "Threshold change in export wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWPctThresholdTotal + title: "12. Import W threshold (total)" + description: "Percentage change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseA + title: "13. Import W threshold (Phase A)" + description: "Percentage change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseB + title: "14. Import W threshold (Phase B)" + description: "Percentage change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseC + title: "15. Import W threshold (Phase C)" + description: "Percentage change in import wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdTotal + title: "16. Export W threshold (total)" + description: "Percentage change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseA + title: "17. Export W threshold (Phase A)" + description: "Percentage change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseB + title: "18. Export W threshold (Phase B)" + description: "Percentage change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseC + title: "19. Export W threshold (Phase C)" + description: "Percentage change in export wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: autoRootDeviceReport + title: "32. Auto report of root device" + description: "Enable automatic report of root device." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-pro.yml new file mode 100644 index 0000000000..3efa762dda --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-pro.yml @@ -0,0 +1,40 @@ +name: aeotec-home-energy-meter-gen8-3-phase-pro +components: +- id: main + label: "Sum Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp2 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp4 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp6 + label: "Clamp 3" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-con.yml new file mode 100644 index 0000000000..f0bf405fb7 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-con.yml @@ -0,0 +1,13 @@ +name: aeotec-home-energy-meter-gen8-sald-con +components: +- id: main + label: "Settled Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-pro.yml new file mode 100644 index 0000000000..da05330d46 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-pro.yml @@ -0,0 +1,13 @@ +name: aeotec-home-energy-meter-gen8-sald-pro +components: +- id: main + label: "Settled Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/can_handle.lua new file mode 100644 index 0000000000..a0bcfde376 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeon_meter(opts, driver, device, ...) + local FINGERPRINTS = require("aeon-meter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeon-meter") + end + end + return false +end + +return can_handle_aeon_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/fingerprints.lua b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/fingerprints.lua new file mode 100644 index 0000000000..5970f40d6d --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEON_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0002, model = 0x0009}, -- DSB09xxx-ZWUS + {mfr = 0x0086, prod = 0x0002, model = 0x0001}, -- DSB28-ZWEU +} + +return AEON_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua index 9f5ec4bee4..334bbb71cd 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua @@ -1,34 +1,9 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) -local AEON_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0002, model = 0x0009}, -- DSB09xxx-ZWUS - {mfr = 0x0086, prod = 0x0002, model = 0x0001}, -- DSB28-ZWEU -} - -local function can_handle_aeon_meter(opts, driver, device, ...) - for _, fingerprint in ipairs(AEON_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local do_configure = function (self, device) device:send(Configuration:Set({parameter_number = 101, size = 4, configuration_value = 4})) -- combined power in watts... device:send(Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300})) -- ...every 5 min @@ -42,7 +17,7 @@ local aeon_meter = { doConfigure = do_configure }, NAME = "aeon meter", - can_handle = can_handle_aeon_meter + can_handle = require("aeon-meter.can_handle"), } return aeon_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/can_handle.lua new file mode 100644 index 0000000000..356dbd55df --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_gen5_meter(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-gen5-meter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-gen5-meter") + end + end + return false +end + +return can_handle_aeotec_gen5_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/fingerprints.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/fingerprints.lua new file mode 100644 index 0000000000..3863c27b51 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_GEN5_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0102, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) US + {mfr = 0x0086, prod = 0x0002, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) EU +} + +return AEOTEC_GEN5_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua index 870e104891..1633427372 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua @@ -1,34 +1,9 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) -local AEOTEC_GEN5_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0102, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) US - {mfr = 0x0086, prod = 0x0002, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) EU -} - -local function can_handle_aeotec_gen5_meter(opts, driver, device, ...) - for _, fingerprint in ipairs(AEOTEC_GEN5_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local do_configure = function (self, device) device:send(Configuration:Set({parameter_number = 101, size = 4, configuration_value = 3})) -- report total power in Watts and total energy in kWh... device:send(Configuration:Set({parameter_number = 102, size = 4, configuration_value = 0})) -- disable group 2... @@ -43,7 +18,7 @@ local aeotec_gen5_meter = { doConfigure = do_configure }, NAME = "aeotec gen5 meter", - can_handle = can_handle_aeotec_gen5_meter + can_handle = require("aeotec-gen5-meter.can_handle"), } return aeotec_gen5_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/can_handle.lua new file mode 100644 index 0000000000..6dd02b40fe --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0003, model = 0x0033 }, -- HEM Gen8 1 Phase EU + { mfr = 0x0371, prod = 0x0102, model = 0x002E } -- HEM Gen8 1 Phase AU +} + +local function can_handle_aeotec_meter_gen8_1_phase(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-home-energy-meter-gen8.1-phase") + end + end + return false +end + +return can_handle_aeotec_meter_gen8_1_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/init.lua new file mode 100644 index 0000000000..db69a9d52d --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/init.lua @@ -0,0 +1,123 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_device = require "st.device" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version=4 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local utils = require "st.utils" +local power_consumption = require("aeotec-home-energy-meter-gen8.power_consumption") + +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local HEM8_DEVICES = { + { profile = 'aeotec-home-energy-meter-gen8-1-phase-con', name = 'Aeotec Home Energy Meter 8 Consumption', endpoints = { 1, 3 } }, + { profile = 'aeotec-home-energy-meter-gen8-1-phase-pro', name = 'Aeotec Home Energy Meter 8 Production', child_key = 'pro', endpoints = { 2, 4 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-con', name = 'Aeotec Home Energy Meter 8 Settled Consumption', child_key = 'sald-con', endpoints = { 5 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-pro', name = 'Aeotec Home Energy Meter 8 Settled Production', child_key = 'sald-pro', endpoints = { 6 } } +} + +local function find_hem8_child_device_key_by_endpoint(endpoint) + for _, child in ipairs(HEM8_DEVICES) do + if child.endpoints then + for _, e in ipairs(child.endpoints) do + if e == endpoint then + return child.child_key + end + end + end + end +end + +local function meter_report_handler(driver, device, cmd, zb_rx) + local endpoint = cmd.src_channel + local device_to_emit_with = device + local child_device_key = find_hem8_child_device_key_by_endpoint(endpoint); + local child_device = device:get_child_by_parent_assigned_key(child_device_key) + + if(child_device) then + device_to_emit_with = child_device + end + + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + if endpoint == 5 then + -- powerConsumptionReport + power_consumption.emit_power_consumption_report_event(device, { value = event_arguments.value }) + end + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) + end +end + +local function do_refresh(self, device) + for _, d in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(d.endpoints) do + device:send(Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, {dst_channels = {endpoint}})) + device:send(Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {endpoint}})) + end + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZWAVE and not (device.child_ids and utils.table_size(device.child_ids) ~= 0) then + for i, hem8_child in ipairs(HEM8_DEVICES) do + if(hem8_child["child_key"]) then + local name = hem8_child.name + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = hem8_child.profile, + parent_device_id = device.id, + parent_assigned_child_key = hem8_child.child_key, + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + do_refresh(driver, device) +end + +local aeotec_home_energy_meter_gen8_1_phase = { + NAME = "Aeotec Home Energy Meter Gen8", + supported_capabilities = { + capabilities.powerConsumptionReport + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler + } + }, + lifecycle_handlers = { + added = device_added + }, + can_handle = require("aeotec-home-energy-meter-gen8.1-phase.can_handle") +} + +return aeotec_home_energy_meter_gen8_1_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/can_handle.lua new file mode 100644 index 0000000000..ec694e2708 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0103, model = 0x002E } -- HEM Gen8 2 Phase US +} + +local function can_handle_aeotec_meter_gen8_2_phase(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-home-energy-meter-gen8.2-phase") + end + end + return false +end + +return can_handle_aeotec_meter_gen8_2_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/init.lua new file mode 100644 index 0000000000..8d78da66f7 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/init.lua @@ -0,0 +1,123 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_device = require "st.device" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version=4 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local utils = require "st.utils" +local power_consumption = require("aeotec-home-energy-meter-gen8.power_consumption") + +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local HEM8_DEVICES = { + { profile = 'aeotec-home-energy-meter-gen8-3-phase-con', name = 'Aeotec Home Energy Meter 8 Consumption', endpoints = { 1, 3, 5 } }, + { profile = 'aeotec-home-energy-meter-gen8-2-phase-pro', name = 'Aeotec Home Energy Meter 8 Production', child_key = 'pro', endpoints = { 2, 4, 6 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-con', name = 'Aeotec Home Energy Meter 8 Settled Consumption', child_key = 'sald-con', endpoints = { 7 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-pro', name = 'Aeotec Home Energy Meter 8 Settled Production', child_key = 'sald-pro', endpoints = { 8 } } +} + +local function find_hem8_child_device_key_by_endpoint(endpoint) + for _, child in ipairs(HEM8_DEVICES) do + if child.endpoints then + for _, e in ipairs(child.endpoints) do + if e == endpoint then + return child.child_key + end + end + end + end +end + +local function meter_report_handler(driver, device, cmd, zb_rx) + local endpoint = cmd.src_channel + local device_to_emit_with = device + local child_device_key = find_hem8_child_device_key_by_endpoint(endpoint); + local child_device = device:get_child_by_parent_assigned_key(child_device_key) + + if(child_device) then + device_to_emit_with = child_device + end + + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + if endpoint == 7 then + -- powerConsumptionReport + power_consumption.emit_power_consumption_report_event(device, { value = event_arguments.value }) + end + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) + end +end + +local function do_refresh(self, device) + for _, d in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(d.endpoints) do + device:send(Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, {dst_channels = {endpoint}})) + device:send(Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {endpoint}})) + end + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZWAVE and not (device.child_ids and utils.table_size(device.child_ids) ~= 0) then + for i, hem8_child in ipairs(HEM8_DEVICES) do + if(hem8_child["child_key"]) then + local name = hem8_child.name + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = hem8_child.profile, + parent_device_id = device.id, + parent_assigned_child_key = hem8_child.child_key, + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + do_refresh(driver, device) +end + +local aeotec_home_energy_meter_gen8_2_phase = { + NAME = "Aeotec Home Energy Meter Gen8", + supported_capabilities = { + capabilities.powerConsumptionReport + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler + } + }, + lifecycle_handlers = { + added = device_added + }, + can_handle = require("aeotec-home-energy-meter-gen8.2-phase.can_handle") +} + +return aeotec_home_energy_meter_gen8_2_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/can_handle.lua new file mode 100644 index 0000000000..9f3cc6cb03 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0003, model = 0x0034 }, -- HEM Gen8 3 Phase EU + { mfr = 0x0371, prod = 0x0102, model = 0x0034 } -- HEM Gen8 3 Phase AU +} + +local function can_handle_aeotec_meter_gen8_3_phase(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-home-energy-meter-gen8.3-phase") + end + end + return false +end + +return can_handle_aeotec_meter_gen8_3_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/init.lua new file mode 100644 index 0000000000..cb1c582fb6 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/init.lua @@ -0,0 +1,123 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_device = require "st.device" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version=4 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local utils = require "st.utils" +local power_consumption = require("aeotec-home-energy-meter-gen8.power_consumption") + +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local HEM8_DEVICES = { + { profile = 'aeotec-home-energy-meter-gen8-3-phase-con', name = 'Aeotec Home Energy Meter 8 Consumption', endpoints = { 1, 3, 5, 7 } }, + { profile = 'aeotec-home-energy-meter-gen8-3-phase-pro', name = 'Aeotec Home Energy Meter 8 Production', child_key = 'pro', endpoints = { 2, 4, 6, 8 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-con', name = 'Aeotec Home Energy Meter 8 Settled Consumption', child_key = 'sald-con', endpoints = { 9 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-pro', name = 'Aeotec Home Energy Meter 8 Settled Production', child_key = 'sald-pro', endpoints = { 10 } } +} + +local function find_hem8_child_device_key_by_endpoint(endpoint) + for _, child in ipairs(HEM8_DEVICES) do + if child.endpoints then + for _, e in ipairs(child.endpoints) do + if e == endpoint then + return child.child_key + end + end + end + end +end + +local function meter_report_handler(driver, device, cmd, zb_rx) + local endpoint = cmd.src_channel + local device_to_emit_with = device + local child_device_key = find_hem8_child_device_key_by_endpoint(endpoint); + local child_device = device:get_child_by_parent_assigned_key(child_device_key) + + if(child_device) then + device_to_emit_with = child_device + end + + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + if endpoint == 9 then + -- powerConsumptionReport + power_consumption.emit_power_consumption_report_event(device, { value = event_arguments.value }) + end + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) + end +end + +local function do_refresh(self, device) + for _, d in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(d.endpoints) do + device:send(Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, {dst_channels = {endpoint}})) + device:send(Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {endpoint}})) + end + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZWAVE and not (device.child_ids and utils.table_size(device.child_ids) ~= 0) then + for i, hem8_child in ipairs(HEM8_DEVICES) do + if(hem8_child["child_key"]) then + local name = hem8_child.name + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = hem8_child.profile, + parent_device_id = device.id, + parent_assigned_child_key = hem8_child.child_key, + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + do_refresh(driver, device) +end + +local aeotec_home_energy_meter_gen8_3_phase = { + NAME = "Aeotec Home Energy Meter Gen8", + supported_capabilities = { + capabilities.powerConsumptionReport + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler + } + }, + lifecycle_handlers = { + added = device_added + }, + can_handle = require("aeotec-home-energy-meter-gen8.3-phase.can_handle") +} + +return aeotec_home_energy_meter_gen8_3_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/can_handle.lua new file mode 100644 index 0000000000..918e703b69 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/can_handle.lua @@ -0,0 +1,22 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0003, model = 0x0033 }, -- HEM Gen8 1 Phase EU + { mfr = 0x0371, prod = 0x0003, model = 0x0034 }, -- HEM Gen8 3 Phase EU + { mfr = 0x0371, prod = 0x0103, model = 0x002E }, -- HEM Gen8 2 Phase US + { mfr = 0x0371, prod = 0x0102, model = 0x002E }, -- HEM Gen8 1 Phase AU + { mfr = 0x0371, prod = 0x0102, model = 0x0034 }, -- HEM Gen8 3 Phase AU +} + +local function can_handle_aeotec_meter_gen8(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("aeotec-home-energy-meter-gen8") + return true, subdriver + end + end + return false +end + +return can_handle_aeotec_meter_gen8 diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/init.lua new file mode 100644 index 0000000000..10d07de685 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/init.lua @@ -0,0 +1,49 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- @type st.zwave.CommandClass.Configuration +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) + +local function device_added(driver, device) + device:refresh() +end + +local function component_to_endpoint(device, component_id) + local ep_num = component_id:match("clamp(%d)") + return { ep_num and tonumber(ep_num) } +end + +local function endpoint_to_component(device, ep) + local meter_comp = string.format("clamp%d", ep) + if device.profile.components[meter_comp] ~= nil then + return meter_comp + else + return "main" + end +end + +local device_init = function(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local do_configure = function (self, device) + device:send(Configuration:Set({parameter_number = 111, configuration_value = 300, size = 4})) -- ...every 5 min + device:send(Configuration:Set({parameter_number = 112, configuration_value = 300, size = 4})) -- ...every 5 min + device:send(Configuration:Set({parameter_number = 113, configuration_value = 300, size = 4})) -- ...every 5 min +end + +local aeotec_home_energy_meter_gen8 = { + NAME = "Aeotec Home Energy Meter Gen8", + lifecycle_handlers = { + added = device_added, + init = device_init, + doConfigure = do_configure + }, + can_handle = require("aeotec-home-energy-meter-gen8.can_handle"), + sub_drivers = { + require("aeotec-home-energy-meter-gen8.sub_drivers") + } +} + +return aeotec_home_energy_meter_gen8 diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/power_consumption.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/power_consumption.lua new file mode 100644 index 0000000000..2c32c5c964 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/power_consumption.lua @@ -0,0 +1,32 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2. + +local capabilities = require "st.capabilities" + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local power_consumption = {} + +power_consumption.emit_power_consumption_report_event = function (device, value) + -- powerConsumptionReport report interval + local current_time = os.time() + local last_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_time = last_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_time then + return + end + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) + local raw_value = value.value * 1000 -- 'Wh' + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state('main', capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ + energy = raw_value, + deltaEnergy = delta_energy + })) +end + +return power_consumption \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/sub_drivers.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/sub_drivers.lua new file mode 100644 index 0000000000..98c3fb45f8 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aeotec-home-energy-meter-gen8.1-phase"), + lazy_load_if_possible("aeotec-home-energy-meter-gen8.2-phase"), + lazy_load_if_possible("aeotec-home-energy-meter-gen8.3-phase") +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-electric-meter/src/init.lua b/drivers/SmartThings/zwave-electric-meter/src/init.lua index 2ef9f20281..53c91cfa90 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/init.lua @@ -1,27 +1,37 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults local defaults = require "st.zwave.defaults" --- @type st.zwave.Driver local ZwaveDriver = require "st.zwave.driver" +--- @type st.zwave.CommandClass.Configuration +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=1}) + +local preferencesMap = require "preferences" local device_added = function (self, device) device:refresh() end +--- Handle preference changes +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param event table +--- @param args +local function info_changed(driver, device, event, args) + local preferences = preferencesMap.get_device_parameters(device) + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences and preferences[id] then + local new_parameter_value = preferencesMap.to_numeric_value(device.preferences[id]) + device:send(Configuration:Set({ parameter_number = preferences[id].parameter_number, size = preferences[id].size, configuration_value = new_parameter_value })) + end + end +end + local driver_template = { supported_capabilities = { capabilities.powerMeter, @@ -29,13 +39,11 @@ local driver_template = { capabilities.refresh }, lifecycle_handlers = { + infoChanged = info_changed, added = device_added }, - sub_drivers = { - require("qubino-meter"), - require("aeotec-gen5-meter"), - require("aeon-meter") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-electric-meter/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-electric-meter/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-electric-meter/src/preferences.lua b/drivers/SmartThings/zwave-electric-meter/src/preferences.lua new file mode 100644 index 0000000000..b1f14c25bd --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/preferences.lua @@ -0,0 +1,95 @@ +local devices = { + AEOTEC_HOME_ENERGY_METER_GEN8_1_PHASE = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = {0x0003, 0x0102 }, + product_ids = 0x0033 + }, + PARAMETERS = { + thresholdCheck = {parameter_number = 3, size = 1}, + imWThresholdTotal = {parameter_number = 4, size = 2}, + imWThresholdPhaseA = {parameter_number = 5, size = 2}, + exWThresholdTotal = {parameter_number = 8, size = 2}, + exWThresholdPhaseA = {parameter_number = 9, size = 2}, + imtWPctThresholdTotal = {parameter_number = 12, size = 1}, + imWPctThresholdPhaseA = {parameter_number = 13, size = 1}, + exWPctThresholdTotal = {parameter_number = 16, size = 1}, + exWPctThresholdPhaseA = {parameter_number = 17, size = 1}, + autoRootDeviceReport = {parameter_number = 32, size = 1}, + } + }, + AEOTEC_HOME_ENERGY_METER_GEN8_2_PHASE = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = 0x0103, + product_ids = 0x002E + }, + PARAMETERS = { + thresholdCheck = {parameter_number = 3, size = 1}, + imWThresholdTotal = {parameter_number = 4, size = 2}, + imWThresholdPhaseA = {parameter_number = 5, size = 2}, + imWThresholdPhaseB = {parameter_number = 6, size = 2}, + exWThresholdTotal = {parameter_number = 8, size = 2}, + exWThresholdPhaseA = {parameter_number = 9, size = 2}, + exWThresholdPhaseB = {parameter_number = 10, size = 2}, + imtWPctThresholdTotal = {parameter_number = 12, size = 1}, + imWPctThresholdPhaseA = {parameter_number = 13, size = 1}, + imWPctThresholdPhaseB = {parameter_number = 14, size = 1}, + exWPctThresholdTotal = {parameter_number = 16, size = 1}, + exWPctThresholdPhaseA = {parameter_number = 17, size = 1}, + exWPctThresholdPhaseB = {parameter_number = 18, size = 1}, + autoRootDeviceReport = {parameter_number = 32, size = 1}, + } + }, + AEOTEC_HOME_ENERGY_METER_GEN8_3_PHASE = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = {0x0003, 0x0102}, + product_ids = 0x0034 + }, + PARAMETERS = { + thresholdCheck = {parameter_number = 3, size = 1}, + imWThresholdTotal = {parameter_number = 4, size = 2}, + imWThresholdPhaseA = {parameter_number = 5, size = 2}, + imWThresholdPhaseB = {parameter_number = 6, size = 2}, + imWThresholdPhaseC = {parameter_number = 7, size = 2}, + exWThresholdTotal = {parameter_number = 8, size = 2}, + exWThresholdPhaseA = {parameter_number = 9, size = 2}, + exWThresholdPhaseB = {parameter_number = 10, size = 2}, + exWThresholdPhaseC = {parameter_number = 11, size = 2}, + imtWPctThresholdTotal = {parameter_number = 12, size = 1}, + imWPctThresholdPhaseA = {parameter_number = 13, size = 1}, + imWPctThresholdPhaseB = {parameter_number = 14, size = 1}, + imWPctThresholdPhaseC = {parameter_number = 15, size = 1}, + exWPctThresholdTotal = {parameter_number = 16, size = 1}, + exWPctThresholdPhaseA = {parameter_number = 17, size = 1}, + exWPctThresholdPhaseB = {parameter_number = 18, size = 1}, + exWPctThresholdPhaseC = {parameter_number = 19, size = 1}, + autoRootDeviceReport = {parameter_number = 32, size = 1}, + } + } +} + +local preferences = {} + +preferences.get_device_parameters = function(zw_device) + for _, device in pairs(devices) do + if zw_device:id_match( + device.MATCHING_MATRIX.mfrs, + device.MATCHING_MATRIX.product_types, + device.MATCHING_MATRIX.product_ids) then + return device.PARAMETERS + end + end + return nil +end + +preferences.to_numeric_value = function(new_value) + local numeric = tonumber(new_value) + if numeric == nil then -- in case the value is boolean + numeric = new_value and 1 or 0 + end + return numeric +end + +return preferences diff --git a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/can_handle.lua new file mode 100644 index 0000000000..dd9ae17949 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_qubino_meter(opts, driver, device, ...) + local FINGERPRINTS = require("qubino-meter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("qubino-meter") + end + end + return false +end + +return can_handle_qubino_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/fingerprints.lua b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/fingerprints.lua new file mode 100644 index 0000000000..5a5fd44be5 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local QUBINO_FINGERPRINTS = { + {mfr = 0x0159, prod = 0x0007, model = 0x0054}, -- Qubino 3 Phase Meter + {mfr = 0x0159, prod = 0x0007, model = 0x0052} -- Qubino Smart Meter +} + +return QUBINO_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua index a8e2b23805..4c2a40a56e 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.Configuration @@ -20,22 +10,10 @@ local Meter = (require "st.zwave.CommandClass.Meter")({ version=3 }) --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" -local QUBINO_FINGERPRINTS = { - {mfr = 0x0159, prod = 0x0007, model = 0x0054}, -- Qubino 3 Phase Meter - {mfr = 0x0159, prod = 0x0007, model = 0x0052} -- Qubino Smart Meter -} local POWER_UNIT_WATT = "W" local ENERGY_UNIT_KWH = "kWh" -local function can_handle_qubino_meter(opts, driver, device, ...) - for _, fingerprint in ipairs(QUBINO_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function meter_report_handler(self, device, cmd) @@ -101,7 +79,7 @@ local qubino_meter = { init = device_init }, NAME = "qubino meter", - can_handle = can_handle_qubino_meter + can_handle = require("qubino-meter.can_handle"), } return qubino_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/sub_drivers.lua b/drivers/SmartThings/zwave-electric-meter/src/sub_drivers.lua new file mode 100644 index 0000000000..3c0e482bda --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("qubino-meter"), + lazy_load_if_possible("aeotec-gen5-meter"), + lazy_load_if_possible("aeon-meter"), + lazy_load_if_possible("aeotec-home-energy-meter-gen8"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua index d82e1549fa..391b3b2b0d 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -110,7 +106,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -140,7 +137,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 103, size = 4, configuration_value = 0}) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua index 09b897ba9d..016aff4061 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -110,7 +106,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -144,7 +141,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 13, size = 1, configuration_value = 0}) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua new file mode 100644 index 0000000000..f581f457df --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua @@ -0,0 +1,560 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Meter = (require "st.zwave.CommandClass.Meter")({version=4}) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +local AEOTEC_MFR_ID = 0x0371 +local AEOTEC_METER_PROD_TYPE = 0x0003 +local AEOTEC_METER_PROD_ID = 0x0033 + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_meter_endpoints = { + { + command_classes = { + {value = zw.METER} + } + } +} + +local HEM8_DEVICES = { + { + profile = 'aeotec-home-energy-meter-gen8-1-phase-con', + name = 'Aeotec Home Energy Meter 8 Consumption', + endpoints = { 1, 3 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-1-phase-pro', + name = 'Aeotec Home Energy Meter 8 Production', + child_key = 'pro', + endpoints = { 2, 4 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-con', + name = 'Aeotec Home Energy Meter 8 Settled Consumption', + child_key = 'sald-con', + endpoints = { 5 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-pro', + name = 'Aeotec Home Energy Meter 8 Settled Production', + child_key = 'sald-pro', + endpoints = { 6 } + } +} + +local mock_parent = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[1].profile .. '.yml'), + zwave_endpoints = aeotec_meter_endpoints, + zwave_manufacturer_id = AEOTEC_MFR_ID, + zwave_product_type = AEOTEC_METER_PROD_TYPE, + zwave_product_id = AEOTEC_METER_PROD_ID +}) + +local mock_child_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[2].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[2].child_key +}) + +local mock_child_sald_con = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[3].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[3].child_key +}) + +local mock_child_sald_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[4].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[4].child_key +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child_prod) + test.mock_device.add_test_device(mock_child_sald_con) + test.mock_device.add_test_device(mock_child_sald_prod) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Added lifecycle event should create children for parent device", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "added" }) + + for _, child in ipairs(HEM8_DEVICES) do + if(child["child_key"]) then + mock_parent:expect_device_create( + { + type = "EDGE_CHILD", + label = child.name, + profile = child.profile, + parent_device_id = mock_parent.id, + parent_assigned_child_key = child.child_key + } + ) + end + end + -- Refresh + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + end +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "doConfigure" }) + + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 112, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 113, size = 4, configuration_value = 300}) + )) + mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Power meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 3 and endpoint ~= 4 and endpoint ~= 5 and endpoint ~= 6 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 27 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Energy meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 3 and endpoint ~= 4 and endpoint ~= 5 and endpoint ~= 6 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + -- set time to trigger power consumption report + local current_time = os.time() - 60 * 20 + --mock_child_sald_con:set_field(LAST_REPORT_TIME, current_time) + mock_parent:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zwave:__queue_receive( + { + --mock_child_sald_con.id, + mock_parent.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, + { + encap = zw.ENCAP.AUTO, + src_channel = 5, + dst_channels = {0} + } + )) + } + ) + + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + -- mock_parent + mock_parent:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (parameter 3) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + thresholdCheck = 0 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 3, + configuration_value = 0, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdTotal (parameter 4) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 4, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseA (parameter 5) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 5, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdTotal (parameter 8) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 8, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseA (parameter 9) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 9, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imtWPctThresholdTotal (parameter 12) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imtWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 12, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseA (parameter 13) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 13, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdTotal (parameter 16) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 16, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseA (parameter 17) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 17, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: autoRootDeviceReport (parameter 32) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + autoRootDeviceReport = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 32, + configuration_value = 1, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Refresh sends commands to all components including base device", + function() + -- refresh commands for zwave devices do not have guaranteed ordering + test.socket.zwave:__set_channel_ordering("relaxed") + + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + + test.socket.capability:__queue_receive({ + mock_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua new file mode 100644 index 0000000000..43ea5cdbe3 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua @@ -0,0 +1,654 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Meter = (require "st.zwave.CommandClass.Meter")({version=4}) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +local AEOTEC_MFR_ID = 0x0371 +local AEOTEC_METER_PROD_TYPE = 0x0103 +local AEOTEC_METER_PROD_ID = 0x002E + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_meter_endpoints = { + { + command_classes = { + {value = zw.METER} + } + } +} + +local HEM8_DEVICES = { + { + profile = 'aeotec-home-energy-meter-gen8-2-phase-con', + name = 'Aeotec Home Energy Meter 8 Consumption', + endpoints = { 1, 3, 5 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-2-phase-pro', + name = 'Aeotec Home Energy Meter 8 Production', + child_key = 'pro', + endpoints = { 2, 4, 6 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-con', + name = 'Aeotec Home Energy Meter 8 Settled Consumption', + child_key = 'sald-con', + endpoints = { 7 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-pro', + name = 'Aeotec Home Energy Meter 8 Settled Production', + child_key = 'sald-pro', + endpoints = { 8 } + } +} + +local mock_parent = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[1].profile .. '.yml'), + zwave_endpoints = aeotec_meter_endpoints, + zwave_manufacturer_id = AEOTEC_MFR_ID, + zwave_product_type = AEOTEC_METER_PROD_TYPE, + zwave_product_id = AEOTEC_METER_PROD_ID +}) + +local mock_child_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[2].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[2].child_key +}) + +local mock_child_sald_con = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[3].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[3].child_key +}) + +local mock_child_sald_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[4].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[4].child_key +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child_prod) + test.mock_device.add_test_device(mock_child_sald_con) + test.mock_device.add_test_device(mock_child_sald_prod) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Added lifecycle event should create children for parent device", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "added" }) + + for _, child in ipairs(HEM8_DEVICES) do + if(child["child_key"]) then + mock_parent:expect_device_create( + { + type = "EDGE_CHILD", + label = child.name, + profile = child.profile, + parent_device_id = mock_parent.id, + parent_assigned_child_key = child.child_key + } + ) + end + end + -- Refresh + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + end +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "doConfigure" }) + + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 112, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 113, size = 4, configuration_value = 300}) + )) + mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Power meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 5 and endpoint ~= 6 and endpoint ~= 7 and endpoint ~= 8 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 27 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Energy meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 5 and endpoint ~= 6 and endpoint ~= 7 and endpoint ~= 8 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + -- set time to trigger power consumption report + local current_time = os.time() - 60 * 20 + --mock_child_sald_con:set_field(LAST_REPORT_TIME, current_time) + mock_parent:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zwave:__queue_receive( + { + mock_parent.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, + { + encap = zw.ENCAP.AUTO, + src_channel = 7, + dst_channels = {0} + } + )) + } + ) + + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + mock_parent:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (parameter 3) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + thresholdCheck = 0 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 3, + configuration_value = 0, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdTotal (parameter 4) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 4, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseA (parameter 5) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 5, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseB (parameter 6) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 6, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdTotal (parameter 8) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 8, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseA (parameter 9) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 9, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseB (parameter 10) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 10, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imtWPctThresholdTotal (parameter 12) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imtWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 12, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseA (parameter 13) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 13, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseB (parameter 14) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 14, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdTotal (parameter 16) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 16, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseA (parameter 17) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 17, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseB (parameter 18) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 18, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: autoRootDeviceReport (parameter 32) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + autoRootDeviceReport = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 32, + configuration_value = 1, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Refresh sends commands to all components including base device", + function() + -- refresh commands for zwave devices do not have guaranteed ordering + test.socket.zwave:__set_channel_ordering("relaxed") + + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + + test.socket.capability:__queue_receive({ + mock_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua new file mode 100644 index 0000000000..465e3add9f --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua @@ -0,0 +1,749 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Meter = (require "st.zwave.CommandClass.Meter")({version=4}) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) +local t_utils = require "integration_test.utils" + +local AEOTEC_MFR_ID = 0x0371 +local AEOTEC_METER_PROD_TYPE = 0x0003 +local AEOTEC_METER_PROD_ID = 0x0034 + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_meter_endpoints = { + { + command_classes = { + {value = zw.METER} + } + } +} + +local HEM8_DEVICES = { + { + profile = 'aeotec-home-energy-meter-gen8-3-phase-con', + name = 'Aeotec Home Energy Meter 8 Consumption', + endpoints = { 1, 3, 5, 7 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-3-phase-pro', + name = 'Aeotec Home Energy Meter 8 Production', + child_key = 'pro', + endpoints = { 2, 4, 6, 8 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-con', + name = 'Aeotec Home Energy Meter 8 Settled Consumption', + child_key = 'sald-con', + endpoints = { 9 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-pro', + name = 'Aeotec Home Energy Meter 8 Settled Production', + child_key = 'sald-pro', + endpoints = { 10 } + } +} + +local mock_parent = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[1].profile .. '.yml'), + zwave_endpoints = aeotec_meter_endpoints, + zwave_manufacturer_id = AEOTEC_MFR_ID, + zwave_product_type = AEOTEC_METER_PROD_TYPE, + zwave_product_id = AEOTEC_METER_PROD_ID +}) + +local mock_child_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[2].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[2].child_key +}) + +local mock_child_sald_con = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[3].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[3].child_key +}) + +local mock_child_sald_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[4].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[4].child_key +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child_prod) + test.mock_device.add_test_device(mock_child_sald_con) + test.mock_device.add_test_device(mock_child_sald_prod) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Added lifecycle event should create children for parent device", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "added" }) + + for _, child in ipairs(HEM8_DEVICES) do + if(child["child_key"]) then + mock_parent:expect_device_create( + { + type = "EDGE_CHILD", + label = child.name, + profile = child.profile, + parent_device_id = mock_parent.id, + parent_assigned_child_key = child.child_key + } + ) + end + end + -- Refresh + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + end +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "doConfigure" }) + + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 112, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 113, size = 4, configuration_value = 300}) + )) + mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Power meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 7 and endpoint ~= 8 and endpoint ~= 9 and endpoint ~= 10 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 27 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Energy meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 7 and endpoint ~= 8 and endpoint ~= 9 and endpoint ~= 10 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + -- set time to trigger power consumption report + local current_time = os.time() - 60 * 20 + -- mock_child_sald_con:set_field(LAST_REPORT_TIME, current_time) + mock_parent:set_field(LAST_REPORT_TIME, current_time) + test.socket.zwave:__queue_receive( + { + mock_parent.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, + { + encap = zw.ENCAP.AUTO, + src_channel = 9, + dst_channels = {0} + } + )) + } + ) + + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + mock_parent:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (parameter 3) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + thresholdCheck = 0 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 3, + configuration_value = 0, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdTotal (parameter 4) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 4, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseA (parameter 5) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 5, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseB (parameter 6) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 6, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseC (parameter 7) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseC = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 7, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdTotal (parameter 8) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 8, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseA (parameter 9) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 9, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseB (parameter 10) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 10, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseC (parameter 11) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseC = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 11, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imtWPctThresholdTotal (parameter 12) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imtWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 12, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseA (parameter 13) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 13, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseB (parameter 14) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 14, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseC (parameter 15) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseC = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 15, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdTotal (parameter 16) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 16, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseA (parameter 17) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 17, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseB (parameter 18) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 18, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (exWPctThresholdPhaseC 19) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseC = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 19, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: autoRootDeviceReport (parameter 32) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + autoRootDeviceReport = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 32, + configuration_value = 1, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Refresh sends commands to all components including base device", + function() + -- refresh commands for zwave devices do not have guaranteed ordering + test.socket.zwave:__set_channel_ordering("relaxed") + + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + + test.socket.capability:__queue_receive({ + mock_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua index dad4b1030e..ce9b61f949 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -104,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("endpointMeter3", capabilities.powerMeter.power({ value = 5, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -123,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) }, + }, + { + min_api_version = 17 } ) @@ -182,7 +178,10 @@ test.register_coroutine_test( { capability = "refresh", component = "main", command = "refresh", args = { } } }) - end + end, + { + min_api_version = 17 + } ) @@ -221,7 +220,10 @@ test.register_coroutine_test( ) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua index 11a1317a74..d96d90aae1 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -110,7 +106,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -128,7 +125,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 42, size = 2, configuration_value = 1800}) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua index cf640c3655..4df103e086 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -55,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -74,6 +67,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -103,7 +99,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-fan/src/init.lua b/drivers/SmartThings/zwave-fan/src/init.lua index acdb34ae76..d3b51d8344 100644 --- a/drivers/SmartThings/zwave-fan/src/init.lua +++ b/drivers/SmartThings/zwave-fan/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -27,10 +17,8 @@ local driver_template = { capabilities.switch, capabilities.fanSpeed, }, - sub_drivers = { - require("zwave-fan-3-speed"), - require("zwave-fan-4-speed") - }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-fan/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-fan/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-fan/src/sub_drivers.lua b/drivers/SmartThings/zwave-fan/src/sub_drivers.lua new file mode 100644 index 0000000000..373e4daf52 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-fan-3-speed"), + lazy_load_if_possible("zwave-fan-4-speed"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua index 249c0ebc14..534c48698e 100644 --- a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua +++ b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -72,6 +62,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -101,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -131,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -159,6 +158,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -190,7 +192,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua index 18aab08c73..80ccef948b 100644 --- a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua +++ b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -71,6 +61,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -99,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -129,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -157,6 +156,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -188,7 +190,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/can_handle.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/can_handle.lua new file mode 100644 index 0000000000..66a04b41a9 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_fan_3_speed(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-fan-3-speed.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-fan-3-speed") + end + end + return false +end + +return is_fan_3_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/fingerprints.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/fingerprints.lua new file mode 100644 index 0000000000..7241769dcd --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/fingerprints.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FAN_3_SPEED_FINGERPRINTS = { + {mfr = 0x001D, prod = 0x1001, model = 0x0334}, -- Leviton 3-Speed Fan Controller + {mfr = 0x0063, prod = 0x4944, model = 0x3034}, -- GE In-Wall Smart Fan Control + {mfr = 0x0063, prod = 0x4944, model = 0x3131}, -- GE In-Wall Smart Fan Control + {mfr = 0x0039, prod = 0x4944, model = 0x3131}, -- Honeywell Z-Wave Plus In-Wall Fan Speed Control + {mfr = 0x0063, prod = 0x4944, model = 0x3337}, -- GE In-Wall Smart Fan Control +} + +return FAN_3_SPEED_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua index 282f2e35db..ad629ca54c 100644 --- a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local log = require "log" local capabilities = require "st.capabilities" @@ -22,13 +12,6 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=4 }) local fan_speed_helper = (require "zwave_fan_helpers") -local FAN_3_SPEED_FINGERPRINTS = { - {mfr = 0x001D, prod = 0x1001, model = 0x0334}, -- Leviton 3-Speed Fan Controller - {mfr = 0x0063, prod = 0x4944, model = 0x3034}, -- GE In-Wall Smart Fan Control - {mfr = 0x0063, prod = 0x4944, model = 0x3131}, -- GE In-Wall Smart Fan Control - {mfr = 0x0039, prod = 0x4944, model = 0x3131}, -- Honeywell Z-Wave Plus In-Wall Fan Speed Control - {mfr = 0x0063, prod = 0x4944, model = 0x3337}, -- GE In-Wall Smart Fan Control -} local function map_fan_3_speed_to_switch_level (speed) if speed == fan_speed_helper.fan_speed.OFF then @@ -63,14 +46,6 @@ end --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is an 3-speed fan, else false -local function is_fan_3_speed(opts, driver, device, ...) - for _, fingerprint in ipairs(FAN_3_SPEED_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local capability_handlers = {} @@ -110,7 +85,8 @@ local zwave_fan_3_speed = { } }, NAME = "Z-Wave fan 3 speed", - can_handle = is_fan_3_speed, + can_handle = require("zwave-fan-3-speed.can_handle"), + shared_device_thread_enabled = true, } return zwave_fan_3_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/can_handle.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/can_handle.lua new file mode 100644 index 0000000000..b67f3c2ec7 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fan_4_speed(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-fan-4-speed.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-fan-4-speed") + end + end + return false +end + +return can_handle_fan_4_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/fingerprints.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/fingerprints.lua new file mode 100644 index 0000000000..2e2f2b13fb --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FAN_4_SPEED_FINGERPRINTS = { + {mfr = 0x001D, prod = 0x0038, model = 0x0002}, -- Leviton 4-Speed Fan Controller +} + +return FAN_4_SPEED_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua index f714a56b1b..62ec23bfa0 100644 --- a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local log = require "log" local capabilities = require "st.capabilities" @@ -22,9 +12,6 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=4 }) local fan_speed_helper = (require "zwave_fan_helpers") -local FAN_4_SPEED_FINGERPRINTS = { - {mfr = 0x001D, prod = 0x0038, model = 0x0002}, -- Leviton 4-Speed Fan Controller -} local function map_fan_4_speed_to_switch_level (speed) if speed == fan_speed_helper.fan_speed.OFF then @@ -64,14 +51,6 @@ end --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is 4-speed fan, else false -local function can_handle_fan_4_speed(opts, driver, device, ...) - for _, fingerprint in ipairs(FAN_4_SPEED_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local capability_handlers = {} @@ -111,7 +90,8 @@ local zwave_fan_4_speed = { } }, NAME = "Z-Wave fan 4 speed", - can_handle = can_handle_fan_4_speed, + can_handle = require("zwave-fan-4-speed.can_handle"), + shared_device_thread_enabled = true, } return zwave_fan_4_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua b/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua index bb056cf06a..38c26fa78c 100644 --- a/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua +++ b/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.SwitchMultilevel diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/can_handle.lua b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/can_handle.lua new file mode 100644 index 0000000000..571ba6bce1 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ecolink_garage_door(opts, driver, device, ...) + local FINGERPRINTS = require("ecolink-zw-gdo.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("ecolink-zw-gdo") + end + end + return false +end + +return can_handle_ecolink_garage_door diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/fingerprints.lua b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/fingerprints.lua new file mode 100644 index 0000000000..15de27ad0b --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Ecolink garage door operator +local ECOLINK_GARAGE_DOOR_FINGERPRINTS = { + {manufacturerId = 0x014A, productType = 0x0007, productId = 0x4731}, +} + +return ECOLINK_GARAGE_DOOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua index e6638841be..1019056330 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.capabilities local capabilities = require "st.capabilities" @@ -28,11 +17,6 @@ local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ ve --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) --- Ecolink garage door operator -local ECOLINK_GARAGE_DOOR_FINGERPRINTS = { - manufacturerId = 0x014A, productType = 0x0007, productId = 0x4731 -} - local GDO_ENDPOINT_NAME = "main" local CONTACTSENSOR_ENDPOINT_NAME = "sensor" local GDO_ENDPOINT_NUMBER = 1 @@ -55,11 +39,6 @@ local GDO_CONFIG_PARAMS = { --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_ecolink_garage_door(opts, driver, device, ...) - return device:id_match(ECOLINK_GARAGE_DOOR_FINGERPRINTS.manufacturerId, - ECOLINK_GARAGE_DOOR_FINGERPRINTS.productType, - ECOLINK_GARAGE_DOOR_FINGERPRINTS.productId) -end local function component_to_endpoint(device, component_id) if (CONTACTSENSOR_ENDPOINT_NAME == component_id) then @@ -282,7 +261,8 @@ local ecolink_garage_door_operator = { doConfigure = configure_device_with_updated_config, infoChanged = configure_device_with_updated_config }, - can_handle = can_handle_ecolink_garage_door + can_handle = require("ecolink-zw-gdo.can_handle"), + shared_device_thread_enabled = true, } return ecolink_garage_door_operator diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/init.lua b/drivers/SmartThings/zwave-garage-door-opener/src/init.lua index 6d0a0a880c..3286471d83 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/init.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.Driver @@ -24,10 +14,8 @@ local driver_template = { capabilities.doorControl, capabilities.contactSensor, }, - sub_drivers = { - require("mimolite-garage-door"), - require("ecolink-zw-gdo") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-garage-door-opener/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/can_handle.lua b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/can_handle.lua new file mode 100644 index 0000000000..e515aae646 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_mimolite_garage_door(opts, driver, device, ...) + local FINGERPRINTS = require("mimolite-garage-door.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("mimolite-garage-door") + end + end + return false +end + +return can_handle_mimolite_garage_door diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/fingerprints.lua b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/fingerprints.lua new file mode 100644 index 0000000000..52f0969e84 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MIMOLITE_GARAGE_DOOR_FINGERPRINTS = { + { manufacturerId = 0x0084, productType = 0x0453, productId = 0x0111 } -- mimolite garage door +} + +return MIMOLITE_GARAGE_DOOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua index 1fd1b4362b..138a4f1b1d 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -28,23 +18,12 @@ local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = --- @type st.zwave.CommandClass.SwitchBinary local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) -local MIMOLITE_GARAGE_DOOR_FINGERPRINTS = { - { manufacturerId = 0x0084, productType = 0x0453, productId = 0x0111 } -- mimolite garage door -} --- Determine whether the passed device is mimolite garage door --- --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_mimolite_garage_door(opts, driver, device, ...) - for _, fingerprint in ipairs(MIMOLITE_GARAGE_DOOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function door_event_helper(device, value) device:emit_event(value == 0x00 and capabilities.doorControl.door.closed() or capabilities.doorControl.door.open()) @@ -118,7 +97,8 @@ local mimolite_garage_door = { doConfigure = do_configure }, NAME = "mimolite garage door", - can_handle = can_handle_mimolite_garage_door + can_handle = require("mimolite-garage-door.can_handle"), + shared_device_thread_enabled = true, } return mimolite_garage_door diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/sub_drivers.lua b/drivers/SmartThings/zwave-garage-door-opener/src/sub_drivers.lua new file mode 100644 index 0000000000..8de6edfd56 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("mimolite-garage-door"), + lazy_load_if_possible("ecolink-zw-gdo"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua index c37e99da13..0608ca2ef2 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.closed()) } + }, + { + min_api_version = 17 } ) @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.open()) } + }, + { + min_api_version = 17 } ) @@ -123,7 +119,8 @@ test.register_message_test( }, { test_init = test_init, - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -162,7 +159,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -183,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 12.2999999, unit = 'C'})) } + }, + { + min_api_version = 17 } ) @@ -203,6 +204,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 45.6, unit = 'F'})) } + }, + { + min_api_version = 17 } ) @@ -228,7 +232,10 @@ test.register_coroutine_test( parameters = updated_params}) )) mock_garage_door:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -255,7 +262,10 @@ test.register_coroutine_test( BarrierOperator:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -282,7 +292,11 @@ test.register_coroutine_test( BarrierOperator:Get({}) ) ) - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -295,7 +309,11 @@ test.register_coroutine_test( ) test.wait_for_events() test.mock_time.advance_time(1) - end + end, + { + min_api_version = 17 + } + ) test.register_coroutine_test( @@ -308,7 +326,11 @@ test.register_coroutine_test( ) test.wait_for_events() test.mock_time.advance_time(1) - end + end, + { + min_api_version = 17 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua index c10d3f8fa5..cc33391a4c 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -88,6 +81,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -109,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -130,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -151,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -172,6 +177,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -203,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.closing()) } + }, + { + min_api_version = 17 } ) @@ -234,6 +245,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.opening()) } + }, + { + min_api_version = 17 } ) @@ -261,7 +275,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -288,7 +305,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -305,7 +325,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 3, node_ids = {}}) )) mock_garage_door:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -326,7 +349,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua index 65c0256149..0d5da468e8 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -58,6 +48,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -74,6 +67,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.doorControl.door.closing()) } + }, + { + min_api_version = 17 } ) @@ -95,6 +91,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -116,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -132,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.doorControl.door.unknown()) } + }, + { + min_api_version = 17 } ) @@ -150,7 +155,8 @@ test.register_message_test( } }, { - test_init = test_init + test_init = test_init, + min_api_version = 17 } ) @@ -170,7 +176,8 @@ test.register_message_test( }, { test_init = test_init, - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-lock/fingerprints.yml b/drivers/SmartThings/zwave-lock/fingerprints.yml index 29d07e956c..a472db1613 100644 --- a/drivers/SmartThings/zwave-lock/fingerprints.yml +++ b/drivers/SmartThings/zwave-lock/fingerprints.yml @@ -375,6 +375,13 @@ zwaveManufacturer: productType: 0x0005 productId: 0x0001 deviceProfileName: lock-battery + # ULTRALOQ + - id: "1106/4/2" + deviceLabel: ULTRALOQ Bolt Z-Wave Smart Deadbolt + manufacturerId: 0x0452 + productId: 0x0002 + productType: 0x0004 + deviceProfileName: base-lock zwaveGeneric: - id: "GenericZwaveLock" deviceLabel: Door Lock diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml index 5fbfb13f3d..e3a25ab57a 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: tamperAlert diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml index f4957f9ad0..efb97c8b27 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: refresh diff --git a/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index fa08ac1fd9..b49489a008 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -1,192 +1,114 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" ---- @type st.zwave.CommandClass -local cc = require "st.zwave.CommandClass" --- @type st.zwave.Driver local ZwaveDriver = require "st.zwave.driver" --- @type st.zwave.defaults local defaults = require "st.zwave.defaults" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) ---- @type st.zwave.CommandClass.Battery -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.CommandClass.Time -local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) -local constants = require "st.zwave.constants" -local utils = require "st.utils" -local json = require "st.json" +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local SCAN_CODES_CHECK_INTERVAL = 30 -local MIGRATION_COMPLETE = "migrationComplete" -local MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped" +local capabilities = require "st.capabilities" -local function periodic_codes_state_verification(driver, device) - local scan_codes_state = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME) - if scan_codes_state == "Scanning" then - driver:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - } - ) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(driver, device) - end - ) - end -end +local consts = require "lock_utils.constants" +local table_utils = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local capability_handlers = require "lock_handlers.capabilities" -local function populate_state_from_data(device) - if device.data.lockCodes ~= nil and device:get_field(MIGRATION_COMPLETE) ~= true then - -- build the lockCodes table - local lockCodes = {} - local lc_data = json.decode(device.data.lockCodes) - for k, v in pairs(lc_data) do - lockCodes[k] = v - end - -- Populate the devices `lockCodes` field - device:set_field(constants.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) - -- Populate the devices state history cache - device.state_cache["main"] = device.state_cache["main"] or {} - device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} - device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} - device:set_field(MIGRATION_COMPLETE, true, { persist = true }) - end -end +local LockLifecycle = {} ---- Builds up initial state for the device ---- ---- @param self st.zwave.Driver ---- @param device st.zwave.Device -local function added_handler(self, device) - populate_state_from_data(device) - if device.data.lockCodes == nil or device:get_field(MIGRATION_RELOAD_SKIPPED) == true then - if (device:supports_capability(capabilities.lockCodes)) then - self:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} }) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(self, device) - end - ) - end - else - device:set_field(MIGRATION_RELOAD_SKIPPED, true, { persist = true }) - end - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) - if (device:supports_capability(capabilities.tamperAlert)) then +function LockLifecycle.device_added(driver, device) + if device:supports_capability(capabilities.tamperAlert) then device:emit_event(capabilities.tamperAlert.tamper.clear()) end + -- set initial state + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) end -local init_handler = function(driver, device, event) - populate_state_from_data(device) -end +function LockLifecycle.init(driver, device) + -- Restore users/credentials capability state from the persistent store in case + -- the capability state cache was wiped since the last driver run. + table_utils.restore_from_persistent_store(device) -local do_refresh = function(self, device) - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) -end + local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) + if lock_pins_supported_by_profile and device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) == true then + -- ensure lockCodes capability state is reflected correctly for already migrated devices + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + end ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @param cmd table -local function update_codes(driver, device, cmd) - local delay = 0 - -- args.codes is json - for name, code in pairs(cmd.args.codes) do - -- these seem to come in the format "code[slot#]: code" - local code_slot = tonumber(string.gsub(name, "code", ""), 10) - if (code_slot ~= nil) then - if (code ~= nil and (code ~= "0" and code ~= "")) then - -- code changed - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({ - user_identifier = code_slot, - user_code = code, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) - end) - delay = delay + 2.2 - else - -- code deleted - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) - end) - delay = delay + 2.2 - device.thread:call_with_delay(delay, function () - device:send(UserCode:Get({user_identifier = code_slot})) - end) - delay = delay + 2.2 - end - end + if device:supports_capability(capabilities.tamperAlert) then + -- ensure our user/credential state is accurate to the current device state + device:emit_event(capabilities.tamperAlert.tamper.clear()) end end -local function time_get_handler(driver, device, cmd) - local time = os.date("*t") - device:send_to_component( - Time:Report({ - hour_local_time = time.hour, - minute_local_time = time.min, - second_local_time = time.sec - }), - device:endpoint_to_component(cmd.src_channel) - ) +function LockLifecycle.driver_switched(driver, device) + if device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME) == true then + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + end + device:try_update_metadata({ provisioning_state = "PROVISIONED" }) end local driver_template = { - supported_capabilities = { - capabilities.lock, - capabilities.lockCodes, - capabilities.battery, - capabilities.tamperAlert - }, lifecycle_handlers = { - added = added_handler, - init = init_handler, + added = LockLifecycle.device_added, + driver_switched = LockLifecycle.driver_switched, + init = LockLifecycle.init, + }, + zwave_handlers = { + [cc.TIME] = { + [0x01] = zwave_handlers.time_get_handler, -- used by DanaLock + [0x03] = zwave_handlers.date_get_handler + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = zwave_handlers.notification_report + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = zwave_handlers.user_code_report, + [UserCode.USERS_NUMBER_REPORT] = zwave_handlers.users_number_report, + } }, capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = capability_handlers.lock, + [capabilities.lock.commands.unlock.NAME] = capability_handlers.unlock, + }, + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = capability_handlers.add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = capability_handlers.update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = capability_handlers.delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = capability_handlers.delete_all_users, + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = capability_handlers.add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = capability_handlers.update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = capability_handlers.delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = capability_handlers.delete_all_credentials, }, [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = do_refresh - } + [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh, + }, }, - zwave_handlers = { - [cc.TIME] = { - [Time.GET] = time_get_handler -- used by DanaLock - } + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.lockUsers, + capabilities.lockCredentials, + capabilities.battery, + capabilities.tamperAlert, }, - sub_drivers = { - require("zwave-alarm-v1-lock"), - require("schlage-lock"), - require("samsung-lock"), - require("keywe-lock"), - require("apiv6_bugfix"), - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua new file mode 100644 index 0000000000..fda854593d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_keywe_lock(opts, self, device, cmd, ...) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated then + local KEYWE_MFR = 0x037B + if device.zwave_manufacturer_id == KEYWE_MFR then + return true, require("legacy-handlers.keywe-lock") + end + end + return false +end + +return can_handle_keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua index d39aa45d1c..455d301473 100644 --- a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -19,17 +9,11 @@ local Association = (require "st.zwave.CommandClass.Association")({version=2}) local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local access_control_event = Notification.event.access_control -local LockDefaults = require "st.zwave.defaults.lock" -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" local TamperDefaults = require "st.zwave.defaults.tamperAlert" +local zwave_handlers = require "lock_handlers.zwave_responses" -local KEYWE_MFR = 0x037B local TAMPER_CLEAR_DELAY = 10 -local function can_handle_keywe_lock(opts, self, device, cmd, ...) - return device.zwave_manufacturer_id == KEYWE_MFR -end - local function clear_tamper_if_needed(device) local current_tamper_state = device:get_latest_state("main", capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME) if current_tamper_state == "detected" then @@ -54,8 +38,9 @@ local function notification_report_handler(self, device, cmd) if event ~= nil then device:emit_event(event) else - LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + zwave_handlers.door_operation_event_handler(self, device, cmd) + zwave_handlers.code_event_handler(self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) device.thread:call_with_delay( TAMPER_CLEAR_DELAY, @@ -80,7 +65,7 @@ local keywe_lock = { doConfigure = do_configure }, NAME = "Keywe Lock", - can_handle = can_handle_keywe_lock, + can_handle = require("keywe-lock.can_handle"), } return keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-lock/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua new file mode 100644 index 0000000000..78757208a4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/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 consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if not slga_migrated then + local subdriver = require("legacy-handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua new file mode 100644 index 0000000000..3bcb8adfe4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -0,0 +1,133 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local init_handler = function(driver, device, event) + local constants = require "st.zwave.constants" + -- temp fix before this can be changed from being persisted in memory + device:set_field(constants.CODE_STATE, nil, { persist = true }) +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd table +local function update_codes(driver, device, cmd) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + local delay = 0 + -- args.codes is json + for name, code in pairs(cmd.args.codes) do + -- these seem to come in the format "code[slot#]: code" + local code_slot = tonumber(string.gsub(name, "code", ""), 10) + if (code_slot ~= nil) then + if (code ~= nil and (code ~= "0" and code ~= "")) then + -- code changed + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({ + user_identifier = code_slot, + user_code = code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) + end) + delay = delay + 2.2 + else + -- code deleted + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) + end) + delay = delay + 2.2 + device.thread:call_with_delay(delay, function () + device:send(UserCode:Get({user_identifier = code_slot})) + end) + delay = delay + 2.2 + end + end + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd table +local function migrate(driver, device, cmd) + local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + local get_lock_codes = LockCodesDefaults.get_lock_codes + local lock_users = {} + local lock_credentials = {} + local lock_codes = get_lock_codes(device) + local ordered_codes = {} + + for code in pairs(lock_codes) do + table.insert(ordered_codes, code) + end + + table.sort(ordered_codes) + for index = 1, #ordered_codes do + local code_slot, code_name = ordered_codes[index], lock_codes[ ordered_codes[index] ] + table.insert(lock_users, {userIndex = index, userType = "guest", userName = code_name}) + table.insert(lock_credentials, {userIndex = index, credentialIndex = tonumber(code_slot), credentialType = "pin"}) + end + device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) + + local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if code_length then + device:emit_event(capabilities.lockCredentials.minPinCodeLen(code_length, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(code_length, { visibility = { displayed = false } })) + end + + local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) + if max_codes then + device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + else + -- if we don't have a code length, request it from the device + device:send(UserCode:UsersNumberGet({})) + end + + device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + local consts = require("lock_utils.constants") + + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore +end + +local legacy_capabilities = { + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.battery, + capabilities.tamperAlert + }, + lifecycle_handlers = { + init = init_handler, + }, + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes, + [capabilities.lockCodes.commands.migrate.NAME] = migrate + }, + }, + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = function(driver, device, cmd) + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + local TamperDefaults = require "st.zwave.defaults.tamperAlert" + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + end + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT], + [UserCode.USERS_NUMBER_REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.USERS_NUMBER_REPORT], + } + }, + sub_drivers = require("legacy-handlers.sub_drivers"), + can_handle = require("legacy-handlers.can_handle"), + NAME = "legacy-handlers" +} + +return legacy_capabilities diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua new file mode 100644 index 0000000000..0970a9c9ed --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_keywe_lock(opts, self, device, cmd, ...) + local KEYWE_MFR = 0x037B + if device.zwave_manufacturer_id == KEYWE_MFR then + return true, require("legacy-handlers.keywe-lock") + end + return false +end + +return can_handle_keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua new file mode 100644 index 0000000000..b40e6658b1 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua @@ -0,0 +1,71 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Association = (require "st.zwave.CommandClass.Association")({version=2}) +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local TAMPER_CLEAR_DELAY = 10 + +local function clear_tamper_if_needed(device) + local current_tamper_state = device:get_latest_state("main", capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME) + if current_tamper_state == "detected" then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_OPEN then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_CLOSED then + event = capabilities.lock.lock.locked() + end + if event ~= nil then + event["data"] = {method = "manual"} + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + device.thread:call_with_delay( + TAMPER_CLEAR_DELAY, + function(d) + clear_tamper_if_needed(device) + end + ) + end +end + +local function do_configure(self, device) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local keywe_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Keywe Lock", + can_handle = require("keywe-lock.can_handle"), +} + +return keywe_lock \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua new file mode 100644 index 0000000000..dc97d03ece --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local SAMSUNG_MFR = 0x022E + if device.zwave_manufacturer_id == SAMSUNG_MFR then + local subdriver = require("legacy-handlers.samsung-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua new file mode 100644 index 0000000000..6e28668b0b --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua @@ -0,0 +1,96 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local access_control_event = Notification.event.access_control + +local json = require "dkjson" +local constants = require "st.zwave.constants" + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local get_lock_codes = LockCodesDefaults.get_lock_codes +local clear_code_state = LockCodesDefaults.clear_code_state +local code_deleted = LockCodesDefaults.code_deleted + + +local function get_ongoing_code_set(device) + local code_id + local code_state = device:get_field(constants.CODE_STATE) + if code_state ~= nil then + for key, state in pairs(code_state) do + if state ~= nil then + code_id = key:match("setName(%d)") + end + end + end + return code_id +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.NEW_USER_CODE_ADDED then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + device:send(UserCode:Get({user_identifier = code_id})) + return + end + elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) + clear_code_state(device, code_id) + end + elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- Update Master Code in the same way as in defaults... + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + -- ...and delete rest of them, as lock does + local lock_codes = get_lock_codes(device) + for code_id, _ in pairs(lock_codes) do + if code_id ~= "0" then + code_deleted(device, code_id) + end + end + event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + end +end + +-- Used doConfigure instead of added to not overwrite parent driver's added_handler +local function do_configure(self, device) + -- taken directly from DTH + -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked + device:emit_event(capabilities.lock.lock.unlocked()) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) +end + +local samsung_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Samsung Lock", + can_handle = require("legacy-handlers.samsung-lock.can_handle"), +} + +return samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua new file mode 100644 index 0000000000..e45491efe2 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local SCHLAGE_MFR = 0x003B + if device.zwave_manufacturer_id == SCHLAGE_MFR then + local subdriver = require("legacy-handlers.schlage-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua new file mode 100644 index 0000000000..a6604b01f5 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua @@ -0,0 +1,178 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local constants = require "st.zwave.constants" +local json = require "dkjson" + +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local user_id_status = UserCode.user_id_status +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) + +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds + +local function set_code_length(self, device, cmd) + local length = cmd.args.length + if length >= 4 and length <= 8 then + device:send(Configuration:Set({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = length, + size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size + })) + end +end + +local function reload_all_codes(self, device, cmd) + LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + end +end + +local function set_code(self, device, cmd) + if (cmd.args.codePIN == "") then + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = {cmd.args.codeSlot, cmd.args.codeName}, + }) + else + -- copied from defaults with additional check for Schlage's configuration + if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then + if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end + local code_state = device:get_field(constants.CODE_STATE) + code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName + device:set_field(constants.CODE_STATE, code_state, { persist = true }) + end + local send_set_user_code = function () + device:send(UserCode:Set({ + user_identifier = cmd.args.codeSlot, + user_code = cmd.args.codePIN, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + end + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) + else + send_set_user_code() + end + end +end + +local function do_configure(self, device) + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function basic_set_handler(self, device, cmd) + device:emit_event(cmd.args.value == 0 and capabilities.lock.lock.unlocked() or capabilities.lock.lock.locked()) + device:send(Association:Remove({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function configuration_report(self, device, cmd) + local parameter_number = cmd.args.parameter_number + if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then + local reported_code_length = cmd.args.configuration_value + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length ~= nil and current_code_length ~= reported_code_length then + local all_codes_deleted_mocked_command = Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED + }) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + end + device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + end +end + +local function is_user_code_report_mfr_specific(device, cmd) + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local code_id = cmd.args.user_identifier + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_state = device:get_field(constants.CODE_STATE) + return user_code == "**********" or user_code == nil or (code_state ~= nil and code_state["setName"..cmd.args.user_identifier] ~= nil) + else + return (code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE) or + reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE + end +end + +local function user_code_report_handler(self, device, cmd) + local code_id = cmd.args.user_identifier + if is_user_code_report_mfr_specific(device, cmd) then + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local event + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_name = LockCodesDefaults.get_code_name(device, code_id) + local change_type = LockCodesDefaults.get_change_type(device, code_id) + event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) + event.data = {codeName = code_name} + if code_id ~= 0 then -- ~= MASTER_CODE + LockCodesDefaults.code_set_event(device, code_id, code_name) + end + elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then + local lock_codes = LockCodesDefaults.get_lock_codes(device) + for _code_id, _ in pairs(lock_codes) do + LockCodesDefaults.code_deleted(device, _code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) + else -- user_id_status.STATUS_NOT_AVAILABLE + event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + end + + if event ~= nil then + device:emit_event(event) + end + LockCodesDefaults.clear_code_state(device, code_id) + LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) + else + LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + end +end + +local schlage_lock = { + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, + [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [capabilities.lockCodes.commands.setCode.NAME] = set_code + } + }, + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report_handler + }, + [cc.CONFIGURATION] = { + [Configuration.REPORT] = configuration_report + }, + [cc.BASIC] = { + [Basic.SET] = basic_set_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure, + }, + NAME = "Schlage Lock", + can_handle = require("legacy-handlers.schlage-lock.can_handle"), +} + +return schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua new file mode 100644 index 0000000000..4c94ca5559 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers.zwave-alarm-v1-lock"), + lazy_load_if_possible("legacy-handlers.schlage-lock"), + lazy_load_if_possible("legacy-handlers.samsung-lock"), + lazy_load_if_possible("legacy-handlers.keywe-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua new file mode 100644 index 0000000000..324f83e6f9 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 then + local subdriver = require("legacy-handlers.zwave-alarm-v1-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua new file mode 100644 index 0000000000..5865091852 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua @@ -0,0 +1,146 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Alarm +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.defaults.lockCodes +local lock_code_defaults = require "st.zwave.defaults.lockCodes" +local json = require "dkjson" + +local METHOD = { + KEYPAD = "keypad", + MANUAL = "manual", + COMMAND = "command", + AUTO = "auto" +} + +--- Default handler for alarm command class reports, these were largely OEM-defined +--- +--- This converts alarm V1 reports to correct lock events +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Alarm.Report +local function alarm_report_handler(driver, device, cmd) + local alarm_type = cmd.args.alarm_type + local event = nil + local lock_codes = lock_code_defaults.get_lock_codes(device) + local code_id = nil + if (cmd.args.alarm_level ~= nil) then + code_id = tostring(cmd.args.alarm_level) + end + if (alarm_type == 9 or alarm_type == 17) then + event = capabilities.lock.lock.unknown() + elseif (alarm_type == 16 or alarm_type == 19) then + event = capabilities.lock.lock.unlocked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 18) then + event = capabilities.lock.lock.locked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 21) then + event = capabilities.lock.lock.locked() + if (cmd.args.alarm_level == 2) then + event["data"] = {method = METHOD.MANUAL} + else + event["data"] = {method = METHOD.KEYPAD} + end + elseif (alarm_type == 22) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.MANUAL} + elseif (alarm_type == 23) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 24) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 25) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 26) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 27) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 32) then + -- all user codes deleted + for code_id, _ in pairs(lock_codes) do + lock_code_defaults.code_deleted(device, code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + elseif (alarm_type == 33) then + -- user code deleted + if (code_id ~= nil) then + lock_code_defaults.clear_code_state(device, code_id) + if (lock_codes[code_id] ~= nil) then + lock_code_defaults.code_deleted(device, code_id) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + end + end + elseif (alarm_type == 13 or alarm_type == 112) then + -- user code changed/set + if (code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + local change_type = lock_code_defaults.get_change_type(device, code_id) + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) + code_changed_event["data"] = { codeName = code_name} + lock_code_defaults.code_set_event(device, code_id, code_name) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 34 or alarm_type == 113) then + -- duplicate lock code + if (code_id ~= nil) then + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 130) then + -- batteries replaced + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 161) then + -- tamper alarm + event = capabilities.tamperAlert.tamper.detected() + elseif (alarm_type == 167) then + -- low battery + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 168) then + -- critical battery + event = capabilities.battery.battery(1) + elseif (alarm_type == 169) then + -- battery too low to operate + event = capabilities.battery.battery(0) + end + + if (event ~= nil) then + device:emit_event(event) + end +end + +local zwave_lock = { + zwave_handlers = { + [cc.ALARM] = { + [Alarm.REPORT] = alarm_report_handler + } + }, + NAME = "Z-Wave lock alarm V1", + can_handle = require("legacy-handlers.zwave-alarm-v1-lock.can_handle") +} + +return zwave_lock diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua new file mode 100644 index 0000000000..13d45bc4dd --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -0,0 +1,252 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local consts = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + + +local CapabilityHandlers = {} + + +-- [[ LOCK USERS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- Find the smallest positive userIndex not already in the table + local next_available_index = tables.next_index(device, "users") + local status = tables.add_entry(device, "users", { + userIndex = next_available_index, + userName = command.args.userName, + userType = command.args.userType, + }) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = next_available_index } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, status, additional_info) +end + +function CapabilityHandlers.update_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + local result_status + if not tables.find_entry(device, "users", command.args.userIndex) then + result_status = tables.add_entry(device, "users", { + userIndex = command.args.userIndex, + userName = command.args.userName, + userType = command.args.userType, + }) + else + result_status = tables.update_entry(device, "users", + command.args.userIndex, + { userName = command.args.userName, userType = command.args.userType } + ) + end + + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, result_status, additional_info) +end + +function CapabilityHandlers.delete_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.BUSY) + return + end + + local associated_credentials = tables.find_all_entries_by(device, "credentials", "userIndex", command.args.userIndex) + for _, associated_credential in ipairs(associated_credentials) do + -- Set busy state with the full user+credential context BEFORE injecting. + -- Injected capability commands are schema-validated, so extra args like userIndex + -- would be stripped. By setting device fields here we preserve the full context. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE, { + userIndex = command.args.userIndex, + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + }) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + } + }) + end + if #associated_credentials == 0 then + -- No associated credentials: delete the user entry directly and report the result + local status = tables.delete_entry(device, "users", command.args.userIndex) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, status, additional_info) + end +end + +function CapabilityHandlers.delete_all_users(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + return + end + -- Set busy state with DELETE_ALL context BEFORE injecting so the response handler + -- knows to clear both tables and emit results for both capabilities. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE_ALL, {}) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteAllCredentials.NAME, + args = { credentialType = consts.CRED_TYPE_PIN } + }) +end + + +-- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- A userIndex of 0 means "auto-assign the next available slot" + local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex + local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + + if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) + return + end + + -- Set busy state and attempt to set the PIN on-device. + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = user_index, + credentialIndex = cred_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = cred_index, + user_code = command.args.credentialData, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) +end + +function CapabilityHandlers.update_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) + return + end + + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { + userIndex = command.args.userIndex, + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = command.args.credentialIndex, + user_code = command.args.credentialData, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) +end + +function CapabilityHandlers.delete_credential(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == consts.LOCK_USERS.DELETE then + -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + device:send(UserCode:Set({ + user_identifier = credential_args.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) + else + -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. + local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) + if not found_cred then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) + return + end + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + userIndex = found_cred.userIndex, + }) + device:send(UserCode:Set({ + user_identifier = command.args.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + end +end + +function CapabilityHandlers.delete_all_credentials(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + local send_clear_commands = function() + local credentials = tables.get_state(device, "credentials") or {} + local delay = 0 + for _, credential in ipairs(credentials) do + device.thread:call_with_delay(delay, function() + device:send(UserCode:Set({ + user_identifier = credential.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + end) + delay = delay + 2 + end + -- Delayed cleanup: ensure all tables are cleared after individual responses complete + device.thread:call_with_delay(delay + 4, function() + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + tables.delete_all_entries(device, "users") + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) + end + tables.delete_all_entries(device, "credentials") + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) + lock_utils.clear_busy_state(device) + end) + end + + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + -- Injected by deleteAllUsers; busy state was already set with LOCK_USERS.DELETE_ALL context. + send_clear_commands() + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + else + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE_ALL, command.args) + send_clear_commands() + end +end + + +-- [[ REFRESH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.refresh(driver, device, cmd) + device:send(DoorLock:OperationGet({})) + device:send(Battery:Get({})) + + if device:supports_capability(capabilities.lockCredentials) then + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) or + (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then + device:send(UserCode:UsersNumberGet({})) + end + end +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua new file mode 100644 index 0000000000..7822ab2bfe --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -0,0 +1,256 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local socket = require "cosock.socket" + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" + + +local ZwaveHandlers = {} + + +-- [[ USER CODE COMMAND CLASS ]] + +function ZwaveHandlers.user_code_report(driver, device, cmd) + -- zw report values + local credential_index = cmd.args.user_identifier + local user_id_status = cmd.args.user_id_status + + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + -- Determine boolean states of user code slot + local user_code_occupied = user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or + (user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code) + local user_code_available = user_id_status == UserCode.user_id_status.AVAILABLE + + if command_in_progress == consts.SYNC.CODES_FROM_LOCK then + if user_code_occupied then + -- If an entry already exists at this index, this will be a no-op. + tables.add_entry(device, "users", { + userIndex = credential_index, + userName = "Guest " .. credential_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = credential_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. credential_index, + }) + -- reset the consecutive unoccupied codes counter since we found an occupied code + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) + elseif user_code_available then + local consecutive_unoccupied_codes = (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1 + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, consecutive_unoccupied_codes) + end + + -- Sync: continue to next code or finish + if credential_index >= tables.get_max_entries(device, "credentials") or + (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then + -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. + device:set_field(consts.SYNC.CODE_INDEX, nil) + lock_utils.clear_busy_state(device) + else + local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 + device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) + device:send(UserCode:Get({user_identifier = synced_code_index})) + end + return + elseif user_code_occupied then + -- Code slot is now occupied: add or update credential and associated user + lock_utils.set_credential_report_helper(device, credential_index) + elseif user_code_available then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.FAILURE) + lock_utils.clear_busy_state(device) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE or tables.find_entry(device, "credentials", credential_index) then + lock_utils.delete_credential_report_helper(device, credential_index) + end + end +end + +function ZwaveHandlers.users_number_report(driver, device, cmd) + device:emit_event(capabilities.lockUsers.totalUsersSupported( + cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.pinUsersSupported( + cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) +end + + +-- [[ NOTIFICATION COMMAND CLASS ]] -- + +function ZwaveHandlers.user_code_event_handler(driver, device, cmd) + if cmd.args.notification_type ~= Notification.notification_type.ACCESS_CONTROL then + return + end + -- zw event values + local event = cmd.args.event + local credential_index = tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level)) + -- cached value from capability event, if applicable + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- This type includes many lock-related events, way too many to list, + -- including user code changes and door operations + local access_control_event = Notification.event.access_control + + if event == access_control_event.ALL_USER_CODES_DELETED then + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") + + elseif event == access_control_event.SINGLE_USER_CODE_DELETED then + lock_utils.delete_credential_report_helper(device, credential_index) + + elseif event == access_control_event.NEW_USER_CODE_ADDED then + lock_utils.set_credential_report_helper(device, credential_index) + + elseif event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- aka "master code" changed + if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.SUCCESS) + lock_utils.clear_busy_state(device) + end + + elseif event == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + if command_in_progress then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.DUPLICATE) + lock_utils.clear_busy_state(device) + end + end +end + +function ZwaveHandlers.door_operation_event_handler(driver, device, cmd) + if cmd.args.notification_type ~= Notification.notification_type.ACCESS_CONTROL then + return + end + -- zw event value + local event = cmd.args.event + -- This type includes many lock-related events, way too many to list, + -- including user code changes and door operations + local access_control_event = Notification.event.access_control + -- send lock, unlock, or unknown event based on the event coded + local capability_event + if not (event >= access_control_event.MANUAL_LOCK_OPERATION and event <= access_control_event.LOCK_JAMMED) then + return -- This is the subset of event kinds that we care about for door operation reporting + elseif ((event >= access_control_event.MANUAL_LOCK_OPERATION and + event <= access_control_event.KEYPAD_UNLOCK_OPERATION) or + event == access_control_event.AUTO_LOCK_LOCKED_OPERATION) then + -- even event codes are unlocks, odd event codes are locks + local events = {[0] = capabilities.lock.lock.unlocked(), [1] = capabilities.lock.lock.locked()} + capability_event = events[event & 1] + elseif (event >= access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION and + event <= access_control_event.LOCK_JAMMED) then + capability_event = capabilities.lock.lock.unknown() + else + return -- no lock event to send for this code + end + + local access_control_event_capability_map = { + [access_control_event.MANUAL_UNLOCK_OPERATION] = "manual", + [access_control_event.MANUAL_LOCK_OPERATION] = "manual", + [access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION] = "manual", + [access_control_event.RF_LOCK_OPERATION] = "command", + [access_control_event.RF_UNLOCK_OPERATION] = "command", + [access_control_event.RF_NOT_FULLY_LOCKED_OPERATION] = "command", + [access_control_event.KEYPAD_LOCK_OPERATION] = "keypad", + [access_control_event.KEYPAD_UNLOCK_OPERATION] = "keypad", + [access_control_event.AUTO_LOCK_LOCKED_OPERATION] = "auto", + [access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION] = "auto" + } + + capability_event.data = {} + capability_event.data.method = access_control_event_capability_map[event] + + if (event == access_control_event.MANUAL_UNLOCK_OPERATION and cmd.args.event_parameter == 2) then + capability_event.data.method = "keypad" -- some locks can distinguish being manually locked via keypad + elseif (event == access_control_event.KEYPAD_LOCK_OPERATION or event == access_control_event.KEYPAD_UNLOCK_OPERATION) then + local code_id = tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level)) -- Look up stored lockUsers data if applicable + if device:supports_capability(capabilities.lockUsers) then + local credential = tables.find_entry(device, "credentials", code_id) + if credential then + local user = tables.find_entry(device, "users", credential.userIndex) + capability_event.data.userIndex = credential.userIndex + if user then + capability_event.data.userName = user.userName + capability_event.data.userType = user.userType + end + else + capability_event.data.userIndex = code_id + end + end + end + + -- Delay timer logic to handle duplicate lock state reports + if device:get_latest_state( + "main", + capabilities.lock.ID, + capabilities.lock.lock.ID) == capability_event.value.value then + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) + end + end + + local timer = device:get_field(consts.DELAY_LOCK_EVENT_TIMER) + if timer ~= nil then + device.thread:cancel_timer(timer) + device:set_field(consts.DELAY_LOCK_EVENT_TIMER, nil) + end + + device:emit_event(capability_event) +end + +function ZwaveHandlers.notification_report(driver, device, cmd) + ZwaveHandlers.user_code_event_handler(driver, device, cmd) + ZwaveHandlers.door_operation_event_handler(driver, device, cmd) + -- Tamper events handled by default tamper handler + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) +end + + +-- [[ TIME COMMAND CLASS ]] -- + +function ZwaveHandlers.time_get_handler(driver, device, cmd) + local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) + local time = os.date("*t") + device:send_to_component( + Time:Report({ + hour_local_time = time.hour, + minute_local_time = time.min, + second_local_time = time.sec + }), + device:endpoint_to_component(cmd.src_channel) + ) +end + +function ZwaveHandlers.date_get_handler(driver, device, cmd) + local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) + local time = os.date("*t") + device:send_to_component( + Time:DateReport({ + year = time.year, + month = time.month, + day = time.day + }), + device:endpoint_to_component(cmd.src_channel) + ) +end + +return ZwaveHandlers diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua new file mode 100644 index 0000000000..f66c452beb --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -0,0 +1,48 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lock_constants = {} + +lock_constants.DRIVER_STATE = { + BUSY = "busy", + COMMAND_IN_PROGRESS = "commandInProgress", + CREDENTIAL_ARGS_IN_USE = "currentCredential", + SLGA_MIGRATED = "slgaMigrated", +} + +lock_constants.SYNC = { + CODES_FROM_LOCK = "syncCodesFromLock", + CODE_INDEX = "syncCodeIndex", + CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", +} + +lock_constants.COMMAND_RESULT = { + SUCCESS = "success", + FAILURE = "failure", + DUPLICATE = "duplicate", + OCCUPIED = "occupied", + INVALID_COMMAND = "invalidCommand", + RESOURCE_EXHAUSTED = "resourceExhausted", + BUSY = "busy" +} + +lock_constants.LOCK_CREDENTIALS = { + ADD = "addCredential", + UPDATE = "updateCredential", + DELETE = "deleteCredential", + DELETE_ALL = "deleteAllCredentials" +} + +lock_constants.LOCK_USERS = { + ADD = "addUser", + UPDATE = "updateUser", + DELETE = "deleteUser", + DELETE_ALL = "deleteAllUsers" +} + +lock_constants.CRED_TYPE_PIN = "pin" +lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" +lock_constants.DELAY_LOCK_EVENT_TIMER = "_delay_lock_event_timer" +lock_constants.MAX_DELAY = 10 + +return lock_constants diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua new file mode 100644 index 0000000000..bfe3562344 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -0,0 +1,268 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT + +local table_utils = {} + +-- DEFS describes how each capability-backed state table is structured: +-- +-- capability SmartThings capability (used for device support checks and to get latest state) +-- attribute Capability attribute function (used to emit state) +-- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table +-- match_key Key used to identify entries in flat tables +-- required_keys Keys that must be non-nil when adding an entry +-- persistent_field device:set_field key used to back up the table across restarts +-- + +local DEFS = { + users = { + capability = capabilities.lockUsers, + attribute = capabilities.lockUsers.users, + max_entries = capabilities.lockUsers.totalUsersSupported, + match_key = "userIndex", + required_keys = {"userIndex", "userType"}, + persistent_field = "persistedUsers", + }, + credentials = { + capability = capabilities.lockCredentials, + attribute = capabilities.lockCredentials.credentials, + max_entries = capabilities.lockCredentials.pinUsersSupported, + match_key = "credentialIndex", + required_keys = {"userIndex", "credentialIndex", "credentialType"}, + persistent_field = "persistedCredentials", + } +} + +-- Resolve a table name to its definition. Logs an error and returns nil if unknown. +local function resolve_table_def(device, table_name) + local def = DEFS[table_name] + if not def then + device.log.error(string.format("table_helpers: unknown table %q", table_name)) + end + return def +end + +-- Validate that an entry table contains all required keys. +local function validate_entry(device, entry, required_keys) + for _, key in ipairs(required_keys or {}) do + if entry[key] == nil then + device.log.error(string.format("table_helpers: entry missing required key %q", key)) + return false + end + end + return true +end + +-- Write the current table contents to the device's persistent field store so that +-- the state survives driver restarts and can be restored if the capability state +-- cache is wiped. +local function persist_table(device, def, data) + device:set_field(def.persistent_field, st_utils.deep_copy(data), { persist = true }) +end + +-- Read the current state for a table and return a deep-copied array. +-- Accepts either a string table name ("users", "credentials") or a DEFS entry directly. +-- Returns nil (with a warning) if the capability is unsupported by the device. +-- When the capability state cache has been wiped (get_latest_state returns nil), +-- falls back to the persistent field store so that callers always receive the +-- last-known state rather than an empty table. +--- @return table[] | nil +function table_utils.get_state(device, name_or_def) + local def = type(name_or_def) == "string" and resolve_table_def(device, name_or_def) or name_or_def + if not def then return nil end + if not device:supports_capability(def.capability, "main") then + device.log.warn(string.format( + "table_helpers: device does not support capability %q", def.capability.ID + )) + return + end + local state = device:get_latest_state("main", def.capability.ID, def.attribute.NAME) + if state ~= nil then + return st_utils.deep_copy(state) + end + -- Capability state cache is absent (e.g. after a hub reboot); fall back to the + -- persistent store so that callers see the last-known table contents. + return st_utils.deep_copy(device:get_field(def.persistent_field) or {}) +end + +-- Find an entry in a named table where the match_key equals value. +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry(device, table_name, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[def.match_key] == value then return entry end + end + return nil +end + +-- Find an entry in a named table where entry[key] equals value (arbitrary key search). +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[key] == value then return entry end + end + return nil +end + +-- Find all entries in a named table where entry[key] equals value (arbitrary key search). +-- Returns an array of matching entries, or an empty array if none found. +function table_utils.find_all_entries_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return {} end + local t = table_utils.get_state(device, def) + if not t then return {} end + local matches = {} + for _, entry in ipairs(t) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches +end + +-- Return the lowest positive integer not yet used as the match_key in the named table. +-- Used to auto-assign the next available slot for a new entry. +function table_utils.next_index(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return 1 end + local t = table_utils.get_state(device, def) or {} + local occupied = {} + for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end + local idx = 1 + while occupied[idx] do idx = idx + 1 end + return idx +end + +function table_utils.get_max_entries(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return end + return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing +end + +-- Add an entry to a named table. The entry must satisfy all required_keys for +-- that table. An entry whose match_key value already exists in the +-- table is skipped to prevent duplicates. If the table has a max_entries limit, +-- entries that exceed the limit are not added. +function table_utils.add_entry(device, table_name, entry) + device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + if not validate_entry(device, entry, def.required_keys) then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + if #t >= table_utils.get_max_entries(device, table_name) then + device.log.warn(string.format( + "table_helpers: cannot add entry to %q, max entries reached", table_name + )) + return COMMAND_RESULT.RESOURCE_EXHAUSTED + end + + -- Object entry: skip if an entry with the same match_key value already exists + if def.match_key then + for _, existing in ipairs(t) do + if existing[def.match_key] == entry[def.match_key] then + device.log.warn(string.format( + "table_helpers: entry with %s == %s already exists in %q, skipping", + def.match_key, tostring(entry[def.match_key]), table_name + )) + return COMMAND_RESULT.OCCUPIED + end + end + end + + table.insert(t, st_utils.deep_copy(entry)) + + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS +end + + +--- Update fields of an existing entry in a table. +--- The entry to update is identified by the match_key parameter in DEFS. +function table_utils.update_entry(device, table_name, match_value, updates) + device.log.debug("table_helpers: attempting to update entry " .. match_value .. " in " .. table_name .. ": " .. st_utils.stringify_table(updates)) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + for _, entry in ipairs(t) do + if entry[def.match_key] == match_value then + for k, v in pairs(updates) do + entry[k] = v + end + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + + device.log.warn(string.format( + "table_helpers: no entry found in %q with %s == %s", + table_name, def.match_key, tostring(match_value) + )) + return COMMAND_RESULT.FAILURE +end + + +-- Delete an entry from a table. +-- +-- Returns SUCCESS, or FAILURE if nothing matched. +function table_utils.delete_entry(device, table_name, matcher) + device.log.debug("table_helpers: attempting to delete entry " .. matcher .. " from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + local predicate = function(entry) return entry[def.match_key] == matcher end + + for i, entry in ipairs(t) do + if predicate(entry) then + table.remove(t, i) + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + return COMMAND_RESULT.FAILURE +end + +-- Delete all entries from a table. +function table_utils.delete_all_entries(device, table_name) + device.log.debug("table_helpers: attempting to delete all entries from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + device:emit_event(def.attribute({}, {visibility = {displayed = false}})) + persist_table(device, def, {}) + return COMMAND_RESULT.SUCCESS +end + +-- Restore capability state from the persistent field store. +-- Called during init to re-emit table events if the capability state cache +-- has been wiped (e.g. after a hub reboot). Only emits for tables that have +-- persisted data, are in a nil state, and whose capability is supported by the device. +function table_utils.restore_from_persistent_store(device) + for _, internal in pairs(DEFS) do + if device:supports_capability(internal.capability, "main") and + device:get_latest_state("main", internal.capability.ID, internal.attribute.NAME) == nil + then + local persisted = st_utils.deep_copy(device:get_field(internal.persistent_field)) + if persisted and #persisted > 0 then + device:emit_event(internal.attribute(persisted, {visibility = {displayed = false}})) + end + end + end +end + +return table_utils diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua new file mode 100644 index 0000000000..64851e485a --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -0,0 +1,160 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" + +local lock_utils = {} + +-- [[ BUSY STATE MANAGEMENT ]] -- + +-- Check if we are currently busy performing a task, or at least 10 seconds have passed since the busy state was last set. +-- If busy, return true. If not busy, clear any stale state and return false. +function lock_utils.is_device_busy(device) + local c_time = os.time() + local busy_since = device:get_field(consts.DRIVER_STATE.BUSY) or false + + if (busy_since == false) or (c_time - busy_since > 10) then + lock_utils.clear_busy_state(device) + return false + end + return true +end + +-- Set states that may be required when in busy state +function lock_utils.set_busy_state(device, command_name, command_args) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) + device:set_field(consts.DRIVER_STATE.BUSY, os.time()) +end + +-- Clear states that were set when in busy state +function lock_utils.clear_busy_state(device) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) + device:set_field(consts.DRIVER_STATE.BUSY, false) +end + + +-- [[ SYNC STATE MANAGEMENT ]] -- + +function lock_utils.sync_device_state(device) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + + if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then + device:set_field(consts.SYNC.CODE_INDEX, 1) + end + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(UserCode:Get({user_identifier = device:get_field(consts.SYNC.CODE_INDEX)})) +end + + +-- [[ COMMAND RESULT STATE MANAGEMENT ]] -- + +function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) + local info = additional_info or {} + info.commandName = command_name + info.statusCode = status_code + if capability then + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) + end +end + + +-- [[ HELPERS ]] -- + +function lock_utils.get_code_id_from_notification_event(event_params, v1_alarm_level) + local code_id = v1_alarm_level + if event_params ~= nil and event_params ~= "" then + event_params = {event_params:byte(1,-1)} + code_id = (#event_params == 1) and event_params[1] or event_params[3] + end + return tostring(code_id) +end + +function lock_utils.set_credential_report_helper(device, credential_index) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + + local result_status + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args.userIndex, + credentialIndex = credential_args.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + credentialName = credential_args.credentialName, + }) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + elseif not tables.find_entry(device, "credentials", credential_index) then + -- credential does not exist and no add/update command in progress, therefore it was added out-of-band + -- check what user slot we have to associate this with, and add a default user + local next_available_index = tables.next_index(device, "users") + if next_available_index <= tables.get_max_entries(device, "users") then + tables.add_entry(device, "users", { + userIndex = next_available_index, + userName = "Guest " .. next_available_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = next_available_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. next_available_index, + }) + end + end + + -- emit command result + if command_in_progress and result_status then + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args.userIndex, + credentialIndex = credential_args.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) + lock_utils.clear_busy_state(device) + end +end + +function lock_utils.delete_credential_report_helper(device, credential_index) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + + local user_status, credential_status + if command_in_progress == consts.LOCK_USERS.DELETE then + user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE then + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + else + -- out-of-band deletion, find the credential index and associated user to delete + local credential = tables.find_entry(device, "credentials", credential_index) + if credential then + credential_status = tables.delete_entry(device, "credentials", credential_index) + local associated_user = tables.find_entry_by(device, "users", "userIndex", credential.userIndex) + if associated_user then + tables.delete_entry(device, "users", credential.userIndex) + end + end + end + + -- emit command results + if command_in_progress == consts.LOCK_USERS.DELETE and user_status and credential_status then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + lock_utils.clear_busy_state(device) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE and credential_status then + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + lock_utils.clear_busy_state(device) + end +end + + +return lock_utils diff --git a/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua new file mode 100644 index 0000000000..002d4c169d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated then + local SAMSUNG_MFR = 0x022E + if device.zwave_manufacturer_id == SAMSUNG_MFR then + local subdriver = require("samsung-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua index 813c6217b4..80c3ce25d5 100644 --- a/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -19,33 +8,9 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local access_control_event = Notification.event.access_control -local json = require "dkjson" -local constants = require "st.zwave.constants" - -local LockDefaults = require "st.zwave.defaults.lock" -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" -local get_lock_codes = LockCodesDefaults.get_lock_codes -local clear_code_state = LockCodesDefaults.clear_code_state -local code_deleted = LockCodesDefaults.code_deleted - -local SAMSUNG_MFR = 0x022E - -local function can_handle_samsung_lock(opts, self, device, cmd, ...) - return device.zwave_manufacturer_id == SAMSUNG_MFR -end - -local function get_ongoing_code_set(device) - local code_id - local code_state = device:get_field(constants.CODE_STATE) - if code_state ~= nil then - for key, state in pairs(code_state) do - if state ~= nil then - code_id = key:match("setName(%d)") - end - end - end - return code_id -end +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" local function notification_report_handler(self, device, cmd) local event @@ -54,36 +19,25 @@ local function notification_report_handler(self, device, cmd) if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then event = capabilities.lock.lock.unlocked() elseif event_code == access_control_event.NEW_USER_CODE_ADDED then - local code_id = get_ongoing_code_set(device) - if code_id ~= nil then - device:send(UserCode:Get({user_identifier = code_id})) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and credential_args ~= nil then + device:send(UserCode:Get({ user_identifier = credential_args.credentialIndex })) return end - elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then - local code_id = get_ongoing_code_set(device) - if code_id ~= nil then - event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) - clear_code_state(device, code_id) - end elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then - -- Update Master Code in the same way as in defaults... - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - -- ...and delete rest of them, as lock does - local lock_codes = get_lock_codes(device) - for code_id, _ in pairs(lock_codes) do - if code_id ~= "0" then - code_deleted(device, code_id) - end - end - event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + -- All other codes are deleted when the master code is changed + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") + return end end if event ~= nil then device:emit_event(event) else - LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + zwave_handlers.door_operation_event_handler(self, device, cmd) + zwave_handlers.code_event_handler(self, device, cmd) end end @@ -92,7 +46,6 @@ local function do_configure(self, device) -- taken directly from DTH -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked device:emit_event(capabilities.lock.lock.unlocked()) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) end local samsung_lock = { @@ -105,7 +58,7 @@ local samsung_lock = { doConfigure = do_configure }, NAME = "Samsung Lock", - can_handle = can_handle_samsung_lock, + can_handle = require("samsung-lock.can_handle"), } return samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua new file mode 100644 index 0000000000..d57135920d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated then + local SCHLAGE_MFR = 0x003B + if device.zwave_manufacturer_id == SCHLAGE_MFR then + local subdriver = require("schlage-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua index 67e649d869..ee04fe42a0 100644 --- a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua @@ -1,92 +1,22 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" local constants = require "st.zwave.constants" -local json = require "dkjson" local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local user_id_status = UserCode.user_id_status -local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local access_control_event = Notification.event.access_control local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) local Association = (require "st.zwave.CommandClass.Association")({version=1}) -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local cap_handlers = require "lock_handlers.capabilities" -local SCHLAGE_MFR = 0x003B local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} -local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds - -local function can_handle_schlage_lock(opts, self, device, cmd, ...) - return device.zwave_manufacturer_id == SCHLAGE_MFR -end - -local function set_code_length(self, device, cmd) - local length = cmd.args.length - if length >= 4 and length <= 8 then - device:send(Configuration:Set({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, - configuration_value = length, - size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size - })) - end -end - -local function reload_all_codes(self, device, cmd) - LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - if current_code_length == nil then - device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) - end -end - -local function set_code(self, device, cmd) - if (cmd.args.codePIN == "") then - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {cmd.args.codeSlot, cmd.args.codeName}, - }) - else - -- copied from defaults with additional check for Schlage's configuration - if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then - if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end - local code_state = device:get_field(constants.CODE_STATE) - code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName - device:set_field(constants.CODE_STATE, code_state, { persist = true }) - end - local send_set_user_code = function () - device:send(UserCode:Set({ - user_identifier = cmd.args.codeSlot, - user_code = cmd.args.codePIN, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) - ) - end - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - if current_code_length == nil then - device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) - device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) - else - send_set_user_code() - end - end -end - local function do_configure(self, device) device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) @@ -101,15 +31,14 @@ local function configuration_report(self, device, cmd) local parameter_number = cmd.args.parameter_number if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then local reported_code_length = cmd.args.configuration_value - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) if current_code_length ~= nil and current_code_length ~= reported_code_length then - local all_codes_deleted_mocked_command = Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = access_control_event.ALL_USER_CODES_DELETED - }) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + -- when the code length is changed, all the codes have been wiped + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") end - device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(reported_code_length)) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(reported_code_length)) end end @@ -129,49 +58,35 @@ local function is_user_code_report_mfr_specific(device, cmd) end local function user_code_report_handler(self, device, cmd) - local code_id = cmd.args.user_identifier + local credential_index = cmd.args.user_identifier if is_user_code_report_mfr_specific(device, cmd) then local reported_user_id_status = cmd.args.user_id_status - local user_code = cmd.args.user_code - local event - if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 - (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then - local code_name = LockCodesDefaults.get_code_name(device, code_id) - local change_type = LockCodesDefaults.get_change_type(device, code_id) - event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) - event.data = {codeName = code_name} - if code_id ~= 0 then -- ~= MASTER_CODE - LockCodesDefaults.code_set_event(device, code_id, code_name) - end - elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then - local lock_codes = LockCodesDefaults.get_lock_codes(device) - for _code_id, _ in pairs(lock_codes) do - LockCodesDefaults.code_deleted(device, _code_id) - end - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) - else -- user_id_status.STATUS_NOT_AVAILABLE - event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + if credential_index == 0 and reported_user_id_status == user_id_status.AVAILABLE then + -- master code changed, clear all credentials + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") end + else + zwave_handlers.user_code_report(self, device, cmd) + end +end - if event ~= nil then - device:emit_event(event) - end - LockCodesDefaults.clear_code_state(device, code_id) - LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) +local function add_credential_handler(self, device, cmd) + local DEFAULT_COMMANDS_DELAY = 4.2 + local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) + local base_handler = function() + cap_handlers.add_credential(self, device, cmd) + end + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, base_handler) else - LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + base_handler() end end local schlage_lock = { - capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, - [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [capabilities.lockCodes.commands.setCode.NAME] = set_code - } - }, zwave_handlers = { [cc.USER_CODE] = { [UserCode.REPORT] = user_code_report_handler @@ -183,11 +98,16 @@ local schlage_lock = { [Basic.SET] = basic_set_handler } }, + capability_handlers = { + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential_handler + } + }, lifecycle_handlers = { doConfigure = do_configure, }, NAME = "Schlage Lock", - can_handle = can_handle_schlage_lock, + can_handle = require("schlage-lock.can_handle"), } return schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua new file mode 100644 index 0000000000..af5dc4d41f --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-alarm-v1-lock"), + lazy_load_if_possible("schlage-lock"), + lazy_load_if_possible("samsung-lock"), + lazy_load_if_possible("keywe-lock"), + lazy_load_if_possible("legacy-handlers"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua new file mode 100644 index 0000000000..d521e2d3a0 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -0,0 +1,135 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lifecycle handlers defined in init.lua: +-- added (device_added), infoChanged (info_changed), init (LockLifecycle.init) +-- +-- Removed: doConfigure tests — z-wave drivers have no doConfigure lifecycle event. +-- Removed: init SLGA_MIGRATED without lockCodes test — z-wave init sends no protocol +-- messages; there is no NumberOfPINUsersSupported read on init. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +-- base-lock profile: lock + lockCodes + lockCredentials + lockUsers + battery +local mock_device_base = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- lock-battery profile: lock + battery only (no lockCodes / lockCredentials) +local mock_device_battery = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local function make_test_init(device) + return function() + test.disable_startup_messages() + test.mock_device.add_test_device(device) + end +end + +-- ============================================================================ +-- added (device_added) +-- ============================================================================ + +test.register_coroutine_test( + "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "added" }) + + -- No migrated capability event expected + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_base.id)) + test.wait_for_events() + + assert( + mock_device_base:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) ~= true, + "SLGA_MIGRATED must NOT be set for a non-TYPED device" + ) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "added: device without lockCodes does NOT emit migrated event but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + + -- No migrated event; lock-battery has no lockCredentials so no UsersNumberGet + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_battery.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_battery.id)) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- init (LockLifecycle.init) +-- ============================================================================ + +test.register_coroutine_test( + "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: device with lockCodes but SLGA_MIGRATED not set does nothing", + function() + -- SLGA_MIGRATED is not set; no events or z-wave sends expected + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: lock-battery device (no lockCodes) does nothing regardless of SLGA_MIGRATED", + function() + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + -- lock-battery has no lockCodes → if branch is false → no events, no z-wave sends + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua index d8e8ecc1bc..6724094462 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua @@ -1,23 +1,16 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +test.disable_startup_messages() local KEYWE_MANUFACTURER_ID = 0x037B local KEYWE_PRODUCT_TYPE = 0x0002 @@ -34,22 +27,41 @@ local zwave_lock_endpoints = { local mock_device = test.mock_device.build_test_zwave_device( { - profile = t_utils.get_profile_definition("base-lock.yml"), + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + _provisioning_state = "TYPED", zwave_endpoints = zwave_lock_endpoints, zwave_manufacturer_id = KEYWE_MANUFACTURER_ID, zwave_product_type = KEYWE_PRODUCT_TYPE, - zwave_product_id = KEYWE_PRODUCT_ID + zwave_product_id = KEYWE_PRODUCT_ID, } ) local function test_init() test.mock_device.add_test_device(mock_device) end + test.set_test_init_function(test_init) +local function added() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.zwave:__expect_send( + DoorLock:OperationGet({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + Battery:Get({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) + test.wait_for_events() +end + test.register_coroutine_test( "Door Lock Operation Reports unlocked should be handled", function() + added() test.socket.zwave:__queue_receive({mock_device.id, DoorLock:OperationReport({door_lock_mode = 0x00}) }) @@ -60,6 +72,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Door Lock Operation Reports locked should be handled", function() + added() test.socket.zwave:__queue_receive({mock_device.id, DoorLock:OperationReport({door_lock_mode = 0xFF}) }) @@ -67,36 +80,15 @@ test.register_coroutine_test( end ) -test.register_message_test( +test.register_coroutine_test( "Lock notification reporting should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Notification:Report({notification_type = 6, event = 24}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Notification:Report({notification_type = 6, event = 25}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) - } - } + function() + added() + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({notification_type = 6, event = 24}) } ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({notification_type = 6, event = 25}) } ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}}))) + end ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua new file mode 100644 index 0000000000..a5c7aee7d4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua @@ -0,0 +1,101 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + + +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) + +local KEYWE_MANUFACTURER_ID = 0x037B +local KEYWE_PRODUCT_TYPE = 0x0002 +local KEYWE_PRODUCT_ID = 0x0001 + +-- supported comand classes +local zwave_lock_endpoints = { + { + command_classes = { + {value = DoorLock} + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = KEYWE_MANUFACTURER_ID, + zwave_product_type = KEYWE_PRODUCT_TYPE, + zwave_product_id = KEYWE_PRODUCT_ID + } +) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Door Lock Operation Reports unlocked should be handled", + function() + test.socket.zwave:__queue_receive({mock_device.id, + DoorLock:OperationReport({door_lock_mode = 0x00}) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Door Lock Operation Reports locked should be handled", + function() + test.socket.zwave:__queue_receive({mock_device.id, + DoorLock:OperationReport({door_lock_mode = 0xFF}) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked())) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Lock notification reporting should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Notification:Report({notification_type = 6, event = 24}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Notification:Report({notification_type = 6, event = 25}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua index 9aac02c6b2..d9642851e1 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -68,7 +58,10 @@ test.register_coroutine_test( DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_UNSECURED }) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked())) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -84,6 +77,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) } + }, + { + min_api_version = 17 } ) @@ -100,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -114,7 +113,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(4.2) test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -132,7 +134,31 @@ test.register_coroutine_test( minute_local_time = time.min, second_local_time = time.sec }):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "The driver should respond correctly to a date get", + function () + test.socket.zwave:__queue_receive({ mock_device.id, Time:DateGet({},{ + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = {} + }) + }) + local time = os.date("*t") + test.socket.zwave:__expect_send(Time:DateReport({ + year = time.year, + month = time.month, + day = time.day + }):build_test_tx(mock_device.id)) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua new file mode 100644 index 0000000000..21729e507d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -0,0 +1,127 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) +local t_utils = require "integration_test.utils" +--- @type st.zwave.constants +local constants = require "st.zwave.constants" +local lock_utils = require "lock_utils.constants".DRIVER_STATE + +local SCHLAGE_MANUFACTURER_ID = 0x003B +local SCHLAGE_PRODUCT_TYPE = 0x0002 +local SCHLAGE_PRODUCT_ID = 0x0469 + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION } + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } +) + +local schlage_mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = SCHLAGE_MANUFACTURER_ID, + zwave_product_type = SCHLAGE_PRODUCT_TYPE, + zwave_product_id = SCHLAGE_PRODUCT_ID + } +) + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local function init_code_slot(slot_number, name, device) + local lock_codes = device.persistent_store[constants.LOCK_CODES] + if lock_codes == nil then + lock_codes = {} + device.persistent_store[constants.LOCK_CODES] = lock_codes + end + lock_codes[tostring(slot_number)] = name +end + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(schlage_mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + init_code_slot(1, "Zach", mock_device) + init_code_slot(5, "Steven", mock_device) + -- setup codes + test.socket.zwave:__queue_receive({mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + end +) + +test.register_coroutine_test( + "Migrate new device", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + end +) + +test.register_coroutine_test( + "Schlage-Lock device called 'migrate' command, validate codeLength is being properly set", + function() + init_code_slot(1, "Zach", schlage_mock_device) + init_code_slot(5, "Steven", schlage_mock_device) + -- setup codes + test.socket.zwave:__queue_receive({schlage_mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.zwave:__queue_receive({schlage_mock_device.id, Configuration:Report({ parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, configuration_value = 6 })}) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ schlage_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(schlage_mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua new file mode 100644 index 0000000000..96b192a03d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua @@ -0,0 +1,480 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockCredentials capability commands: +-- addCredential, updateCredential, deleteCredential, deleteAllCredentials + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local access_control_event = Notification.event.access_control + +test.disable_startup_messages() + + +if table_utils.find_all_entries_by == nil then + function table_utils.find_all_entries_by(device, table_name, key, value) + local entries = table_utils.get_state(device, table_name) or {} + local matches = {} + for _, entry in ipairs(entries) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches + end +end + + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + mock_device.get_latest_state = function(_, component_id, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(component_id, capability_id, attribute_name)] + if value == nil then + if capability_id == capabilities.lockCredentials.ID and attribute_name == capabilities.lockCredentials.credentials.NAME then + value = mock_device.persistent_store.persistedCredentials + elseif capability_id == capabilities.lockUsers.ID and attribute_name == capabilities.lockUsers.users.NAME then + value = mock_device.persistent_store.persistedUsers + end + end + if value == nil then return default_value end + return value + end + local original_set_field = mock_device.set_field + mock_device.set_field = function(_, key, value, opts) + if opts and opts.persist then + mock_device.persistent_store[key] = value + else + mock_device.transient_store[key] = value + end + if original_set_field then original_set_field(mock_device, key, value, opts) end + end + mock_device.emit_event = function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end +end + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + mock_device.persistent_store = mock_device.persistent_store or {} + mock_device.transient_store = mock_device.transient_store or {} + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function queue_user_code_added(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(user_identifier), + }) }) +end + +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + local event = capabilities.lockUsers.users(so_far, { visibility = { displayed = false } }) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + end + mock_device.persistent_store.persistedUsers = entries + test.wait_for_events() +end + +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + local event = capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } }) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + end + mock_device.persistent_store.persistedCredentials = entries + test.wait_for_events() +end + +-- ============================================================================ +-- addCredential +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", + function() + -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + -- Expect SetPINCode to be sent to the lock + expect_set_pin_code(1, "1234") + test.wait_for_events() + + -- Lock responds with SUCCESS + queue_user_code_added(1) + + -- Handler adds the credential to the credentials table. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + -- commandResult with userIndex and credentialIndex + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits duplicate when the lock returns DUPLICATE_CODE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + expect_set_pin_code(1, "1234") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE, event_parameter = string.char(1) }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: returns busy when another operation is already in progress", + function() + -- Put the device into busy state by starting an addCredential operation + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + expect_set_pin_code(1, "1234") + test.wait_for_events() + + -- Second addCredential while first is still pending → should get busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: returns failure when cached credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin9" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns failure immediately when the credential does not exist in the table", + function() + -- No credentials seeded — credentialIndex 99 does not exist + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + -- No z-wave message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin2222" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: returns failure when credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure when credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure immediately when the credentialIndex does not exist in the table", + function() + -- No credentials seeded — index 5 is unknown + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 5, "pin" } }, + }) + + -- No z-wave messages should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials: sends ClearAllPINCodes and emits success when the lock returns PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: emits busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- First deleteAllCredentials starts the z-wave flow (device is now busy) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + -- deleteAllCredentials sends individual UserCode:Set AVAILABLE commands + test.wait_for_events() + + -- Second deleteAllCredentials while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua new file mode 100644 index 0000000000..0895ff3215 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua @@ -0,0 +1,514 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for lockUsers and lockCredentials commands with state +-- pre-configured before each test. Two users and two credentials are seeded +-- at the start of every test so tests can focus on the various response states +-- produced by the z-wave response handlers in commands.lua. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local access_control_event = Notification.event.access_control +local json = require "st.json" + + +test.disable_startup_messages() +if table_utils.find_all_entries_by == nil then + function table_utils.find_all_entries_by(device, table_name, key, value) + local entries = table_utils.get_state(device, table_name) or {} + local matches = {} + for _, entry in ipairs(entries) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches + end +end + + +-- ── Shared device ────────────────────────────────────────────────────────── + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- Lightweight mock state so that table_utils functions work on mock_device +-- before the driver has lazily initialised the wrapped device. After the +-- driver processes its first message (wrapped_init), MockDevice.__index +-- delegates every field access to the real driver device, so the overrides +-- below are only ever active during the pre-init seeding phase. +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) + + -- get_state guards with device:supports_capability; always return true here. + rawset(mock_device, "supports_capability", function() return true end) + + -- get_state / get_max_entries use device:get_latest_state to read + -- capability attribute values from the state cache. + rawset(mock_device, "get_latest_state", + function(_, component_id, capability_id, attribute_name, default_value) + local key = mock_state_key(component_id, capability_id, attribute_name) + local value = mock_latest_state[key] + if value == nil then return default_value end + return value + end + ) + + -- add_entry / delete_entry / update_entry all call device:emit_event. + -- Forward to the capability socket so __expect_send checks pass, and + -- keep mock_latest_state in sync so that successive get_state calls + -- within the same seeding loop see the growing list. + rawset(mock_device, "emit_event", function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end) +end + +local function test_init() + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function expect_clear_pin_code(user_identifier) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(mock_device.id) + ) +end + +local function queue_user_code_added(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(user_identifier), + }) }) +end + +local function queue_user_code_deleted(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.SINGLE_USER_CODE_DELETED, + event_parameter = string.char(user_identifier), + }) }) +end + +-- ── Seeding helpers ──────────────────────────────────────────────────────── + +local INITIAL_USERS = { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, +} +local INITIAL_CREDS = { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, +} + +-- Seed a list of entries into a named table, consuming the resulting events. +local function seed_table(attribute_fn, table_name, entries) + local accumulated = {} + for _, entry in ipairs(entries) do + table.insert(accumulated, entry) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + attribute_fn(accumulated, { visibility = { displayed = false } })) + ) + assert( + table_utils.add_entry(mock_device, table_name, entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_table: add_entry failed for " .. table_name + ) + end + test.wait_for_events() +end + +-- Pre-configure each test with 2 users and 2 credentials. +local function setup_state() + seed_table(capabilities.lockUsers.users, "users", INITIAL_USERS) + seed_table(capabilities.lockCredentials.credentials, "credentials", INITIAL_CREDS) +end + +-- ============================================================================ +-- addUser — pre-configured device (2 users already present) +-- ============================================================================ + +test.register_coroutine_test( + "addUser (pre-configured): assigns the next available index (3) when two users already exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser — pre-configured device +-- ============================================================================ + +test.register_coroutine_test( + "updateUser (pre-configured): updates Alice's name and emits success with userIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceRenamed", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceRenamed", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser (pre-configured): returns failure for a userIndex that does not exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 10, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 10, userName = "Ghost", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 10 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser with associated credential — exercises clear_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser (pre-configured, PASS): removes both user and credential and emits success for each", + function() + setup_state() + + -- Delete user 1 who has credential 1 → driver injects deleteCredential → ClearPINCode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + expect_clear_pin_code(1) + test.wait_for_events() + + queue_user_code_deleted(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- addCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "addCredential (pre-configured): succeeds for a new slot and emits userIndex + credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03", "Guest 3" } }, + }) + + expect_set_pin_code(3, "pin03") + test.wait_for_events() + + queue_user_code_added(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits duplicate when the lock rejects with DUPLICATE_CODE", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin01" } }, + }) + + expect_set_pin_code(3, "pin01") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE, event_parameter = string.char(1) }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits failure when the lock returns available", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + expect_set_pin_code(3, "pin03") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, UserCode:Report({ user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential (pre-configured): succeeds and emits success with userIndex and credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin1" } }, + }) + + expect_set_pin_code(1, "newPin1") + test.wait_for_events() + + queue_user_code_added(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential (pre-configured): returns failure immediately for a non-existent credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential standalone (lockCredentials.DELETE path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential (pre-configured, PASS): removes credential and emits success with indices", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + expect_clear_pin_code(1) + test.wait_for_events() + + queue_user_code_deleted(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua new file mode 100644 index 0000000000..63f846cf49 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua @@ -0,0 +1,761 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Unit tests for lock_utils/tables.lua +-- Tests directly call table_utils functions and verify both return values +-- and emitted capability events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local json = require "st.json" +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +test.disable_startup_messages() + +-- --------------------------------------------------------------------------- +-- Shared mock device +-- --------------------------------------------------------------------------- + +local zw = require "st.zwave" + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local mock_latest_state = {} +local mock_persistent_fields = {} +local mock_transient_fields = {} + +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_table_utils_device_mocks() + mock_latest_state = {} + mock_persistent_fields = {} + mock_transient_fields = {} + mock_device.supports_capability = function() return true end + mock_device.log = { error = function() end, warn = function() end } + mock_device.get_latest_state = function(_, component_id, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(component_id, capability_id, attribute_name)] + if value == nil then return default_value end + return value + end + mock_device.emit_event = function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end + mock_device.set_field = function(_, key, value, opts) + if opts and opts.persist then + mock_persistent_fields[key] = value + else + mock_transient_fields[key] = value + end + end + mock_device.get_field = function(_, key) + if mock_transient_fields[key] ~= nil then return mock_transient_fields[key] end + return mock_persistent_fields[key] + end + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) +end + + +local function test_init() + test.mock_device.add_test_device(mock_device) + install_table_utils_device_mocks() +end +test.set_test_init_function(test_init) + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- Seed the users table with `entries` and consume the resulting emit_events. +-- After this call the state cache has those entries and the socket is clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + -- Build the expected post-insert table up to this entry. + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for entry userIndex=" .. tostring(entry.userIndex)) + end +end + +-- Seed the credentials table with `entries` and consume the resulting emit_events. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for entry credentialIndex=" .. tostring(entry.credentialIndex)) + end +end + +-- =========================================================================== +-- add_entry +-- =========================================================================== + +test.register_coroutine_test( + "add_entry: adds a new user entry and emits the updated table", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns OCCUPIED when an entry with the same userIndex already exists", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- Attempt to add a different entry with the same userIndex (match_key) + local duplicate = { userIndex = 1, userType = "unrestricted", userName = "Bob" } + local result = table_utils.add_entry(mock_device, "users", duplicate) + assert(result == constants.COMMAND_RESULT.OCCUPIED, + "Expected OCCUPIED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns RESOURCE_EXHAUSTED when table is at max capacity", + function() + local entries = {} + for i = 1, 20 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local overflow = { userIndex = 21, userType = "guest", userName = "Overflow" } + local result = table_utils.add_entry(mock_device, "users", overflow) + assert(result == constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + "Expected RESOURCE_EXHAUSTED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE when a required key is missing", + function() + -- userType is required for users table + local incomplete = { userIndex = 1 } + local result = table_utils.add_entry(mock_device, "users", incomplete) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.add_entry(mock_device, "nonexistent_table", + { userIndex = 1, userType = "guest" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: adds a credential entry and emits updated credentials table", + function() + local entry = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- update_entry +-- =========================================================================== + +test.register_coroutine_test( + "update_entry: updates an existing user entry and emits updated table", + function() + local original = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ original }) + + local expected_after_update = { + { userIndex = 1, userType = "adminMember", userName = "Alice_Updated" } + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_after_update, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 1, + { userName = "Alice_Updated", userType = "adminMember" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.update_entry(mock_device, "users", 99, + { userName = "Ghost" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: only updates the specified fields, leaves others intact", + function() + local entry1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local entry2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ entry1, entry2 }) + + -- Update only userName of entry 2; userType should remain "guest" + local expected = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 2, userType = "guest", userName = "Bob_Renamed" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 2, { userName = "Bob_Renamed" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.update_entry(mock_device, "bad_table", 1, { userName = "X" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_entry +-- =========================================================================== + +test.register_coroutine_test( + "delete_entry: deletes an existing entry and returns COMMAND_RESULT.SUCCESS", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- After deletion the table is empty + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 1) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.delete_entry(mock_device, "users", 99) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: remaining entries are intact after a deletion", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + -- Delete the middle entry; expect e1 and e3 remain + local expected_remaining = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 3, userType = "guest", userName = "Carol" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_remaining, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 2) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_entry(mock_device, "bad_table", 1) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_all_entries +-- =========================================================================== + +test.register_coroutine_test( + "delete_all_entries: emits an empty users table and returns SUCCESS", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: returns SUCCESS even when the table is already empty", + function() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_all_entries(mock_device, "bad_table") + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: emits an empty credentials table and returns SUCCESS", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + seed_credentials({ cred }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "credentials") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- find_entry +-- =========================================================================== + +test.register_coroutine_test( + "find_entry: returns the matching entry when it exists", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry(mock_device, "users", 2) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + assert(result.userName == "Bob", + "Expected userName == 'Bob', got: " .. tostring(result.userName)) + end +) + +test.register_coroutine_test( + "find_entry: returns nil when no entry matches the value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry(mock_device, "users", 99) + assert(result == nil, + "Expected nil for missing entry, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry: finds a credential entry by credentialIndex", + function() + local cred = { userIndex = 1, credentialIndex = 5, credentialType = "pin" } + seed_credentials({ cred }) + + local result = table_utils.find_entry(mock_device, "credentials", 5) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.credentialIndex == 5, + "Expected credentialIndex == 5, got: " .. tostring(result.credentialIndex)) + end +) + +-- =========================================================================== +-- find_entry_by +-- =========================================================================== + +test.register_coroutine_test( + "find_entry_by: returns the entry matching an arbitrary key", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "adminMember", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Bob") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns nil when no entry matches", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Nobody") + assert(result == nil, + "Expected nil for unmatched key/value, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns the first matching entry when multiple entries share the same value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userType", "guest") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 1, + "Expected first match userIndex == 1, got: " .. tostring(result.userIndex)) + end +) + +-- =========================================================================== +-- next_index +-- =========================================================================== + +test.register_coroutine_test( + "next_index: returns 1 when the table is empty", + function() + local result = table_utils.next_index(mock_device, "users") + assert(result == 1, + "Expected 1 for empty table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns the next sequential index after a contiguous range", + function() + local entries = {} + for i = 1, 3 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 4, + "Expected 4 after indices 1-3, got: " .. tostring(result)) + end +) + +-- this should not happen during normal operation. +test.register_coroutine_test( + "next_index: returns the lowest gap when indices are non-contiguous", + function() + -- Insert entries at indices 1 and 3, leaving a gap at 2 + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e3 }) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 2, + "Expected 2 as the lowest gap, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- get_max_entries +-- =========================================================================== + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "users") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 for the credentials table when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "credentials") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- Persistence — mutations write to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: add_entry writes the new users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "users", entry) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 1 and persisted[1].userName == "Alice", + "Persisted user data does not match the added entry") + end +) + +test.register_coroutine_test( + "persist: update_entry writes the updated users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userType = "adminMember", userName = "Alice" } }, + { visibility = { displayed = false } })) + ) + table_utils.update_entry(mock_device, "users", 1, { userType = "adminMember" }) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after update") + assert(persisted[1].userType == "adminMember", + "Persisted user type was not updated, got: " .. tostring(persisted[1].userType)) + end +) + +test.register_coroutine_test( + "persist: delete_entry removes the entry from the persistent store immediately", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ e2 }, { visibility = { displayed = false } })) + ) + table_utils.delete_entry(mock_device, "users", 1) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after delete, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 2, + "Expected remaining user to have userIndex == 2, got: " .. tostring(persisted[1].userIndex)) + end +) + +test.register_coroutine_test( + "persist: delete_all_entries writes an empty table to the persistent store", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + table_utils.delete_all_entries(mock_device, "users") + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 0, + "Expected empty persistent store after delete_all, got: " .. tostring(persisted and #persisted)) + end +) + +test.register_coroutine_test( + "persist: add_entry writes the new credentials table to the persistent store immediately", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "credentials", cred) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedCredentials")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted credential, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].credentialIndex == 1, + "Persisted credential index does not match") + end +) + +-- =========================================================================== +-- Persistence — get_state falls back to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: get_state returns data from the persistent store when capability state cache is absent", + function() + -- The device was loaded with a pre-seeded persistent field (set in test_init + -- below), but no capability event has been emitted for users, so get_latest_state + -- returns nil. get_state must fall back to the persistent store. + local state = table_utils.get_state(mock_device, "users") + assert(type(state) == "table" and #state == 1, + "Expected 1 user from persistent-store fallback, got: " .. tostring(state and #state)) + assert(state[1].userIndex == 1 and state[1].userName == "Alice", + "Fallback data does not match pre-seeded persistent entry") + end, + { + test_init = function() + -- Pre-seed persistent store BEFORE add_test_device so that wrapped_init + -- copies the field into the device's persistent_store on startup. + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +-- =========================================================================== +-- Persistence — restore_from_persistent_store re-emits stored tables +-- =========================================================================== + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits users capability event for stored data", + function() + local user = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ user }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + end, + { + test_init = function() + install_table_utils_device_mocks() + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits credentials capability event for stored data", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + end, + { + test_init = function() + install_table_utils_device_mocks() + mock_device:set_field( + "persistedCredentials", + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store is a no-op when the persistent store is empty", + function() + -- No capability events expected when there is nothing in the persistent store. + table_utils.restore_from_persistent_store(mock_device) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua new file mode 100644 index 0000000000..da39c8c692 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua @@ -0,0 +1,705 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockUsers capability commands: +-- addUser, updateUser, deleteUser, deleteAllUsers + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local json = require "st.json" + + +test.disable_startup_messages() + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- ── helpers ──────────────────────────────────────────────────────────────── + +-- Lightweight mock state so that table_utils functions work on mock_device +-- before the driver has lazily initialised the wrapped device. After the +-- driver processes its first message (wrapped_init), MockDevice.__index +-- delegates every field access to the real driver device, so the overrides +-- below are only ever active during the pre-init seeding phase. +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) + + -- get_state guards with device:supports_capability; always return true here. + rawset(mock_device, "supports_capability", function() return true end) + + -- get_state / get_max_entries use device:get_latest_state to read + -- capability attribute values from the state cache. + rawset(mock_device, "get_latest_state", + function(_, component_id, capability_id, attribute_name, default_value) + local key = mock_state_key(component_id, capability_id, attribute_name) + local value = mock_latest_state[key] + if value == nil then return default_value end + return value + end + ) + + -- add_entry / delete_entry / update_entry all call device:emit_event. + -- Forward to the capability socket so __expect_send checks pass, and + -- keep mock_latest_state in sync so that successive get_state calls + -- within the same seeding loop see the growing list. + rawset(mock_device, "emit_event", function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end) +end + +local function test_init() + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function expect_clear_pin_code(user_identifier) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(mock_device.id) + ) +end + +-- Directly insert users into the device state via table_utils (mirrors test_lock_tables.lua). +-- Consumes the resulting capability events so the socket queue stays clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Directly insert credentials into the device state. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- Set totalUsersSupported (and pinUsersSupported) by injecting the Z-Wave +-- UsersNumberReport that the real users_number_report handler processes. +-- Using the Z-Wave path is essential: it goes through the driver's normal +-- emit_component_event path and populates the driver device's state cache, +-- which is what get_max_entries reads when enforcing the slot limit. +local function set_total_users_supported(n) + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:UsersNumberReport({ supported_users = n }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(n, { state_change = true, visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(n, { state_change = true, visibility = { displayed = false } })) + ) + test.wait_for_events() +end + +-- ============================================================================ +-- addUser +-- ============================================================================ + +test.register_coroutine_test( + "addUser: assigns userIndex 1 for the first user and emits a success commandResult", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Alice", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: assigns the next sequential userIndex when users already exist", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: fills a gap left by a deleted user rather than appending beyond max", + function() + -- Seed indices 1 and 3; index 2 is the expected gap to fill + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: returns resourceExhausted when totalUsersSupported has been reached", + function() + set_total_users_supported(2) + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser +-- ============================================================================ + +test.register_coroutine_test( + "updateUser: updates an existing user and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceUpdated", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceUpdated", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: creates user when userIndex does not exist", + function() + -- empty table — nothing to update + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 99, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 99, userName = "Ghost", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 99 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: can change a user's type as well as name", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Alice", "adminMember" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "adminMember" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser (no associated credential — pure local delete) +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser: removes a user with no credential and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser: returns failure when the target userIndex is not in the users table", + function() + -- empty table + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 99 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (injects deleteAllCredentials → ClearAllPINCodes z-wave flow) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers: sends ClearAllPINCodes and emits success for both users and credentials on PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(0.5, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(8, "oneshot") + test.mock_time.advance_time(0) + expect_clear_pin_code(1) + test.wait_for_events() + test.mock_time.advance_time(0.5) + expect_clear_pin_code(2) + test.wait_for_events() + + -- The legacy subdriver consumes deletion notifications for this mock profile; advance + -- to the driver's cleanup timer, which clears both tables and reports success. + test.mock_time.advance_time(8) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers: emits busy when another lock command is in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- State-consistency: add → delete → re-add (indices 1, 2, 3 lifecycle) +-- ============================================================================ +-- +-- These tests verify that the users and credentials tables stay in sync +-- through a full lifecycle: populate three slots, remove the middle one, +-- then re-add a user and credential into the freed slot. The goal is to +-- confirm there is no stale index state that would cause duplicate entries, +-- wrong slot assignment, or mismatched user↔credential links. + + +test.register_coroutine_test( + "State-consistency: add users 1-3, deleteUser 2 (no credential), re-add user reclaims index 2", + function() + -- Populate three user slots directly (no credentials, so deleteUser will take + -- the no-Z-Wave path and delete the user entry locally). + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + -- Delete user at index 2; no credential is linked so this is a pure local delete. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3}, so it assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "State-consistency: add users+credentials 1-3, deleteUser 2 (with credential), re-add user+credential reclaims index 2 cleanly", + function() + -- Populate three user and credential slots. + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }) + + -- Delete user at index 2. The handler finds a linked credential and injects + -- deleteCredential, which sends ClearPINCode to the lock. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + expect_clear_pin_code(2) + test.wait_for_events() + + -- Lock acknowledges the deletion. + -- The legacy subdriver handles deletion notifications for this mock profile, so clear local + -- state directly to model the intended post-delete state before testing index reuse. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.delete_entry(mock_device, "users", 2) == constants.COMMAND_RESULT.SUCCESS) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.delete_entry(mock_device, "credentials", 2) == constants.COMMAND_RESULT.SUCCESS) + test.wait_for_events() + mock_device:set_field(constants.DRIVER_STATE.BUSY, false, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil, {}) + + -- Re-add a user. next_index sees occupied = {1, 3} and assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a credential for the new user at slot 2. The old credential at + -- credentialIndex 2 was cleanly removed, so add_entry must succeed without + -- returning OCCUPIED or any stale-state error. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "9999" } }, + }) + expect_set_pin_code(2, "9999") + test.wait_for_events() + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.add_entry(mock_device, "credentials", { + userIndex = 2, + credentialIndex = 2, + credentialType = "pin", + credentialName = "Guest 2", + }) == constants.COMMAND_RESULT.SUCCESS) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua similarity index 91% rename from drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua rename to drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua index 7707b8a850..81ba1df2ad 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -59,7 +49,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"}), { visibility = { displayed = false } })) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -102,7 +95,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -116,7 +112,10 @@ test.register_coroutine_test( }) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 failed", { state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -130,7 +129,10 @@ test.register_coroutine_test( }) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged(2 .. " failed", { state_change = true }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -172,7 +174,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua similarity index 92% rename from drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua rename to drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua index b1a5964502..38dabbd9dd 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -85,7 +75,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -102,7 +95,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -118,7 +114,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -147,7 +146,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(4)) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -163,7 +165,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 17 + } ) local expect_reload_all_codes_messages = function() @@ -197,7 +202,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -222,7 +230,10 @@ test.register_coroutine_test( ) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -246,7 +257,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua deleted file mode 100644 index e4a9b50758..0000000000 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua +++ /dev/null @@ -1,246 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - --- Mock out globals -local test = require "integration_test" -local capabilities = require "st.capabilities" -local zw = require "st.zwave" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) ---- @type st.zwave.CommandClass.Battery -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.CommandClass.UserCode -local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) -local t_utils = require "integration_test.utils" -local zw_test_utils = require "integration_test.zwave_test_utils" -local utils = require "st.utils" - -local mock_datastore = require "integration_test.mock_env_datastore" - -local json = require "dkjson" - -local zwave_lock_endpoints = { - { - command_classes = { - { value = zw.BATTERY }, - { value = zw.DOOR_LOCK }, - { value = zw.USER_CODE }, - { value = zw.NOTIFICATION } - } - } -} - -local lockCodes = { - ["1"] = "Zach", - ["2"] = "Steven" -} - -local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints, - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } -) - -local mock_device_no_data = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - data = {} - } -) - -local expect_reload_all_codes_messages = function(dev, lc) - test.socket.capability:__expect_send(dev:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode(lc), { visibility = { displayed = false } }) - )) - test.socket.zwave:__expect_send( UserCode:UsersNumberGet({}):build_test_tx(dev.id) ) - test.socket.capability:__expect_send(dev:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(dev.id) ) -end - -test.register_coroutine_test( - "Device added data lock codes population", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device added without data should function", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - expect_reload_all_codes_messages(mock_device_no_data,{}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - end -) - -test.register_coroutine_test( - "Device init after added shouldn't change the datastores", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device init after added with no data should update the datastores", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - -- This should happen as the data is empty at this point - expect_reload_all_codes_messages(mock_device_no_data, {}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - test.socket.device_lifecycle():__queue_receive(mock_device_no_data:generate_info_changed( - { - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } - )) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", true) - end -) - - -test.register_coroutine_test( - "Device added data lock codes population, should not reload all codes", - function() - test.timer.__create_and_queue_test_time_advance_timer(31, "oneshot") - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.wait_for_events() - test.mock_time.advance_time(35) - -- Nothing should happen - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua similarity index 94% rename from drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua rename to drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua index 52144295b3..66038d0760 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -43,10 +33,10 @@ local zwave_lock_endpoints = { } local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints - } + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } ) local function test_init() @@ -63,28 +53,6 @@ local expect_reload_all_codes_messages = function() test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device.id) ) end -test.register_coroutine_test( - "When the device is added it should be set up and start reading codes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - - expect_reload_all_codes_messages() - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end -) - test.register_coroutine_test( "Door Lock Operation Reports should be handled", function() @@ -92,7 +60,10 @@ test.register_coroutine_test( DoorLock:OperationReport({door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED}) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked())) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -108,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -129,6 +103,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -159,7 +136,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -182,6 +160,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -196,7 +177,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(4.2) test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -212,6 +196,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false } })) } + }, + { + min_api_version = 17 } ) @@ -220,7 +207,10 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -236,6 +226,9 @@ test.register_message_test( direction = "send", message = UserCode:Get({user_identifier = 1}):build_test_tx(mock_device.id) } + }, + { + min_api_version = 17 } ) @@ -249,7 +242,10 @@ test.register_coroutine_test( test.mock_time.advance_time(4.2) test.socket.zwave:__expect_send(UserCode:Get( {user_identifier = 1}):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -266,7 +262,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) local function init_code_slot(slot_number, name, device) @@ -288,7 +287,10 @@ test.register_coroutine_test( )) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -312,7 +314,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zwave:__expect_send(UserCode:Get({user_identifier = 4}):build_test_tx(mock_device.id)) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -334,6 +339,9 @@ test.register_message_test( capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) ) } + }, + { + min_api_version = 17 } ) @@ -362,7 +370,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -384,7 +393,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -424,7 +436,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -446,7 +461,10 @@ test.register_coroutine_test( capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Superb Owl" } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -459,7 +477,7 @@ test.register_coroutine_test( Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, - event_parameter = "" + event_parameter = "\x01" }) } ) @@ -468,7 +486,10 @@ test.register_coroutine_test( capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -496,7 +517,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Complete", { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -728,6 +752,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -750,7 +777,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -769,8 +799,17 @@ test.register_coroutine_test( Battery:Get({}) ) ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:UsersNumberGet({}) + ) + ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua new file mode 100644 index 0000000000..71bf25209d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -0,0 +1,393 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Tests for lock_handlers/zwave_responses.lua +-- Covers: +-- • user_code_report: sync-codes-from-lock flow (advances slot, completes sync) +-- • user_code_report: out-of-band ENABLED code creates user+credential entries +-- • user_code_event_handler (via Notification:Report): NEW_USER_CODE_ADDED out-of-band +-- • user_code_event_handler: multi-byte event_parameter (byte 3 carries user_id) +-- • door_operation_event_handler: KEYPAD_UNLOCK with user info lookup +-- • door_operation_event_handler: KEYPAD_UNLOCK with unknown credential +-- • door_operation_event_handler: AUTO_LOCK_LOCKED_OPERATION → locked/auto +-- • DoorLock:OperationReport DOOR_SECURED / DOOR_UNSECURED → locked / unlocked +-- • UserCode:UsersNumberReport → pinUsersSupported + totalUsersSupported +-- +-- Removed: alarm tests — z-wave alarm decoding is only in the legacy-handlers sub-driver +-- for non-SLGA devices; not applicable to the main SLGA driver path. +-- Removed: max/min PIN code length tests — no z-wave equivalent attribute. +-- Removed: NOT_FULLY_LOCKED lock_state test — no clean z-wave OperationReport equivalent. +-- Removed: NumberOfPINUsersSupported profile-migration tests — that logic lives in the +-- zigbee legacy sub-driver; users_number_report in z-wave only emits events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) +end +test.set_test_init_function(test_init) + +-- ============================================================================ +-- user_code_report: sync codes from lock +-- During sync (CODES_FROM_LOCK busy state), ENABLED slots advance the index +-- without adding table entries; only AVAILABLE slots remove local entries. +-- ============================================================================ + +test.register_coroutine_test( + "user_code_report: sync advances to next code slot when slot is occupied", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- Receive UserCode:Report for slot 1 (occupied) + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + + -- driver requests the next code slot. + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "user_code_report: sync completes when the last slot is reached", + function() + -- get_max_entries defaults to 20 when the attribute is missing + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 20, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 20, userName = "Guest 20", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 20, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 20" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil, + "CODE_INDEX must be nil after sync completes") + assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false, + "BUSY must be false after sync completes") + end +) + +test.register_coroutine_test( + "user_code_report: out-of-band ENABLED code creates user and credential entries", + function() + -- command_in_progress == nil → out-of-band path + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + + -- User entry added + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + -- Credential entry added + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- user_code_event_handler (ACCESS_CONTROL Notification:Report) +-- ============================================================================ + +test.register_coroutine_test( + "user_code_event: NEW_USER_CODE_ADDED out-of-band creates user and credential entries", + function() + -- event_parameter = single byte encodes user_id = 1 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = string.char(1), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "user_code_event: multi-byte event_parameter uses byte 3 to extract user_id", + function() + -- 3-byte event_parameter: bytes are (0, 0, 2) → byte 3 = 2 → user_id = 2 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = string.char(0, 0, 2), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- door_operation_event_handler (ACCESS_CONTROL Notification:Report) +-- ============================================================================ + +test.register_coroutine_test( + "door_operation_event: KEYPAD_UNLOCK_OPERATION includes user info when credential exists", + function() + -- Seed persisted user/credential tables so the driver's lookup succeeds. + mock_device:set_field("persistedUsers", { + { userIndex = 1, userName = "John Doe", userType = "guest" }, + }, { persist = true }) + mock_device:set_field("persistedCredentials", { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }, { persist = true }) + + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, + event_parameter = string.char(1), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 1, + userName = "John Doe", + userType = "guest", + }, + }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "door_operation_event: KEYPAD_UNLOCK_OPERATION with unknown credential sets userIndex from event_parameter", + function() + -- No credential seeded for slot 99 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, + event_parameter = string.char(99), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 99, + }, + }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "door_operation_event: AUTO_LOCK_LOCKED_OPERATION emits locked with auto method", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.AUTO_LOCK_LOCKED_OPERATION, + event_parameter = "", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- DoorLock:OperationReport → lock state +-- Removed: NOT_FULLY_LOCKED — no clean z-wave OperationReport equivalent. +-- ============================================================================ + +test.register_coroutine_test( + "OperationReport: DOOR_SECURED emits locked event", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED }), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "OperationReport: DOOR_UNSECURED emits unlocked event", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_UNSECURED }), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- users_number_report: UserCode:UsersNumberReport +-- ============================================================================ + +test.register_coroutine_test( + "users_number_report: emits pinUsersSupported and totalUsersSupported", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:UsersNumberReport({ supported_users = 20 }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported( + 20, { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported( + 20, { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua new file mode 100644 index 0000000000..42a80c6f66 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + if slga_migrated then + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 then + local subdriver = require("zwave-alarm-v1-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua index 44d978999b..fa9836498b 100644 --- a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -19,9 +8,10 @@ local cc = require "st.zwave.CommandClass" local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) --- @type st.zwave.CommandClass.Battery local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.defaults.lockCodes -local lock_code_defaults = require "st.zwave.defaults.lockCodes" -local json = require "dkjson" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" local METHOD = { KEYPAD = "keypad", @@ -30,15 +20,6 @@ local METHOD = { AUTO = "auto" } ---- Determine whether the passed command is a V1 alarm command ---- ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @return boolean true if the device is smoke co alarm -local function can_handle_v1_alarm(opts, driver, device, cmd, ...) - return opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 -end - --- Default handler for alarm command class reports, these were largely OEM-defined --- --- This converts alarm V1 reports to correct lock events @@ -48,84 +29,66 @@ end --- @param cmd st.zwave.CommandClass.Alarm.Report local function alarm_report_handler(driver, device, cmd) local alarm_type = cmd.args.alarm_type - local event = nil - local lock_codes = lock_code_defaults.get_lock_codes(device) - local code_id = nil - if (cmd.args.alarm_level ~= nil) then - code_id = tostring(cmd.args.alarm_level) - end + local credential_index = cmd.args.alarm_level + local event if (alarm_type == 9 or alarm_type == 17) then event = capabilities.lock.lock.unknown() elseif (alarm_type == 16 or alarm_type == 19) then event = capabilities.lock.lock.unlocked() - if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + if (credential_index ~= nil) then + local credential = tables.find_entry(device, "credentials", credential_index) + local user_id = credential and credential.userIndex or nil + event.data = { userIndex = user_id, method = METHOD.KEYPAD} end elseif (alarm_type == 18) then event = capabilities.lock.lock.locked() - if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + if (credential_index ~= nil) then + local credential = tables.find_entry(device, "credentials", credential_index) + local user_id = credential and credential.userIndex or nil + event.data = { userIndex = user_id, method = METHOD.KEYPAD} end elseif (alarm_type == 21) then event = capabilities.lock.lock.locked() - if (cmd.args.alarm_level == 2) then - event["data"] = {method = METHOD.MANUAL} - else - event["data"] = {method = METHOD.KEYPAD} - end + event.data = {method = (cmd.args.alarm_level == 2) and METHOD.MANUAL or METHOD.KEYPAD} elseif (alarm_type == 22) then event = capabilities.lock.lock.unlocked() - event["data"] = {method = METHOD.MANUAL} + event.data = {method = METHOD.MANUAL} elseif (alarm_type == 23) then event = capabilities.lock.lock.unknown() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 24) then event = capabilities.lock.lock.locked() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 25) then event = capabilities.lock.lock.unlocked() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 26) then event = capabilities.lock.lock.unknown() - event["data"] = {method = METHOD.AUTO} + event.data = {method = METHOD.AUTO} elseif (alarm_type == 27) then event = capabilities.lock.lock.locked() - event["data"] = {method = METHOD.AUTO} + event.data = {method = METHOD.AUTO} elseif (alarm_type == 32) then - -- all user codes deleted - for code_id, _ in pairs(lock_codes) do - lock_code_defaults.code_deleted(device, code_id) - end - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + -- all credentials have been deleted + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") elseif (alarm_type == 33) then - -- user code deleted - if (code_id ~= nil) then - lock_code_defaults.clear_code_state(device, code_id) - if (lock_codes[code_id] ~= nil) then - lock_code_defaults.code_deleted(device, code_id) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) - end - end + -- credential has been deleted + lock_utils.delete_credential_report_helper(device, credential_index) + elseif (alarm_type == 13 or alarm_type == 112) then -- user code changed/set - if (code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - local change_type = lock_code_defaults.get_change_type(device, code_id) - local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) - code_changed_event["data"] = { codeName = code_name} - lock_code_defaults.code_set_event(device, code_id, code_name) - lock_code_defaults.clear_code_state(device, code_id) - device:emit_event(code_changed_event) - end + lock_utils.set_credential_report_helper(device, credential_index) + elseif (alarm_type == 34 or alarm_type == 113) then - -- duplicate lock code - if (code_id ~= nil) then - local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) - lock_code_defaults.clear_code_state(device, code_id) - device:emit_event(code_changed_event) + -- duplicate lock code. Log duplicate error + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + command_in_progress, consts.COMMAND_RESULT.DUPLICATE) + lock_utils.clear_busy_state(device) end + elseif (alarm_type == 130) then -- batteries replaced if (device:is_cc_supported(cc.BATTERY)) then @@ -159,7 +122,7 @@ local zwave_lock = { } }, NAME = "Z-Wave lock alarm V1", - can_handle = can_handle_v1_alarm, + can_handle = require("zwave-alarm-v1-lock.can_handle") } return zwave_lock diff --git a/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua b/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua index 3e973a14f8..519f577c86 100644 --- a/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua +++ b/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua @@ -65,6 +65,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestExterminated()) } + }, + { + min_api_version = 17 } ) @@ -84,6 +87,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.idle()) } + }, + { + min_api_version = 17 } ) @@ -103,6 +109,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.trapArmed()) } + }, + { + min_api_version = 17 } ) @@ -122,6 +131,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.trapArmed()) } + }, + { + min_api_version = 17 } ) @@ -141,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.trapRearmRequired()) } + }, + { + min_api_version = 17 } ) @@ -160,6 +175,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestDetected()) } + }, + { + min_api_version = 17 } ) @@ -179,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestDetected()) } + }, + { + min_api_version = 17 } ) @@ -198,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestExterminated()) } + }, + { + min_api_version = 17 } ) @@ -217,6 +241,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.idle()) } + }, + { + min_api_version = 17 } ) @@ -233,6 +260,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.battery.battery(85)) } + }, + { + min_api_version = 17 } ) @@ -249,6 +279,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -300,7 +333,10 @@ test.register_coroutine_test( ) ) mock_mouse_trap:expect_metadata_update({provisioning_state = "PROVISIONED"}) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/fingerprints.yml b/drivers/SmartThings/zwave-sensor/fingerprints.yml index 0d04f7aff9..0810b20a38 100644 --- a/drivers/SmartThings/zwave-sensor/fingerprints.yml +++ b/drivers/SmartThings/zwave-sensor/fingerprints.yml @@ -446,11 +446,23 @@ zwaveManufacturer: productId: 0x000B deviceProfileName: contact-battery-tamperalert - id: 027A/7000/E001 - deviceLabel: Zooz Open/Closed Sensor + deviceLabel: Open Close XS Sensor manufacturerId: 0x027A productType: 0x7000 productId: 0xE001 - deviceProfileName: contact-battery-tamperalert + deviceProfileName: base-contact + - id: 027A/0201/0006 + deviceLabel: Q Sensor + manufacturerId: 0x027A + productType: 0x0201 + productId: 0x0006 + deviceProfileName: multi-functional-motion + - id: 027A/7000/E004 + deviceLabel: Temperature Humidity XS Sensor + manufacturerId: 0x027A + productType: 0x7000 + productId: 0xE004 + deviceProfileName: humidity-temperature-battery - id: "aeotec/multisensor/7" deviceLabel: Aeotec Multipurpose Sensor manufacturerId: 0x0371 @@ -502,12 +514,12 @@ zwaveManufacturer: productType: 0x4C47 productId: 0x4C44 deviceProfileName: base-water - - id: 027A/7000/E002 - deviceLabel: Zooz Water Leak Sensor + - id: "Zooz/ZSE42" + deviceLabel: Zooz ZSE42 XS Water Leak Sensor manufacturerId: 0x027A productType: 0x7000 productId: 0xE002 - deviceProfileName: base-water + deviceProfileName: zooz-zse42-water - id: 010F/0800 deviceLabel: Fibaro Motion Sensor manufacturerId: 0x010F @@ -542,6 +554,12 @@ zwaveManufacturer: productType: 0x0000 productId: 0x0001 deviceProfileName: base-water + - id: shelly/wave/motion + deviceLabel: Shelly Wave Motion + manufacturerId: 0x0460 + productType: 0x0100 + productId: 0x0082 + deviceProfileName: shelly-wave-motion zwaveGeneric: - id: "GenericSensorAlarm" deviceLabel: Z-Wave Sensor diff --git a/drivers/SmartThings/zwave-sensor/profiles/humidity-temperature-battery.yml b/drivers/SmartThings/zwave-sensor/profiles/humidity-temperature-battery.yml index b08623cd43..914b63d4da 100644 --- a/drivers/SmartThings/zwave-sensor/profiles/humidity-temperature-battery.yml +++ b/drivers/SmartThings/zwave-sensor/profiles/humidity-temperature-battery.yml @@ -4,10 +4,6 @@ components: capabilities: - id: temperatureMeasurement version: 1 - config: - values: - - key: "temperature.value" - range: [-20, 100] - id: relativeHumidityMeasurement version: 1 - id: battery @@ -15,4 +11,55 @@ components: - id: refresh version: 1 categories: - - name: MultiFunctionalSensor + - name: TempHumiditySensor +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + values: + - key: "temperature.value" + range: [-20, 100] + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + values: + - key: "temperature.value" + range: [-20, 100] + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + values: + - key: "temperature.value" + range: [-20, 100] + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + actions: [] diff --git a/drivers/SmartThings/zwave-sensor/profiles/shelly-wave-motion.yml b/drivers/SmartThings/zwave-sensor/profiles/shelly-wave-motion.yml new file mode 100644 index 0000000000..5bbeab8ee5 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/profiles/shelly-wave-motion.yml @@ -0,0 +1,59 @@ +name: shelly-wave-motion +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + config: + values: + - key: "illuminance.value" + range: [0, 10000] + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor +preferences: + - name: "ledOpnClsChangeStat" + title: "P157: open/close change status" + description: "This parameter enables open/close status change by LED indicator." + required: false + preferenceType: enumeration + definition: + options: + 0 : "LED ind.disabled" + 1 : "LED ind.enabled" + default: 0 + - name: "sensitivity" + title: "P158: sensitivity" + description: "Sensitivity" + + required: false + preferenceType: enumeration + definition: + options: + 0 : "low sensitivity" + 1 : "moderate sensitivity" + 2 : "high sensitivity" + default: 0 + - name: "blindTime" + title: "P159: Motion Blind time" + description: "Blind time in seconds after last detected motion" + required: false + preferenceType: integer + definition: + minimum: 2 + maximum : 8 + default: 5 + - name: "motionNotdetRepT" + title: "P160:Motion not detect.rep.time" + description: "Time after last detected motion for device to send motion not detected" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum : 32767 + default: 30 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-sensor/profiles/zooz-zse42-water.yml b/drivers/SmartThings/zwave-sensor/profiles/zooz-zse42-water.yml new file mode 100644 index 0000000000..1a497e7438 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/profiles/zooz-zse42-water.yml @@ -0,0 +1,52 @@ +name: zooz-zse42-water +components: + - id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: LeakSensor +preferences: + #param 1 + - name: "ledIndicator" + title: "LED Indicator" + description: "When enabled the LED indicator will blink continuously when water is detected." + required: false + preferenceType: enumeration + definition: + options: + 1: "Enabled *" + 0: "Disabled" + default: 1 + #param 2 + - name: "leakAlertClearDelay" + title: "Leak Alert Clear Delay" + description: "Default = 0; How long the sensor will wait before sending a 'dry' report to your hub after water is no longer detected." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 3600 + default: 0 + #param 4 + - name: "lowBatteryAlert" + title: "Low Battery Alert" + description: "Battery level threshold for low battery reports." + required: false + preferenceType: enumeration + definition: + options: + 10: "10%" + 15: "15%" + 20: "20% *" + 25: "25%" + 30: "30%" + 40: "40%" + 50: "50%" + default: 20 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/can_handle.lua new file mode 100644 index 0000000000..d9956517dd --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_multisensor(opts, self, device, ...) + local FINGERPRINTS = require("aeotec-multisensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subdriver = require("aeotec-multisensor") + return true, subdriver + end + end + return false +end + +return can_handle_aeotec_multisensor diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/fingerprints.lua new file mode 100644 index 0000000000..9436a85979 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_MULTISENSOR_FINGERPRINTS = { + { manufacturerId = 0x0086, productId = 0x0064 }, -- MultiSensor 6 + { manufacturerId = 0x0371, productId = 0x0018 }, -- MultiSensor 7 +} + +return AEOTEC_MULTISENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua index a7299faca2..947f2fea2b 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,20 +7,6 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local AEOTEC_MULTISENSOR_FINGERPRINTS = { - { manufacturerId = 0x0086, productId = 0x0064 }, -- MultiSensor 6 - { manufacturerId = 0x0371, productId = 0x0018 }, -- MultiSensor 7 -} - -local function can_handle_aeotec_multisensor(opts, self, device, ...) - for _, fingerprint in ipairs(AEOTEC_MULTISENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function notification_report_handler(self, device, cmd) local event if cmd.args.notification_type == Notification.notification_type.POWER_MANAGEMENT then @@ -60,12 +35,10 @@ local aeotec_multisensor = { [Notification.REPORT] = notification_report_handler } }, - sub_drivers = { - require("aeotec-multisensor/multisensor-6"), - require("aeotec-multisensor/multisensor-7") - }, + sub_drivers = require("aeotec-multisensor.sub_drivers"), NAME = "aeotec multisensor", - can_handle = can_handle_aeotec_multisensor + can_handle = require("aeotec-multisensor.can_handle"), + shared_device_thread_enabled = true, } return aeotec_multisensor diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/can_handle.lua new file mode 100644 index 0000000000..d86e9c8b3a --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multisensor_6(opts, self, device, ...) +local MULTISENSOR_6_PRODUCT_ID = 0x0064 + if device.zwave_product_id == MULTISENSOR_6_PRODUCT_ID then + return true, require("aeotec-multisensor.multisensor-6") + end + return false +end +return can_handle_multisensor_6 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua index 1b9d4d6b97..f8d001cdf4 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -19,12 +10,8 @@ local cc = require "st.zwave.CommandClass" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) local WakeUp = (require "st.zwave.CommandClass.WakeUp")({version = 2}) -local MULTISENSOR_6_PRODUCT_ID = 0x0064 local PREFERENCE_NUM = 9 -local function can_handle_multisensor_6(opts, self, device, ...) - return device.zwave_product_id == MULTISENSOR_6_PRODUCT_ID -end local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher @@ -62,7 +49,8 @@ local multisensor_6 = { } }, NAME = "aeotec multisensor 6", - can_handle = can_handle_multisensor_6 + can_handle = require("aeotec-multisensor.multisensor-6.can_handle"), + shared_device_thread_enabled = true, } return multisensor_6 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/can_handle.lua new file mode 100644 index 0000000000..f109d0e31c --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multisensor_7(opts, self, device, ...) + local MULTISENSOR_7_PRODUCT_ID = 0x0018 + if device.zwave_product_id == MULTISENSOR_7_PRODUCT_ID then + return true, require("aeotec-multisensor.multisensor-7") + end + return false +end + +return can_handle_multisensor_7 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua index 2d2bf4e36e..d97d65759c 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -19,13 +10,8 @@ local cc = require "st.zwave.CommandClass" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 2 }) -local MULTISENSOR_7_PRODUCT_ID = 0x0018 local PREFERENCE_NUM = 10 -local function can_handle_multisensor_7(opts, self, device, ...) - return device.zwave_product_id == MULTISENSOR_7_PRODUCT_ID -end - local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher --This is done to help the hub correctly set the checkInterval for migrated devices. @@ -62,7 +48,8 @@ local multisensor_7 = { } }, NAME = "aeotec multisensor 7", - can_handle = can_handle_multisensor_7 + can_handle = require("aeotec-multisensor.multisensor-7.can_handle"), + shared_device_thread_enabled = true, } return multisensor_7 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/sub_drivers.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/sub_drivers.lua new file mode 100644 index 0000000000..396f53fe86 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aeotec-multisensor/multisensor-6"), + lazy_load_if_possible("aeotec-multisensor/multisensor-7"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/can_handle.lua new file mode 100644 index 0000000000..1b87febb74 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zwave_water_temp_humidity_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-water-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subdriver = require("aeotec-water-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_zwave_water_temp_humidity_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/fingerprints.lua new file mode 100644 index 0000000000..423d87754e --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS = { + { manufacturerId = 0x0371, productType = 0x0002, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro EU + { manufacturerId = 0x0371, productType = 0x0102, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro US + { manufacturerId = 0x0371, productType = 0x0202, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro AU + { manufacturerId = 0x0371, productId = 0x0012 } -- Aeotec Water Sensor 7 +} + +return ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua index aa896c5f6e..509e38ac9e 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,26 +9,8 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS = { - { manufacturerId = 0x0371, productType = 0x0002, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro EU - { manufacturerId = 0x0371, productType = 0x0102, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro US - { manufacturerId = 0x0371, productType = 0x0202, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro AU - { manufacturerId = 0x0371, productId = 0x0012 } -- Aeotec Water Sensor 7 -} --- Determine whether the passed device is zwave water temperature humidiry sensor ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_zwave_water_temp_humidity_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end --- Default handler for notification command class reports --- @@ -71,7 +44,8 @@ local zwave_water_temp_humidity_sensor = { }, }, NAME = "zwave water temp humidity sensor", - can_handle = can_handle_zwave_water_temp_humidity_sensor + can_handle = require("aeotec-water-sensor.can_handle"), + shared_device_thread_enabled = true, } return zwave_water_temp_humidity_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-sensor/src/apiv6_bugfix/init.lua deleted file mode 100644 index 317a876420..0000000000 --- a/drivers/SmartThings/zwave-sensor/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,45 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - --- doing refresh would cause incorrect state for device, see comments in wakeup-no-poll -local NORTEK_FP = {mfr = 0x014F, prod = 0x2001, model = 0x0102} -- NorTek open/close sensor -local POPP_THERMOSTAT_FP = {mfr = 0x0002, prod = 0x0115, model = 0xA010} --Popp thermostat -local AEOTEC_MULTISENSOR_6_FP = {mfr = 0x0086, model = 0x0064} --Aeotec multisensor 6 -local AEOTEC_MULTISENSOR_7_FP = {mfr = 0x0371, model = 0x0018} --Aeotec multisensor 7 -local ENERWAVE_MOTION_FP = {mfr = 0x011A} --Enerwave motion sensor -local HOMESEER_MULTI_SENSOR_FP = {mfr = 0x001E, prod = 0x0002, model = 0x0001} -- Homeseer multi sensor HSM100 -local SENSATIVE_STRIP_FP = {mfr = 0x019A, model = 0x000A} -local FPS = {NORTEK_FP, POPP_THERMOSTAT_FP, - AEOTEC_MULTISENSOR_6_FP, AEOTEC_MULTISENSOR_7_FP, - ENERWAVE_MOTION_FP, HOMESEER_MULTI_SENSOR_FP, SENSATIVE_STRIP_FP} - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - if version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION then - - for _, fp in ipairs(FPS) do - if device:id_match(fp.mfr, fp.prod, fp.model) then return false end - end - return true - else - return false - end -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-sensor/src/configurations.lua b/drivers/SmartThings/zwave-sensor/src/configurations.lua index 89c89428fa..0a3c62ead8 100644 --- a/drivers/SmartThings/zwave-sensor/src/configurations.lua +++ b/drivers/SmartThings/zwave-sensor/src/configurations.lua @@ -1,17 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) --- @type st.zwave.CommandClass.Association @@ -345,6 +336,20 @@ local devices = { ASSOCIATION = { {grouping_identifier = 1} } + }, + ZOOZ_ZSE42_WATER_LEAK = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x7000, + product_ids = 0xE002 + }, + BATTERY = { + quantity = 1, + type = "CR2450" + }, + WAKE_UP = { + { seconds = 21600 } --6 hours + } } } local configurations = {} @@ -376,6 +381,11 @@ configurations.initial_configuration = function(driver, device) device:send(WakeUp:IntervalSet({seconds = value.seconds, node_id = _node_id})) end end + local battery = configurations.get_device_battery(device) + if battery ~= nil then + device:emit_event(capabilities.battery.quantity({ value = battery.quantity or 1 })) + device:emit_event(capabilities.battery.type({ value = battery.type or "Unspecified" })) + end end configurations.get_device_configuration = function(zw_device) @@ -426,4 +436,16 @@ configurations.get_device_wake_up = function(zw_device) return nil end +configurations.get_device_battery = function(zw_device) + for _, device in pairs(devices) do + if zw_device:id_match( + device.MATCHING_MATRIX.mfrs, + device.MATCHING_MATRIX.product_types, + device.MATCHING_MATRIX.product_ids) then + return device.BATTERY + end + end + return nil +end + return configurations diff --git a/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/can_handle.lua new file mode 100644 index 0000000000..8ab0bac6bc --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_enerwave_motion_sensor(opts, driver, device, cmd, ...) + local ENERWAVE_MFR = 0x011A + if device.zwave_manufacturer_id == ENERWAVE_MFR then + local subdriver = require("enerwave-motion-sensor") + return true, subdriver + else return false end +end + +return can_handle_enerwave_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua index f0df8d4514..53ef77c4be 100644 --- a/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,12 +10,6 @@ local Association = (require "st.zwave.CommandClass.Association")({version=2}) --- @type st.zwave.CommandClass.WakeUp local WakeUp = (require "st.zwave.CommandClass.WakeUp")({version=1}) -local ENERWAVE_MFR = 0x011A - -local function can_handle_enerwave_motion_sensor(opts, driver, device, cmd, ...) - return device.zwave_manufacturer_id == ENERWAVE_MFR -end - local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher --This is done to help the hub correctly set the checkInterval for migrated devices. @@ -55,7 +39,8 @@ local enerwave_motion_sensor = { doConfigure = do_configure }, NAME = "enerwave_motion_sensor", - can_handle = can_handle_enerwave_motion_sensor + can_handle = require("enerwave-motion-sensor.can_handle"), + shared_device_thread_enabled = true, } return enerwave_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/can_handle.lua new file mode 100644 index 0000000000..c9fd2eafd1 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_everspring_motion_light(opts, driver, device, ...) + local EVERSPRING_MOTION_LIGHT_FINGERPRINT = { mfr = 0x0060, prod = 0x0012, model = 0x0001 } + if device:id_match( + EVERSPRING_MOTION_LIGHT_FINGERPRINT.mfr, + EVERSPRING_MOTION_LIGHT_FINGERPRINT.prod, + EVERSPRING_MOTION_LIGHT_FINGERPRINT.model + ) then + local subdriver = require("everspring-motion-light-sensor") + return true, subdriver + end + return false +end + +return can_handle_everspring_motion_light diff --git a/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua index 386ef0d5b9..8baa3756d6 100644 --- a/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua @@ -1,31 +1,12 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2,strict=true}) local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({version=2}) -local EVERSPRING_MOTION_LIGHT_FINGERPRINT = { mfr = 0x0060, prod = 0x0012, model = 0x0001 } - -local function can_handle_everspring_motion_light(opts, driver, device, ...) - return device:id_match( - EVERSPRING_MOTION_LIGHT_FINGERPRINT.mfr, - EVERSPRING_MOTION_LIGHT_FINGERPRINT.prod, - EVERSPRING_MOTION_LIGHT_FINGERPRINT.model - ) -end - local function device_added(driver, device) device:emit_event(capabilities.motionSensor.motion.inactive()) device:send(SwitchBinary:Get({})) @@ -37,7 +18,7 @@ local everspring_motion_light = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_everspring_motion_light + can_handle = require("everspring-motion-light-sensor.can_handle"), } return everspring_motion_light diff --git a/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/can_handle.lua new file mode 100644 index 0000000000..5596894fbb --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ezmultipli_multipurpose_sensor(opts, driver, device, ...) + local EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS = { manufacturerId = 0x001E, productType = 0x0004, productId = 0x0001 } + if device:id_match(EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.manufacturerId, + EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.productType, + EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.productId) then + local subdriver = require("ezmultipli-multipurpose-sensor") + return true, subdriver + end + return false +end + +return can_handle_ezmultipli_multipurpose_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua index 40a14fd32e..64f34ffd96 100644 --- a/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.utils @@ -28,19 +19,6 @@ local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2}) local CAP_CACHE_KEY = "st.capabilities." .. capabilities.colorControl.ID -local EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x001E, productType = 0x0004, productId = 0x0001 } -} - -local function can_handle_ezmultipli_multipurpose_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function basic_report_handler(driver, device, cmd) local event local value = (cmd.args.target_value ~= nil) and cmd.args.target_value or cmd.args.value @@ -104,7 +82,8 @@ local ezmultipli_multipurpose_sensor = { [capabilities.colorControl.commands.setColor.NAME] = set_color } }, - can_handle = can_handle_ezmultipli_multipurpose_sensor + can_handle = require("ezmultipli-multipurpose-sensor.can_handle"), + shared_device_thread_enabled = true, } -return ezmultipli_multipurpose_sensor \ No newline at end of file +return ezmultipli_multipurpose_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/can_handle.lua new file mode 100644 index 0000000000..6cf9ebba98 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_door_window_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("fibaro-door-window-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.prod, fingerprint.productId) then + local subdriver = require("fibaro-door-window-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_fibaro_door_window_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/can_handle.lua new file mode 100644 index 0000000000..992ea8fd9d --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_door_window_sensor_1(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("fibaro-door-window-sensor.fibaro-door-window-sensor-1.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("fibaro-door-window-sensor.fibaro-door-window-sensor-1") + end + end + return false +end + +return can_handle_fibaro_door_window_sensor_1 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/fingerprints.lua new file mode 100644 index 0000000000..50727133bb --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS = { + { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } +} + +return FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua index 63f4d8d8a0..abe2a8b6dc 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -21,19 +10,6 @@ local SensorAlarm = (require "st.zwave.CommandClass.SensorAlarm")({ version = 1 local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 1 }) local configurationsMap = require "configurations" -local FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS = { - { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } -} - -local function can_handle_fibaro_door_window_sensor_1(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function sensor_alarm_report_handler(driver, device, cmd) if (cmd.args.sensor_state == SensorAlarm.sensor_state.ALARM) then device:emit_event(capabilities.tamperAlert.tamper.detected()) @@ -66,13 +42,18 @@ local function do_configure(driver, device) device:send(Association:Remove({grouping_identifier = 1, node_ids = driver.environment_info.hub_zwave_id})) end +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + local function device_added(driver, device) do_refresh(driver, device) - device:emit_event(capabilities.tamperAlert.tamper.clear()) - device:emit_event(capabilities.contactSensor.contact.open()) + emit_event_if_latest_state_missing(device, "main", capabilities.contactSensor, capabilities.contactSensor.contact.NAME, capabilities.contactSensor.contact.open()) + emit_event_if_latest_state_missing(device, "main", capabilities.tamperAlert, capabilities.tamperAlert.tamper.NAME, capabilities.tamperAlert.tamper.clear()) end - local fibaro_door_window_sensor_1 = { NAME = "fibaro door window sensor 1", lifecycle_handlers = { @@ -87,7 +68,8 @@ local fibaro_door_window_sensor_1 = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh }, - can_handle = can_handle_fibaro_door_window_sensor_1 + can_handle = require("fibaro-door-window-sensor.fibaro-door-window-sensor-1.can_handle"), + shared_device_thread_enabled = true, } return fibaro_door_window_sensor_1 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/can_handle.lua new file mode 100644 index 0000000000..4493496f94 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_door_window_sensor_2(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("fibaro-door-window-sensor.fibaro-door-window-sensor-2.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("fibaro-door-window-sensor.fibaro-door-window-sensor-2") + end + end + return false +end + +return can_handle_fibaro_door_window_sensor_2 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/fingerprints.lua new file mode 100644 index 0000000000..6103c107d1 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe + { manufacturerId = 0x010F, productType = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA + { manufacturerId = 0x010F, productType = 0x0702, productId = 0x3000 } -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ +} + +return FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua index 6290f15a41..a1f8ccee82 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,25 +7,16 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Alarm local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 2 }) -local FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe - { manufacturerId = 0x010F, productType = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA - { manufacturerId = 0x010F, productType = 0x0702, productId = 0x3000 } -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ -} - -local function can_handle_fibaro_door_window_sensor_2(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end +local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) end - return false end local function device_added(self, device) - device:emit_event(capabilities.tamperAlert.tamper.clear()) - device:emit_event(capabilities.contactSensor.contact.open()) - device:emit_event(capabilities.temperatureAlarm.temperatureAlarm.cleared()) + emit_event_if_latest_state_missing(device, "main", capabilities.tamperAlert, capabilities.tamperAlert.tamper.NAME, capabilities.tamperAlert.tamper.clear()) + emit_event_if_latest_state_missing(device, "main", capabilities.contactSensor, capabilities.contactSensor.contact.NAME, capabilities.contactSensor.contact.open()) + emit_event_if_latest_state_missing(device, "main", capabilities.temperatureAlarm, capabilities.temperatureAlarm.temperatureAlarm.NAME, capabilities.temperatureAlarm.temperatureAlarm.cleared()) end local function alarm_report_handler(self, device, cmd) @@ -77,7 +57,8 @@ local fibaro_door_window_sensor_2 = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_fibaro_door_window_sensor_2, + can_handle = require("fibaro-door-window-sensor.fibaro-door-window-sensor-2.can_handle"), + shared_device_thread_enabled = true, } return fibaro_door_window_sensor_2 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fingerprints.lua new file mode 100644 index 0000000000..699df3f623 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fingerprints.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS = { + { manufacturerId = 0x010F, prod = 0x0700, productId = 0x1000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / Europe + { manufacturerId = 0x010F, prod = 0x0700, productId = 0x2000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / NA + { manufacturerId = 0x010F, prod = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe + { manufacturerId = 0x010F, prod = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA + { manufacturerId = 0x010F, prod = 0x0702, productId = 0x3000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ + { manufacturerId = 0x010F, prod = 0x0701, productId = 0x2001 }, -- Fibaro Open/Closed Sensor with temperature (FGK-10X) / NA + { manufacturerId = 0x010F, prod = 0x0701, productId = 0x1001 }, -- Fibaro Open/Closed Sensor + { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } -- Fibaro Open/Closed Sensor +} + +return FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua index 4d809567ee..dc26fcddcd 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" @@ -24,26 +13,6 @@ local preferencesMap = require "preferences" local FIBARO_DOOR_WINDOW_SENSOR_WAKEUP_INTERVAL = 21600 --seconds -local FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x010F, prod = 0x0700, productId = 0x1000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / Europe - { manufacturerId = 0x010F, prod = 0x0700, productId = 0x2000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / NA - { manufacturerId = 0x010F, prod = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe - { manufacturerId = 0x010F, prod = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA - { manufacturerId = 0x010F, prod = 0x0702, productId = 0x3000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ - { manufacturerId = 0x010F, prod = 0x0701, productId = 0x2001 }, -- Fibaro Open/Closed Sensor with temperature (FGK-10X) / NA - { manufacturerId = 0x010F, prod = 0x0701, productId = 0x1001 }, -- Fibaro Open/Closed Sensor - { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } -- Fibaro Open/Closed Sensor -} - -local function can_handle_fibaro_door_window_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.prod, fingerprint.productId) then - return true - end - end - return false -end - local function parameterNumberToParameterName(preferences,parameterNumber) for id, parameter in pairs(preferences) do if parameter.parameter_number == parameterNumber then @@ -153,11 +122,9 @@ local fibaro_door_window_sensor = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - sub_drivers = { - require("fibaro-door-window-sensor/fibaro-door-window-sensor-1"), - require("fibaro-door-window-sensor/fibaro-door-window-sensor-2") - }, - can_handle = can_handle_fibaro_door_window_sensor + sub_drivers = require("fibaro-door-window-sensor.sub_drivers"), + can_handle = require("fibaro-door-window-sensor.can_handle"), + shared_device_thread_enabled = true, } return fibaro_door_window_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/sub_drivers.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/sub_drivers.lua new file mode 100644 index 0000000000..0c4ddd4e43 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("fibaro-door-window-sensor/fibaro-door-window-sensor-1"), + lazy_load_if_possible("fibaro-door-window-sensor/fibaro-door-window-sensor-2"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/can_handle.lua new file mode 100644 index 0000000000..341fbcd6f9 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_flood_sensor(opts, driver, device, ...) + local FIBARO_MFR_ID = 0x010F + local FIBARO_FLOOD_PROD_TYPES = { 0x0000, 0x0B00 } + if device:id_match(FIBARO_MFR_ID, FIBARO_FLOOD_PROD_TYPES, nil) then + local subdriver = require("fibaro-flood-sensor") + return true, subdriver + end + return false +end + +return can_handle_fibaro_flood_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua index 03d28a3453..75fc982511 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -22,19 +13,10 @@ local SensorAlarm = (require "st.zwave.CommandClass.SensorAlarm")({ version = 1 --- @type st.zwave.CommandClass.SensorBinary local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) --- @type st.zwave.CommandClass.SensorMultilevel -local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) local preferences = require "preferences" local configurations = require "configurations" -local FIBARO_MFR_ID = 0x010F -local FIBARO_FLOOD_PROD_TYPES = { 0x0000, 0x0B00 } - -local function can_handle_fibaro_flood_sensor(opts, driver, device, ...) - return device:id_match(FIBARO_MFR_ID, FIBARO_FLOOD_PROD_TYPES, nil) -end - - local function basic_set_handler(self, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value device:emit_event(value == 0xFF and capabilities.waterSensor.water.wet() or capabilities.waterSensor.water.dry()) @@ -70,14 +52,6 @@ local function sensor_binary_report_handler(self, device, cmd) device:emit_event(event) end -local function sensor_multilevel_report_handler(self, device, cmd) - if (cmd.args.sensor_type == SensorMultilevel.sensor_type.TEMPERATURE) then - local scale = 'C' - if (cmd.args.scale == SensorMultilevel.scale.temperature.FAHRENHEIT) then scale = 'F' end - device:emit_event(capabilities.temperatureMeasurement.temperature({value = cmd.args.sensor_value, unit = scale})) - end -end - local function do_configure(driver, device) configurations.initial_configuration(driver, device) -- The flood sensor can be hardwired, so update any preferences @@ -98,14 +72,12 @@ local fibaro_flood_sensor = { [cc.SENSOR_BINARY] = { [SensorBinary.REPORT] = sensor_binary_report_handler }, - [cc.SENSOR_MULTILEVEL] = { - [SensorMultilevel.REPORT] = sensor_multilevel_report_handler - } }, lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_fibaro_flood_sensor + can_handle = require("fibaro-flood-sensor.can_handle"), + shared_device_thread_enabled = true, } return fibaro_flood_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/can_handle.lua new file mode 100644 index 0000000000..b184001935 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_motion_sensor(opts, driver, device, ...) + + local FIBARO_MOTION_MFR = 0x010F + local FIBARO_MOTION_PROD = 0x0800 + if device:id_match(FIBARO_MOTION_MFR, FIBARO_MOTION_PROD) then + local subdriver = require("fibaro-motion-sensor") + return true, subdriver + end + return false +end + +return can_handle_fibaro_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua index 828a8f4c8a..e1aa2ccaf8 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -18,12 +8,6 @@ local cc = require "st.zwave.CommandClass" local SensorAlarm = (require "st.zwave.CommandClass.SensorAlarm")({ version = 1 }) local capabilities = require "st.capabilities" -local FIBARO_MOTION_MFR = 0x010F -local FIBARO_MOTION_PROD = 0x0800 - -local function can_handle_fibaro_motion_sensor(opts, driver, device, ...) - return device:id_match(FIBARO_MOTION_MFR, FIBARO_MOTION_PROD) -end local function sensor_alarm_report(driver, device, cmd) if (cmd.args.sensor_state ~= SensorAlarm.sensor_state.NO_ALARM) then @@ -40,7 +24,8 @@ local fibaro_motion_sensor = { [SensorAlarm.REPORT] = sensor_alarm_report } }, - can_handle = can_handle_fibaro_motion_sensor + can_handle = require("fibaro-motion-sensor.can_handle"), + shared_device_thread_enabled = true, } -return fibaro_motion_sensor \ No newline at end of file +return fibaro_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/firmware-version/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/firmware-version/can_handle.lua new file mode 100644 index 0000000000..3ecdc2baf0 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/firmware-version/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +--This sub_driver will populate the currentVersion (firmware) when the firmwareUpdate capability is enabled +local FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x7000, productId = 0xE002 } -- Zooz ZSE42 Water Sensor +} + +return function(opts, driver, device, ...) + if device:supports_capability_by_id(capabilities.firmwareUpdate.ID) then + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subDriver = require("firmware-version") + return true, subDriver + end + end + end + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/zwave-sensor/src/firmware-version/init.lua b/drivers/SmartThings/zwave-sensor/src/firmware-version/init.lua new file mode 100644 index 0000000000..8fe58a7167 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/firmware-version/init.lua @@ -0,0 +1,66 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Version +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) +--- @type st.zwave.CommandClass.WakeUp +local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) + + +--Runs upstream handlers (ex zwave_handlers) +local function call_parent_handler(handlers, self, device, event, args) + for _, func in ipairs(handlers or {}) do + func(self, device, event, args) + end +end + +--Request version if not populated yet +local function send_version_get(driver, device) + if device:get_latest_state("main", capabilities.firmwareUpdate.ID, capabilities.firmwareUpdate.currentVersion.NAME) == nil then + device:send(Version:Get({})) + end +end + +local function version_report(driver, device, cmd) + local major = cmd.args.application_version + local minor = cmd.args.application_sub_version + local fmtFirmwareVersion = string.format("%d.%02d", major, minor) + device:emit_event(capabilities.firmwareUpdate.currentVersion({ value = fmtFirmwareVersion })) +end + +local function wakeup_notification(driver, device, cmd) + send_version_get(driver, device) + --Call parent WakeUp functions + call_parent_handler(driver.zwave_handlers[cc.WAKE_UP][WakeUp.NOTIFICATION], driver, device, cmd) +end + +local function added_handler(driver, device) + --Call main function + driver.lifecycle_handlers.added(driver, device) + --Extras for this sub_driver + send_version_get(driver, device) +end + +local firmware_version = { + NAME = "firmware_version", + can_handle = require("firmware-version.can_handle"), + + lifecycle_handlers = { + added = added_handler, + }, + zwave_handlers = { + [cc.VERSION] = { + [Version.REPORT] = version_report + }, + [cc.WAKE_UP] = { + [WakeUp.NOTIFICATION] = wakeup_notification + } + }, + shared_device_thread_enabled = true, +} + +return firmware_version diff --git a/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/can_handle.lua new file mode 100644 index 0000000000..e24d7b9cf2 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is glentronics water leak sensor +--- +--- @param driver Driver driver instance +--- @param device Device device isntance +--- @return boolean true if the device proper, else false +local function can_handle_glentronics_water_leak_sensor(opts, driver, device, ...) + local GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS = { manufacturerId = 0x0084, productType = 0x0093, productId = 0x0114 } -- glentronics water leak sensor + if device:id_match( + GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.manufacturerId, + GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.productType, + GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.productId) then + return true, require("glentronics-water-leak-sensor") + end + return false +end + +return can_handle_glentronics_water_leak_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua index 92085a1557..cc6c476658 100644 --- a/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,24 +9,6 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x0084, productType = 0x0093, productId = 0x0114 } -- glentronics water leak sensor -} - ---- Determine whether the passed device is glentronics water leak sensor ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_glentronics_water_leak_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function notification_report_handler(self, device, cmd) local event if cmd.args.notification_type == Notification.notification_type.POWER_MANAGEMENT then @@ -79,7 +52,8 @@ local glentronics_water_leak_sensor = { added = device_added }, NAME = "glentronics water leak sensor", - can_handle = can_handle_glentronics_water_leak_sensor + can_handle = require("glentronics-water-leak-sensor.can_handle"), + shared_device_thread_enabled = true, } return glentronics_water_leak_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/can_handle.lua new file mode 100644 index 0000000000..992c1f7c7f --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is homeseer multi sensor +--- +--- @param driver Driver driver instance +--- @param device Device device instance +--- @return boolean true if the device proper, else false +local function can_handle_homeseer_multi_sensor(opts, driver, device, ...) + local HOMESEER_MULTI_SENSOR_FINGERPRINTS = { manufacturerId = 0x001E, productType = 0x0002, productId = 0x0001 } -- Homeseer multi sensor HSM100 + if device:id_match( + HOMESEER_MULTI_SENSOR_FINGERPRINTS.manufacturerId, + HOMESEER_MULTI_SENSOR_FINGERPRINTS.productType, + HOMESEER_MULTI_SENSOR_FINGERPRINTS.productId) then + return true, require("homeseer-multi-sensor") + end + return false +end + +return can_handle_homeseer_multi_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua index e74b513337..07835ac7d4 100644 --- a/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -22,24 +13,6 @@ local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({version = 5}) local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1}) -local HOMESEER_MULTI_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x001E, productType = 0x0002, productId = 0x0001 }, -- Homeseer multi sensor HSM100 -} - ---- Determine whether the passed device is homeseer multi sensor ---- ---- @param driver Driver driver instance ---- @param device Device device instance ---- @return boolean true if the device proper, else false -local function can_handle_homeseer_multi_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(HOMESEER_MULTI_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function basic_set_handler(self, device, cmd) if cmd.args.value ~= nil then device:emit_event(cmd.args.value == 0xFF and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) @@ -88,7 +61,8 @@ local homeseer_multi_sensor = { init = device_init, }, NAME = "homeseer multi sensor", - can_handle = can_handle_homeseer_multi_sensor + can_handle = require("homeseer-multi-sensor.can_handle"), + shared_device_thread_enabled = true, } return homeseer_multi_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/init.lua b/drivers/SmartThings/zwave-sensor/src/init.lua index 5f21df812c..9ab4d0077d 100644 --- a/drivers/SmartThings/zwave-sensor/src/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -121,26 +110,7 @@ local driver_template = { capabilities.powerMeter, capabilities.smokeDetector }, - sub_drivers = { - require("zooz-4-in-1-sensor"), - require("vision-motion-detector"), - require("fibaro-flood-sensor"), - require("aeotec-water-sensor"), - require("glentronics-water-leak-sensor"), - require("homeseer-multi-sensor"), - require("fibaro-door-window-sensor"), - require("sensative-strip"), - require("enerwave-motion-sensor"), - require("aeotec-multisensor"), - require("zwave-water-leak-sensor"), - require("everspring-motion-light-sensor"), - require("ezmultipli-multipurpose-sensor"), - require("fibaro-motion-sensor"), - require("v1-contact-event"), - require("timed-tamper-clear"), - require("wakeup-no-poll"), - require("apiv6_bugfix") - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { added = added_handler, init = device_init, @@ -155,9 +125,12 @@ local driver_template = { [WakeUp.NOTIFICATION] = wakeup_notification } }, + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) +defaults.register_for_default_handlers(driver_template, + driver_template.supported_capabilities, + {native_capability_attrs_enabled = true}) --- @type st.zwave.Driver local sensor = ZwaveDriver("zwave_sensor", driver_template) sensor:run() diff --git a/drivers/SmartThings/zwave-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-sensor/src/preferences.lua b/drivers/SmartThings/zwave-sensor/src/preferences.lua index a83f49eeb8..70293b10fa 100644 --- a/drivers/SmartThings/zwave-sensor/src/preferences.lua +++ b/drivers/SmartThings/zwave-sensor/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) @@ -148,7 +138,33 @@ local devices = { ledLowBrightness = {parameter_number = 82, size = 2}, ledHighBrightness = {parameter_number = 83, size = 2} } - } + }, + SHELLY_WAVE_MOTION_SENSOR = { + MATCHING_MATRIX = { + mfrs = 0x0460, + product_types = 0x0100, + product_ids = {0x0082} + }, + PARAMETERS = { + ledOpnClsChangeStat = {parameter_number = 157, size = 1}, + sensitivity = {parameter_number = 158, size = 1}, + blindTime = {parameter_number = 159, size = 2}, + motionNotdetRepT = {parameter_number = 160, size = 2}, + }, + }, + ZOOZ_ZSE42_WATER_LEAK = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x7000, + product_ids = 0xE002 + }, + PARAMETERS = { + ledIndicator = { parameter_number = 1, size = 1 }, + leakAlertClearDelay = { parameter_number = 2, size = 4 }, + batteryThreshold = { parameter_number = 3, size = 1 }, + lowBatteryAlert = { parameter_number = 4, size = 1 }, + }, + }, } local preferences = {} @@ -156,7 +172,7 @@ preferences.update_preferences = function(driver, device, args) local prefs = preferences.get_device_parameters(device) if prefs ~= nil then for id, value in pairs(device.preferences) do - if not (args and args.old_st_store) or (args.old_st_store.preferences[id] ~= value and prefs and prefs[id]) then + if not (args and args.old_st_store and args.old_st_store.preferences) or (args.old_st_store.preferences[id] ~= value and prefs and prefs[id]) then local new_parameter_value = preferences.to_numeric_value(device.preferences[id]) device:send(Configuration:Set({parameter_number = prefs[id].parameter_number, size = prefs[id].size, configuration_value = new_parameter_value})) end diff --git a/drivers/SmartThings/zwave-sensor/src/sensative-strip/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/sensative-strip/can_handle.lua new file mode 100644 index 0000000000..9b515bae9f --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/sensative-strip/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_sensative_strip(opts, driver, device, cmd, ...) + local SENSATIVE_MFR = 0x019A + local SENSATIVE_MODEL = 0x000A + if device:id_match(SENSATIVE_MFR, nil, SENSATIVE_MODEL) then + local subdriver = require("sensative-strip") + return true, subdriver + end + return false +end + +return can_handle_sensative_strip diff --git a/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua b/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua index b48bd39f6c..89085c00c9 100644 --- a/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -19,17 +9,11 @@ local Configuration = (require "st.zwave.CommandClass.Configuration")({ version --- @type st.zwave.CommandClass.WakeUp local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) -local SENSATIVE_MFR = 0x019A -local SENSATIVE_MODEL = 0x000A local LEAKAGE_ALARM_PARAM = 12 local LEAKAGE_ALARM_OFF = 0 local SENSATIVE_COMFORT_PROFILE = "illuminance-temperature" local CONFIG_REPORT_RECEIVED = "configReportReceived" -local function can_handle_sensative_strip(opts, driver, device, cmd, ...) - return device:id_match(SENSATIVE_MFR, nil, SENSATIVE_MODEL) -end - local function configuration_report(driver, device, cmd) local parameter_number = cmd.args.parameter_number local configuration_value = cmd.args.configuration_value @@ -72,7 +56,8 @@ local sensative_strip = { doConfigure = do_configure }, NAME = "sensative_strip", - can_handle = can_handle_sensative_strip + can_handle = require("sensative-strip.can_handle"), + shared_device_thread_enabled = true, } return sensative_strip diff --git a/drivers/SmartThings/zwave-sensor/src/sub_drivers.lua b/drivers/SmartThings/zwave-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..9500731597 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/sub_drivers.lua @@ -0,0 +1,25 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require("lazy_load_subdriver") + +return { + lazy_load_if_possible("zooz-4-in-1-sensor"), + lazy_load_if_possible("vision-motion-detector"), + lazy_load_if_possible("fibaro-flood-sensor"), + lazy_load_if_possible("aeotec-water-sensor"), + lazy_load_if_possible("glentronics-water-leak-sensor"), + lazy_load_if_possible("homeseer-multi-sensor"), + lazy_load_if_possible("fibaro-door-window-sensor"), + lazy_load_if_possible("sensative-strip"), + lazy_load_if_possible("enerwave-motion-sensor"), + lazy_load_if_possible("aeotec-multisensor"), + lazy_load_if_possible("zwave-water-leak-sensor"), + lazy_load_if_possible("everspring-motion-light-sensor"), + lazy_load_if_possible("ezmultipli-multipurpose-sensor"), + lazy_load_if_possible("fibaro-motion-sensor"), + lazy_load_if_possible("v1-contact-event"), + lazy_load_if_possible("timed-tamper-clear"), + lazy_load_if_possible("wakeup-no-poll"), + lazy_load_if_possible("firmware-version"), +} diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua index 2d83fd6e25..472932fdfb 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua @@ -1,21 +1,13 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" local zw_test_utils = require "integration_test.zwave_test_utils" +local capabilities = require "st.capabilities" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) @@ -89,7 +81,35 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Notification HOME_SECURITY MOTION_DETECTION should be handled as motion active", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_sensor.id, + zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.HOME_SECURITY, + event = Notification.event.home_security.MOTION_DETECTION + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua index 02c2adf751..323c0d4807 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -116,7 +106,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ULTRAVIOLET}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -164,7 +157,10 @@ test.register_coroutine_test( )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configuration value should be updated and device refreshed, when wakeup notification received", @@ -233,7 +229,10 @@ test.register_coroutine_test( Configuration:Get({parameter_number = 9}) )) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -252,6 +251,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.ultravioletIndex.ultravioletIndex({value = 10})) } + }, + { + min_api_version = 17 } ) @@ -284,6 +286,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.dc()) } + }, + { + min_api_version = 17 } ) @@ -316,6 +321,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 17 } ) @@ -335,6 +343,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -356,7 +367,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -389,7 +403,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -410,7 +427,35 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Notification HOME_SECURITY MOTION_DETECTION should be handled as motion active", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_sensor.id, + zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.HOME_SECURITY, + event = Notification.event.home_security.MOTION_DETECTION + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua index 7fd57e42b2..13442ec635 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -118,7 +108,10 @@ test.register_coroutine_test( )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -164,7 +157,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ULTRAVIOLET}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Configuration value should be updated and device refreshed, when wakeup notification received", @@ -231,7 +227,10 @@ test.register_coroutine_test( mock_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ULTRAVIOLET}) )) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -250,6 +249,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.ultravioletIndex.ultravioletIndex({value = 10})) } + }, + { + min_api_version = 17 } ) @@ -282,6 +284,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.dc()) } + }, + { + min_api_version = 17 } ) @@ -314,6 +319,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua index 3b73ff17c3..b6ede32bd5 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -106,7 +96,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua index 8af011f51f..e38ce96f37 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -86,7 +76,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 4, node_ids = {}}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -102,6 +95,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -118,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -137,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -156,6 +158,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -175,6 +180,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -194,6 +202,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -210,6 +221,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -229,6 +243,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -248,6 +265,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -267,6 +287,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -286,6 +309,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua index a5f44ff12e..0f3dc972de 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -60,6 +50,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -79,6 +72,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -98,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -117,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -136,6 +138,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -161,7 +166,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua index a90655669d..3559656e3a 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -61,7 +51,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 1, node_ids = {}}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -81,7 +74,10 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -100,7 +96,10 @@ test.register_coroutine_test( WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua index 45e0672c11..a33f9b97ec 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -59,7 +49,10 @@ test.register_coroutine_test( Battery:Get({}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -93,7 +86,10 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua index 1cb2735a31..8479a5f74f 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -66,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -110,7 +106,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -129,6 +128,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -149,6 +151,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -196,7 +201,10 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua index 097016fc81..3c288c0be4 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -58,7 +48,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 8, size = 1, configuration_value = 5}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua index 04103db0db..ab124e8f80 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -62,7 +52,10 @@ test.register_coroutine_test( Battery:Get({}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua index 17a627a736..0a31f97de5 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -75,7 +65,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua index 74b6d6196c..fb2138c0d5 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -60,6 +50,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -121,7 +117,11 @@ do mock_device, SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) )) - end + end, + { + min_api_version = 17 + } + ) end @@ -157,7 +157,11 @@ do mock_device, SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) )) - end + end, + { + min_api_version = 17 + } + ) end @@ -287,7 +291,10 @@ test.register_coroutine_test( ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua index 248c22ab60..05e306b6c7 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -82,7 +72,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -104,7 +95,10 @@ test.register_coroutine_test( SensorBinary:Get({}) )) mock_fibaro_door_window_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -120,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -139,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -158,6 +158,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -178,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -197,6 +203,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua index bd8caa1e66..6707dd7ae0 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -58,6 +48,7 @@ test.set_test_init_function(test_init) test.register_message_test( "Device should be polled with refresh right after inclusion", { + -- The initial tamperAlert and contactSensor event should be send during the device's first time onboarding { channel = "device_lifecycle", direction = "receive", @@ -96,10 +87,41 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + -- Avoid sending the initial tamperAlert and contactSensor event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_fibaro_door_window_sensor1.id, "added" } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_fibaro_door_window_sensor1, + Battery:Get({}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_fibaro_door_window_sensor1, + SensorBinary:Get({}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_fibaro_door_window_sensor1, + SensorAlarm:Get({}) + ) } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -186,7 +208,10 @@ test.register_coroutine_test( Association:Remove({grouping_identifier = 1, node_ids = {}}) )) mock_fibaro_door_window_sensor1:expect_metadata_update({provisioning_state = "PROVISIONED"}) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -202,6 +227,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -221,6 +249,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -240,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -255,7 +289,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -271,7 +316,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.contactSensor.contact.closed()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -291,6 +347,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -309,6 +368,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -328,7 +390,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -347,7 +420,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 70.7, unit = 'F' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua index fccd88ad97..8fd23b208b 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -105,6 +101,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -124,6 +123,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -143,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 17 } ) @@ -162,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -181,6 +189,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 17 } ) @@ -295,7 +306,10 @@ test.register_coroutine_test( mock_fibaro_door_window_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -333,12 +347,16 @@ test.register_coroutine_test( mock_fibaro_door_window_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) )) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( "device_added should be handled", { + -- The initial tamperAlert, contactSensor & temperatureAlarm event should be send during the device's first time onboarding { channel = "device_lifecycle", direction = "receive", @@ -358,7 +376,16 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + }, + -- Avoid sending the initial tamperAlert, contactSensor & temperatureAlarm event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + { + channel = "device_lifecycle", + direction = "receive", + message = {mock_fibaro_door_window_sensor.id, "added"} } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua index 89efce6c2b..4d3e6e0660 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -94,7 +84,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -120,7 +111,10 @@ test.register_coroutine_test( SensorBinary:Get({}) )) mock_fibaro_door_window_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -136,6 +130,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -155,6 +152,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -174,6 +174,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -194,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -213,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -232,7 +241,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fibaro_door_window_sensor.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -251,7 +271,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 70.7, unit = 'F' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_fibaro_door_window_sensor.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -261,9 +292,20 @@ test.register_coroutine_test( test.socket.zwave:__set_channel_ordering("relaxed") test.socket.device_lifecycle():__queue_receive({mock_fibaro_door_window_sensor.id, "init"}) test.timer.__create_and_queue_test_time_advance_timer(11, "oneshot") - local _preferences = {} - _preferences.alarmStatus = 1 -- "Door/window opened" - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) + local _preference_updates = { --all preferences changed from defaults + alarmStatus=1, + visualLedIndications=4, + delayOfTamperAlarmCancel=6, + highTempThreshold=600, + intervalOfTempReports=1, + lowTempThreshold=30, + reportTamperAlarmCancel=false, + tempMeasurementInterval=320, + tempReportsThreshold=11, + temperatureAlarmReports=1, + temperatureOffset=2, + } + test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preference_updates })) test.wait_for_events() test.socket.zwave:__queue_receive( { @@ -298,45 +340,12 @@ test.register_coroutine_test( }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 2 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 2, configuration_value = 1 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.visualLedIndications = 4 --"Indication of device tampering" - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, @@ -347,447 +356,150 @@ test.register_coroutine_test( }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 3 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 3, configuration_value = 4 }) - }) - - test.mock_time.advance_time(1) - _preferences = {} - _preferences.delayOfTamperAlarmCancel = 5 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 30, - configuration_value = 5, + configuration_value = 6, size = 2 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 30 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 30, configuration_value = 5 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.reportTamperAlarmCancel = 1 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 31, - configuration_value = 1, + configuration_value = 0, size = 1 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 31 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 31, configuration_value = 1 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.tempMeasurementInterval = 300 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 50, - configuration_value = 300, + configuration_value = 320, size = 2 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 50 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 50, configuration_value = 300 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.tempReportsThreshold = 10 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 51, - configuration_value = 10, + configuration_value = 11, size = 2 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 51 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 51, configuration_value = 10 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.intervalOfTempReports = 0 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 52, - configuration_value = 0, + configuration_value = 1, size = 2 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 52 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 52, configuration_value = 0 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.temperatureOffset = 0 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 53, - configuration_value = 0, + configuration_value = 2, size = 4 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 53 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 53, configuration_value = 0 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.temperatureAlarmReports = 0 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 54, - configuration_value = 0, + configuration_value = 1, size = 1 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 54 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 54, configuration_value = 0 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.highTempThreshold = 540 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 55, - configuration_value = 540, + configuration_value = 600, size = 2 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Get({ parameter_number = 55 }) ) ) - - test.wait_for_events() - - test.socket.zwave:__queue_receive({ - mock_fibaro_door_window_sensor.id, - Configuration:Report({ parameter_number = 55, configuration_value = 540 }) - }) - - test.mock_time.advance_time(1) - - _preferences = {} - _preferences.lowTempThreshold = 40 - test.socket.device_lifecycle():__queue_receive(mock_fibaro_door_window_sensor:generate_info_changed({ preferences = _preferences })) - test.wait_for_events() - test.socket.zwave:__queue_receive( - { - mock_fibaro_door_window_sensor.id, - WakeUp:Notification({}) - } - ) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - Battery:Get({}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) - )) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_fibaro_door_window_sensor, - SensorBinary:Get({}) - )) test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, Configuration:Set({ parameter_number = 56, - configuration_value = 40, + configuration_value = 30, size = 2 }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_door_window_sensor, @@ -797,11 +509,58 @@ test.register_coroutine_test( test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 2, configuration_value = 1 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 3, configuration_value = 4 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 31, configuration_value = 1 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 30, configuration_value = 5 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 31, configuration_value = 1 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 50, configuration_value = 300 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 51, configuration_value = 10 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 52, configuration_value = 0 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 53, configuration_value = 0 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 54, configuration_value = 0 }) + }) + test.socket.zwave:__queue_receive({ + mock_fibaro_door_window_sensor.id, + Configuration:Report({ parameter_number = 55, configuration_value = 540 }) + }) test.socket.zwave:__queue_receive({ mock_fibaro_door_window_sensor.id, Configuration:Report({ parameter_number = 56, configuration_value = 40 }) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua index 909beb6b1d..0ac7e57418 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -20,6 +10,8 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) local SensorAlarm = (require "st.zwave.CommandClass.SensorAlarm")({ version = 1 }) local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) local t_utils = require "integration_test.utils" local sensor_endpoints = { @@ -60,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -76,6 +71,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -94,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -112,6 +113,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -130,7 +134,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 25, unit = 'C'})) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_sensor.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -150,6 +165,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -169,6 +187,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -193,7 +214,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -217,7 +241,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -241,7 +268,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -265,8 +295,41 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) +test.register_coroutine_test( + "doConfigure should call initial_configuration and preferences for non-wakeup device", + function () + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_sensor.id, "doConfigure" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_sensor, + Configuration:Set({ parameter_number = 74, configuration_value = 3, size = 1 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_sensor, + Association:Set({ grouping_identifier = 2, node_ids = {} }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_sensor, + Association:Set({ grouping_identifier = 3, node_ids = {} }) + ) + ) + mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua index 45cde29b8f..530d1c03bb 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -90,7 +80,10 @@ test.register_coroutine_test( mock_sensor, Configuration:Set({parameter_number = 77, size = 1, configuration_value = 2}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua index 7cb91b2619..3dbfd89bc7 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -95,7 +85,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE, scale = SensorMultilevel.scale.luminance.LUX}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -111,6 +104,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -126,7 +122,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -146,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -165,6 +175,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) } + }, + { + min_api_version = 17 } ) @@ -184,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) } + }, + { + min_api_version = 17 } ) @@ -203,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) } + }, + { + min_api_version = 17 } ) @@ -223,6 +242,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 17 } ) @@ -242,6 +264,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -261,6 +286,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -276,7 +304,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -296,7 +335,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -315,7 +365,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 70.7, unit = 'F' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua index e514a2f2e5..ac2022e069 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -77,7 +67,10 @@ test.register_coroutine_test( capabilities.threeAxis.threeAxis({value = {200, 200, 400}, unit = 'mG'}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -213,7 +206,10 @@ test.register_coroutine_test( mock_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ACCELERATION_Z_AXIS, scale = SensorMultilevel.scale.acceleration_z_axis.METERS_PER_SQUARE_SECOND}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_firmware_version.lua b/drivers/SmartThings/zwave-sensor/src/test/test_firmware_version.lua new file mode 100644 index 0000000000..bf4c68d5bb --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/test/test_firmware_version.lua @@ -0,0 +1,132 @@ +-- Copyright 2026 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Version +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) +--- @type st.zwave.CommandClass.WakeUp +local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) + +local sensor_endpoints = { + { + command_classes = { + { value = cc.WAKE_UP }, + { value = cc.BATTERY }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("zooz-zse42-water.yml"), + zwave_endpoints = sensor_endpoints, + zwave_manufacturer_id = 0x027A, + zwave_product_type = 0x7000, + zwave_product_id = 0xE002, +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Wakeup notification should not poll binary sensor if device has contact state", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(WakeUp:Notification({ })) } + }, + --This is sent by the sub-driver + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Version:Get({}) + ) + }, + --This is sent by the main driver + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + WakeUp:IntervalGetV1({}) + ) + }, + --This is sent by the main driver + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Battery:Get({ }) + ) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Version:Report should emit firmwareUpdate.currentVersion", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Version:Report({ + application_version = 1, + application_sub_version = 5, + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.firmwareUpdate.currentVersion({ value = "1.05" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "added lifecycle event should emit initial state and request firmware version", + function () + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, Version:Get({})) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua index 783891fbcc..3496ebb6d5 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -71,6 +61,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({ value = 101.3, unit = "kPa" })) } + }, + { + min_api_version = 17 } ) @@ -91,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({ value = 30.13 * KILO_PASCAL_PER_INCH_OF_MERCURY, unit = "kPa" })) } + }, + { + min_api_version = 17 } ) @@ -110,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.bodyWeightMeasurement.bodyWeightMeasurement({ value = 60, unit = "kg" })) } + }, + { + min_api_version = 17 } ) @@ -130,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.bodyWeightMeasurement.bodyWeightMeasurement({ value = 120, unit = "lbs" })) } + }, + { + min_api_version = 17 } ) @@ -150,6 +152,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 700, unit = "lux" })) } + }, + { + min_api_version = 17 } ) @@ -169,11 +174,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 70 })) } + }, + { + min_api_version = 17 } ) test.register_message_test( - "SensorMultilevel report temperature should be handled as temperature", + "SensorMultilevel report temperature (C) should be handled as temperature", { { channel = "zwave", @@ -188,12 +196,23 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 30, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) test.register_message_test( - "SensorMultilevel report temperature should be handled as temperature", + "SensorMultilevel report temperature (F) should be handled as temperature", { { channel = "zwave", @@ -208,7 +227,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 70, unit = "F" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -228,6 +258,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.voltageMeasurement.voltage({ value = 5, unit = "V" })) } + }, + { + min_api_version = 17 } ) @@ -248,6 +281,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.voltageMeasurement.voltage({ value = 0.005, unit = "V" })) } + }, + { + min_api_version = 17 } ) @@ -266,7 +302,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 50, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -286,7 +333,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 50, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -306,6 +364,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 50, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -325,6 +386,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 50, unit = "kVAh" })) } + }, + { + min_api_version = 17 } ) @@ -346,6 +410,9 @@ test.register_message_test( direction = "send", message = zw_test_utils.zwave_test_build_send_command(mock_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) } + }, + { + min_api_version = 17 } ) @@ -362,12 +429,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) }, { channel = "capability", @@ -378,9 +445,19 @@ test.register_message_test( channel = "zwave", direction = "send", message = zw_test_utils.zwave_test_build_send_command(mock_device, Meter:Get({ scale = 2 })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } }, - { inner_block_ordering = "relaxed" } + { + min_api_version = 17 + } ) test.register_message_test( @@ -396,12 +473,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) }, { channel = "capability", @@ -412,9 +489,19 @@ test.register_message_test( channel = "zwave", direction = "send", message = zw_test_utils.zwave_test_build_send_command(mock_device, Meter:Get({ scale = 2 })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } }, - { inner_block_ordering = "relaxed" } + { + min_api_version = 17 + } ) test.register_message_test( @@ -433,6 +520,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -452,6 +542,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -476,6 +569,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -500,6 +596,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -591,6 +690,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -682,6 +784,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) }, + }, + { + min_api_version = 17 } ) @@ -701,6 +806,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -720,6 +828,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -739,6 +850,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) } + }, + { + min_api_version = 17 } ) @@ -758,6 +872,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -777,6 +894,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -796,6 +916,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -815,6 +938,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -834,6 +960,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -853,6 +982,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -872,6 +1004,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -891,6 +1026,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -910,6 +1048,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -929,6 +1070,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -960,7 +1104,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -980,6 +1125,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -999,6 +1147,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -1018,6 +1169,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -1044,7 +1198,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1064,6 +1219,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -1083,6 +1241,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -1102,6 +1263,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -1121,6 +1285,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -1140,6 +1307,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1159,6 +1329,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1178,6 +1351,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1197,6 +1373,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1216,6 +1395,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1235,6 +1417,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1254,6 +1439,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1273,6 +1461,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1292,6 +1483,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1310,6 +1504,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) } + }, + { + min_api_version = 17 } ) @@ -1328,6 +1525,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -1347,7 +1547,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, Battery:Get({}))) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -1366,6 +1569,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -1385,6 +1591,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 17 } ) @@ -1502,10 +1711,19 @@ test.register_message_test( mock_device, Meter:Get({scale = 0}) ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Meter:Get({scale = 4}) + ) } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 19 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua index 747c4cbce1..926558036b 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -78,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 17 } ) @@ -100,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 17 } ) @@ -122,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 17 } ) @@ -144,6 +143,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -166,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 17 } ) @@ -189,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -212,6 +220,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua index f57bfe7950..743ffc5298 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -59,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -75,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -94,6 +90,9 @@ test.register_message_test( WakeUp:IntervalSet({node_id = 0x00, seconds = 1200}) ) } + }, + { + min_api_version = 17 } ) @@ -136,7 +135,10 @@ test.register_coroutine_test( mock_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE}, {dst_channels={2}}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Receiving wakeup notification should generate proper messages", @@ -164,6 +166,9 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua b/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua index 9818f1422c..c88527f0cd 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -109,8 +99,9 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua b/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua index 873909df23..fb9b519a42 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -69,7 +59,10 @@ test.register_coroutine_test( Configuration:Get({ parameter_number = 12 }) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -77,7 +70,10 @@ test.register_coroutine_test( function() test.socket.zwave:__queue_receive({mock_sensor.id, Configuration:Report( { configuration_value = 0x00, parameter_number = 12 } )}) mock_sensor:expect_metadata_update({ profile = "illuminance-temperature" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -96,7 +92,10 @@ test.register_coroutine_test( test.socket.zwave:__queue_receive({mock_sensor.id, Configuration:Report( { configuration_value = 0x00, parameter_number = 12 } )}) mock_sensor:expect_metadata_update({ profile = "illuminance-temperature" }) test.socket.zwave:__queue_receive({mock_sensor.id, WakeUp:Notification({})}) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua index e97c97029b..e80e28dbd9 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,7 +57,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, scale = SensorMultilevel.scale.temperature.CELSIUS }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -105,6 +101,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -124,6 +123,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -143,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -162,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -181,6 +189,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -200,7 +211,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -220,7 +242,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 37, unit = 'F' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -237,6 +270,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -253,6 +289,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -269,6 +308,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua b/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua index 866508e3d6..4cfd906636 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua @@ -1,16 +1,6 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -65,6 +55,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -89,6 +82,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -106,7 +102,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected())) test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua b/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua index e1b82a6b38..3cd16b6fa3 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -66,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -85,6 +78,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -104,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -123,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -160,7 +162,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -182,7 +185,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua index 1bef24c5d6..fde2d5ebfe 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -61,6 +51,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -105,7 +101,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -124,6 +123,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -144,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -162,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({value = 131, unit = "lux"})) } + }, + { + min_api_version = 17 } ) @@ -182,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 80.25, unit = "F"})) } + }, + { + min_api_version = 17 } ) @@ -201,6 +212,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 53 })) } + }, + { + min_api_version = 17 } ) @@ -224,7 +238,29 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_message_test( + "Sensor multilevel luminance report with value=0 uses default lux conversion", + { + { + channel = "zwave", + direction = "receive", + message = { mock_sensor.id, zw_test_utils.zwave_test_build_receive_command(SensorMultilevel:Report({ + sensor_type = SensorMultilevel.sensor_type.LUMINANCE, + sensor_value = 0 })) } + }, + { + channel = "capability", + direction = "send", + message = mock_sensor:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({value = 0, unit = "lux"})) + } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua index 606c8bb429..7a8adbeb10 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -82,7 +75,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -102,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -120,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -139,6 +149,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -158,6 +171,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 17 } ) @@ -178,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -197,6 +216,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -212,7 +234,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -258,7 +291,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -271,7 +305,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, SensorBinary:Get({sensor_type = SensorBinary.sensor_type.MOTION}))) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE, scale = SensorMultilevel.scale.luminance.LUX}))) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua index e52944e9e0..8e463e7625 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -63,6 +53,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -78,7 +71,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -98,6 +102,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -117,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -136,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 17 } ) @@ -155,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -174,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -189,7 +208,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -215,7 +245,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE, scale = SensorMultilevel.scale.luminance.LUX}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua index c3956c3d01..677204e5d1 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -139,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -241,7 +234,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -267,7 +261,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -287,6 +292,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -306,6 +314,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -325,6 +336,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -343,6 +357,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -361,6 +378,9 @@ test.register_message_test( direction = "send", message = mock_motion_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -389,6 +409,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -413,6 +436,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -432,6 +458,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -451,6 +480,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 22 })) } + }, + { + min_api_version = 17 } ) @@ -470,6 +502,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 17 } ) @@ -489,6 +524,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -508,6 +546,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -527,6 +568,9 @@ test.register_message_test( direction = "send", message = mock_contact_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -556,6 +600,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -575,6 +622,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -594,6 +644,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -609,7 +662,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_motion_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_motion_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -625,7 +689,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_water_device:generate_test_message("main", capabilities.waterSensor.water.wet()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_water_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -641,7 +716,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_contact_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_contact_device.id, capability_id = "switch", capability_attr_id = "switch" } + } } + }, + { + min_api_version = 17 } ) @@ -661,6 +747,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.moldHealthConcern.moldHealthConcern.unhealthy()) } + }, + { + min_api_version = 17 } ) @@ -680,6 +769,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.moldHealthConcern.moldHealthConcern.good()) } + }, + { + min_api_version = 17 } ) @@ -700,6 +792,51 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.dewPoint.dewpoint({value = 8, unit = "C"})) } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Basic Set value=0 for contact sensor should emit contact.closed", + { + { + channel = "zwave", + direction = "receive", + message = { mock_contact_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({ + value = 0 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_contact_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Basic Set value=0 for motion sensor should emit motion.inactive", + { + { + channel = "zwave", + direction = "receive", + message = { mock_motion_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({ + value = 0 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_motion_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua index e6caf1f07c..775130d87e 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -65,7 +55,10 @@ test.register_coroutine_test( WakeUp:IntervalSet({ seconds = 14400, node_id = 0}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -97,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -116,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -135,6 +137,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -154,6 +159,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -173,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -189,6 +200,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -208,6 +222,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -227,6 +244,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -246,6 +266,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -265,6 +288,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/can_handle.lua new file mode 100644 index 0000000000..c05cbdcf7d --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/can_handle.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_tamper_event(opts, driver, device, cmd, ...) + local cc = require "st.zwave.CommandClass" + local Notification = (require "st.zwave.CommandClass.Notification")({ version = 4 }) + local FIBARO_DOOR_WINDOW_MFR_ID = 0x010F + + if device.zwave_manufacturer_id ~= FIBARO_DOOR_WINDOW_MFR_ID and + opts.dispatcher_class == "ZwaveDispatcher" and + cmd ~= nil and + cmd.cmd_class ~= nil and + cmd.cmd_class == cc.NOTIFICATION and + cmd.cmd_id == Notification.REPORT and + cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and + (cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_COVER_REMOVED or + cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_MOVED) then + return true, require("timed-tamper-clear") + end + return false +end + +return can_handle_tamper_event diff --git a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua index 0a0db6d905..dd6cc672f4 100644 --- a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua @@ -1,16 +1,7 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -20,19 +11,6 @@ local capabilities = require "st.capabilities" local TAMPER_TIMER = "_tamper_timer" local TAMPER_CLEAR = 10 -local FIBARO_DOOR_WINDOW_MFR_ID = 0x010F - -local function can_handle_tamper_event(opts, driver, device, cmd, ...) - return device.zwave_manufacturer_id ~= FIBARO_DOOR_WINDOW_MFR_ID and - opts.dispatcher_class == "ZwaveDispatcher" and - cmd ~= nil and - cmd.cmd_class ~= nil and - cmd.cmd_class == cc.NOTIFICATION and - cmd.cmd_id == Notification.REPORT and - cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and - (cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_COVER_REMOVED or - cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_MOVED) -end -- This behavior is from zwave-door-window-sensor.groovy. We've seen this behavior -- in Ecolink and several other z-wave sensors that do not send tamper clear events @@ -44,6 +22,13 @@ local function handle_tamper_event(driver, device, cmd) device.thread:cancel_timer(tamper_timer) end device:set_field(TAMPER_TIMER, device.thread:call_with_delay(TAMPER_CLEAR, function() + -- The device can be removed while this timer is still pending. Once a device is + -- deleted its metatable is swapped so that all field/method lookups return nil + -- (see Device:deleted() in st.device), so guard against that here to avoid + -- "attempt to call a nil value (method 'emit_event_for_endpoint')" errors. + if device.id == nil then + return + end device:emit_event_for_endpoint(cmd.src_channel, capabilities.tamperAlert.tamper.clear()) device:set_field(TAMPER_TIMER, nil) end)) @@ -56,7 +41,8 @@ local timed_tamper_clear = { } }, NAME = "timed tamper clear", - can_handle = can_handle_tamper_event + can_handle = require("timed-tamper-clear.can_handle"), + shared_device_thread_enabled = true, } return timed_tamper_clear diff --git a/drivers/SmartThings/zwave-sensor/src/v1-contact-event/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/can_handle.lua new file mode 100644 index 0000000000..492a72dc74 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/can_handle.lua @@ -0,0 +1,22 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_v1_contact_event(opts, driver, device, cmd, ...) + local cc = require "st.zwave.CommandClass" + local Notification = (require "st.zwave.CommandClass.Notification")({ version = 4 }) + + if opts.dispatcher_class == "ZwaveDispatcher" and + cmd ~= nil and + cmd.cmd_class ~= nil and + cmd.cmd_class == cc.NOTIFICATION and + cmd.cmd_id == Notification.REPORT and + cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and + cmd.args.v1_alarm_type == 0x07 then + local subdriver = require("v1-contact-event") + return true, subdriver + else + return false + end +end + +return can_handle_v1_contact_event diff --git a/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua index f8ed0bf0b6..78b3beefcb 100644 --- a/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -18,15 +7,6 @@ local cc = require "st.zwave.CommandClass" local Notification = (require "st.zwave.CommandClass.Notification")({ version = 4 }) local capabilities = require "st.capabilities" -local function can_handle_v1_contact_event(opts, driver, device, cmd, ...) - return opts.dispatcher_class == "ZwaveDispatcher" and - cmd ~= nil and - cmd.cmd_class ~= nil and - cmd.cmd_class == cc.NOTIFICATION and - cmd.cmd_id == Notification.REPORT and - cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and - cmd.args.v1_alarm_type == 0x07 -end -- This behavior is from zwave-door-window-sensor.groovy, where it is -- indicated that certain monoprice sensors had this behavior. Also, @@ -48,7 +28,8 @@ local v1_contact_event = { } }, NAME = "v1 contact event", - can_handle = can_handle_v1_contact_event + can_handle = require("v1-contact-event.can_handle"), + shared_device_thread_enabled = true, } return v1_contact_event diff --git a/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/can_handle.lua new file mode 100644 index 0000000000..d270a7954d --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is zwave-plus-motion-temp-sensor +local function can_handle_vision_motion_detector(opts, driver, device, ...) + local VISION_MOTION_DETECTOR_FINGERPRINTS = { manufacturerId = 0x0109, productType = 0x2002, productId = 0x0205 } -- Vision Motion Detector ZP3102 + if device:id_match( + VISION_MOTION_DETECTOR_FINGERPRINTS.manufacturerId, + VISION_MOTION_DETECTOR_FINGERPRINTS.productType, + VISION_MOTION_DETECTOR_FINGERPRINTS.productId + ) then + return true, require("vision-motion-detector") + end + return false +end + +return can_handle_vision_motion_detector diff --git a/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua index b3be204ca4..f9e8f8a04e 100644 --- a/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -22,24 +13,6 @@ local Configuration = (require "st.zwave.CommandClass.Configuration")({ version --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local VISION_MOTION_DETECTOR_FINGERPRINTS = { - { manufacturerId = 0x0109, productType = 0x2002, productId = 0x0205 } -- Vision Motion Detector ZP3102 -} - ---- Determine whether the passed device is zwave-plus-motion-temp-sensor ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_vision_motion_detector(opts, driver, device, ...) - for _, fingerprint in ipairs(VISION_MOTION_DETECTOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - --- Handler for notification report command class from sensor --- --- @param self st.zwave.Driver @@ -87,7 +60,8 @@ local vision_motion_detector = { doConfigure = do_configure, }, NAME = "Vision motion detector", - can_handle = can_handle_vision_motion_detector + can_handle = require("vision-motion-detector.can_handle"), + shared_device_thread_enabled = true, } return vision_motion_detector diff --git a/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/can_handle.lua new file mode 100644 index 0000000000..15ac66d439 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device, ...) + local fingerprint = {manufacturerId = 0x014F, productType = 0x2001, productId = 0x0102} -- NorTek open/close sensor + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("wakeup-no-poll") + end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua index 877fbb4ac5..42163aeaae 100644 --- a/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua @@ -1,16 +1,7 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -21,12 +12,6 @@ local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({version = 2 --- @type st.zwave.CommandClass.Battery local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) -local fingerprint = {manufacturerId = 0x014F, productType = 0x2001, productId = 0x0102} -- NorTek open/close sensor - -local function can_handle(opts, driver, device, ...) - return device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) -end - -- Nortek open/closed sensors _always_ respond with "open" when polled, and they are polled after wakeup local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher @@ -48,7 +33,8 @@ local wakeup_no_poll = { [WakeUp.NOTIFICATION] = wakeup_notification } }, - can_handle = can_handle + can_handle = require("wakeup-no-poll.can_handle"), + shared_device_thread_enabled = true, } -return wakeup_no_poll \ No newline at end of file +return wakeup_no_poll diff --git a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/can_handle.lua new file mode 100644 index 0000000000..e979e32153 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zooz_4_in_1_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("zooz-4-in-1-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subdriver = require("zooz-4-in-1-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_zooz_4_in_1_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/fingerprints.lua new file mode 100644 index 0000000000..12d853b147 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZOOZ_4_IN_1_FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x2021, productId = 0x2101 }, -- Zooz 4-in-1 sensor + { manufacturerId = 0x0109, productType = 0x2021, productId = 0x2101 }, -- ZP3111US 4-in-1 Motion + { manufacturerId = 0x0060, productType = 0x0001, productId = 0x0004 } -- Everspring Immune Pet PIR Sensor SP815 +} + +return ZOOZ_4_IN_1_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua index 64ba7bd88a..2a4053302b 100644 --- a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -22,25 +13,8 @@ local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ ve --- @type st.utils local utils = require "st.utils" -local ZOOZ_4_IN_1_FINGERPRINTS = { - { manufacturerId = 0x027A, productType = 0x2021, productId = 0x2101 }, -- Zooz 4-in-1 sensor - { manufacturerId = 0x0109, productType = 0x2021, productId = 0x2101 }, -- ZP3111US 4-in-1 Motion - { manufacturerId = 0x0060, productType = 0x0001, productId = 0x0004 } -- Everspring Immune Pet PIR Sensor SP815 -} --- Determine whether the passed device is zooz_4_in_1_sensor ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_zooz_4_in_1_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ZOOZ_4_IN_1_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end --- Handler for notification report command class --- @@ -112,7 +86,8 @@ local zooz_4_in_1_sensor = { } }, NAME = "zooz 4 in 1 sensor", - can_handle = can_handle_zooz_4_in_1_sensor + can_handle = require("zooz-4-in-1-sensor.can_handle"), + shared_device_thread_enabled = true, } return zooz_4_in_1_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/can_handle.lua new file mode 100644 index 0000000000..63da2ae39c --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_water_leak_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-water-leak-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("zwave-water-leak-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_water_leak_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/fingerprints.lua new file mode 100644 index 0000000000..c07bdb52cb --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/fingerprints.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local WATER_LEAK_SENSOR_FINGERPRINTS = { + {mfr = 0x0084, prod = 0x0063, model = 0x010C}, -- SmartThings Water Leak Sensor + {mfr = 0x0084, prod = 0x0053, model = 0x0216}, -- FortrezZ Water Leak Sensor + {mfr = 0x021F, prod = 0x0003, model = 0x0085}, -- Dome Leak Sensor + {mfr = 0x0258, prod = 0x0003, model = 0x0085}, -- NEO Coolcam Water Sensor + {mfr = 0x0258, prod = 0x0003, model = 0x1085}, -- NEO Coolcam Water Sensor + {mfr = 0x0258, prod = 0x0003, model = 0x2085}, -- NEO Coolcam Water Sensor + {mfr = 0x0086, prod = 0x0002, model = 0x007A}, -- Aeotec Water Sensor 6 (EU) + {mfr = 0x0086, prod = 0x0102, model = 0x007A}, -- Aeotec Water Sensor 6 (US) + {mfr = 0x0086, prod = 0x0202, model = 0x007A}, -- Aeotec Water Sensor 6 (AU) + {mfr = 0x000C, prod = 0x0201, model = 0x000A}, -- HomeSeer LS100+ Water Sensor + {mfr = 0x0173, prod = 0x4C47, model = 0x4C44}, -- Leak Gopher Z-Wave Leak Detector + {mfr = 0x027A, prod = 0x7000, model = 0xE002} -- Zooz ZSE42 XS Water Leak Sensor +} + +return WATER_LEAK_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua index 76ee1f1969..6a956a4343 100644 --- a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua @@ -1,46 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) - -local WATER_LEAK_SENSOR_FINGERPRINTS = { - {mfr = 0x0084, prod = 0x0063, model = 0x010C}, -- SmartThings Water Leak Sensor - {mfr = 0x0084, prod = 0x0053, model = 0x0216}, -- FortrezZ Water Leak Sensor - {mfr = 0x021F, prod = 0x0003, model = 0x0085}, -- Dome Leak Sensor - {mfr = 0x0258, prod = 0x0003, model = 0x0085}, -- NEO Coolcam Water Sensor - {mfr = 0x0258, prod = 0x0003, model = 0x1085}, -- NEO Coolcam Water Sensor - {mfr = 0x0258, prod = 0x0003, model = 0x2085}, -- NEO Coolcam Water Sensor - {mfr = 0x0086, prod = 0x0002, model = 0x007A}, -- Aeotec Water Sensor 6 (EU) - {mfr = 0x0086, prod = 0x0102, model = 0x007A}, -- Aeotec Water Sensor 6 (US) - {mfr = 0x0086, prod = 0x0202, model = 0x007A}, -- Aeotec Water Sensor 6 (AU) - {mfr = 0x000C, prod = 0x0201, model = 0x000A}, -- HomeSeer LS100+ Water Sensor - {mfr = 0x0173, prod = 0x4C47, model = 0x4C44}, -- Leak Gopher Z-Wave Leak Detector - {mfr = 0x027A, prod = 0x7000, model = 0xE002} -- Zooz ZSE42 XS Water Leak Sensor -} - -local function can_handle_water_leak_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(WATER_LEAK_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local function basic_set_handler(driver, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value device:emit_event(value == 0xFF and capabilities.waterSensor.water.wet() or capabilities.waterSensor.water.dry()) @@ -53,7 +17,8 @@ local water_leak_sensor = { [Basic.SET] = basic_set_handler } }, - can_handle = can_handle_water_leak_sensor + can_handle = require("zwave-water-leak-sensor.can_handle"), + shared_device_thread_enabled = true, } return water_leak_sensor diff --git a/drivers/SmartThings/zwave-siren/fingerprints.yml b/drivers/SmartThings/zwave-siren/fingerprints.yml index 1e36e4af81..4b25211366 100644 --- a/drivers/SmartThings/zwave-siren/fingerprints.yml +++ b/drivers/SmartThings/zwave-siren/fingerprints.yml @@ -1,10 +1,16 @@ zwaveManufacturer: - - id: "Zooz" + - id: "Zooz/ZSE19" deviceLabel: Zooz Multisiren manufacturerId: 0x027A productType: 0x000C productId: 0x0003 deviceProfileName: multifunctional-siren + - id: "Zooz/ZSE50" + deviceLabel: Zooz ZSE50 Siren and Chime + manufacturerId: 0x027A + productType: 0x0004 + productId: 0x0369 + deviceProfileName: zooz-zse50 - id: "Everspring" deviceLabel: Everspring Siren manufacturerId: 0x0060 diff --git a/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml b/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml new file mode 100644 index 0000000000..866b88f436 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml @@ -0,0 +1,309 @@ +# Zooz ZSE50 Siren/Chime +# With deviceConfig - allows setting a tone from routines +name: zooz-zse50 +components: + - id: main + capabilities: + - id: alarm + version: 1 + - id: chime + version: 1 + - id: mode + version: 1 + - id: powerSource + version: 1 + - id: audioVolume + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren +### PREFERENCES ### +preferences: + #param 1 + - name: "playbackMode" + title: "Playback Mode" + description: "* = Default; Set siren playback mode: once (0), loop for x seconds (1), loop x times (2), loop until cancel (3), no sound (4)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Play once *" + 1: "Play in loop for set duration" + 2: "Play in loop for set number" + 3: "Play in loop until stopped" + 4: "No sound, LED only" + default: 0 + #param 2 + - name: "playbackDuration" + title: "Playback Duration" + description: "Default: 180; Set playback duration for the siren (in seconds) when the siren is in playback mode 1." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 900 + default: 180 + #param 3 + - name: "playbackLoop" + title: "Playback Loop Count" + description: "Default: 1; Set the number of playback loops for the selected tone when the siren is in playback mode 2." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 99 + default: 1 + #param 4 + - name: "playbackTone" + title: "Playback Tone" + description: "Set the default tone for the siren playback. Choose the number of the file in the library as value. Check the 'modes' list for the id numbers" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 50 + default: 1 + #param 5 - playbackVolume ## Handled with volume command + #param 6 + - name: "ledMode" + title: "LED Indicator Mode" + description: "* = Default; Set the LED indicator mode for the siren: off (0), strobe (1), police strobe (2), pulse (3), solid on (4). See documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "LED always off" + 1: "LED strobe single color *" + 2: "LED strobe red and blue" + 3: "LED pulse single color" + 4: "LED solid on single color" + default: 1 + #param 7 + - name: "ledColor" + title: "LED Indicator Color" + description: "Default: 0; Set the LED indicator color: red (0), yellow (42), green (85), indigo (127), blue (170), purple (212), or white (255). More colors available through custom values corresponding to the color wheel. See advanced documentation for details." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 255 + default: 0 + #param 8 + - name: "lowBattery" + title: "Low Battery Report" + description: "Which % level should the device report low battery to the hub." + required: false + preferenceType: enumeration + definition: + options: + 10: "10% [DEFAULT]" + 15: "15%" + 20: "20%" + 25: "25%" + 30: "30%" + 35: "35%" + 40: "40%" + default: 10 + #param 9 + - name: "ledBatteryMode" + title: "LED In Back-Up Battery Mode" + description: "* = Default; Set the LED indicator in back-up battery mode: off (0), regular LED mode (1), pulse white for full battery and red for low battery (2)." + required: false + preferenceType: enumeration + definition: + options: + 0: "LED off" + 1: "Regular LED mode *" + 2: "Pulse white for full, red for low" + default: 1 + #param 10 + - name: "btnToneSelection" + title: "Button Tone Selection" + description: "Disable tone selection from physical buttons on the siren (0). When disabled, you'll only be able to program tones using the advanced parameters in the Z-Wave UI. Expert users only, see documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled [DEFAULT]" + default: 1 + #param 11 + - name: "btnVolSelection" + title: "Button Volume Selection" + description: "Disable volume adjustment from physical buttons on the siren (0). When disabled, you'll only be able to adjust volume using the advanced parameters in the Z-Wave UI. Expert users only, see documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled [DEFAULT]" + default: 1 + #param 13 + - name: "systemVolume" + title: "System Message Volume" + description: "Default: 50; Set system message volume (0-100, 0 – mute)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 50 + #param 14 + - name: "ledBrightness" + title: "LED Indicator Brightness" + description: "Default: 5; Choose the LED indicator's brightness level (0 – off, 10 – high brightness)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 10 + default: 5 + #param 15 + - name: "batteryFrequency" + title: "Battery Reporting Frequency" + description: "Default: 12; Set the reporting interval for battery (1-84 hours)." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 84 + default: 12 + #param 16 + - name: "batteryThreshold" + title: "Battery Reporting Threshold" + description: "Default: 0; Set the threshold for battery reporting in % changes. Set to 0 to disable reporting based on threshold." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 20 + default: 0 + +### DEVICE CONFIG ### +deviceConfig: + dashboard: + states: + - component: main + capability: chime + version: 1 + actions: + - component: main + capability: chime + version: 1 + basicPlus: [ ] + detailView: + - component: main + capability: alarm + version: 1 + values: + - key: alarm.value + enabledValues: + - 'off' + - 'both' + - key: "{{enumCommands}}" + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + - component: main + capability: powerSource + version: 1 + values: + - key: powerSource.value + enabledValues: + - 'battery' + - 'mains' + - component: main + capability: audioVolume + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: alarm + version: 1 + values: + - key: alarm.value + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + command: setMode + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: powerSource + version: 1 + values: + - key: powerSource.value + enabledValues: + - 'battery' + - 'mains' + step: 1 + - component: main + capability: audioVolume + version: 1 + - component: main + capability: battery + version: 1 + actions: + - component: main + capability: alarm + version: 1 + values: + - key: "{{enumCommands}}" + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + command: setMode + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: audioVolume + version: 1 diff --git a/drivers/SmartThings/zwave-siren/src/aeon-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/aeon-siren/can_handle.lua new file mode 100644 index 0000000000..2aae744ccc --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/aeon-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeon_siren(opts, driver, device, ...) + local AEON_MFR = 0x0086 + local AEON_SIREN_PRODUCT_ID = 0x0050 + + if device.zwave_manufacturer_id == AEON_MFR and device.zwave_product_id == AEON_SIREN_PRODUCT_ID then + return true, require("aeon-siren") + end + return false +end + +return can_handle_aeon_siren diff --git a/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua b/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua index f98ba06d83..1c1a2bb0b1 100644 --- a/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua @@ -1,32 +1,16 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) -local AEON_MFR = 0x0086 -local AEON_SIREN_PRODUCT_ID = 0x0050 - local SOUND_TYPE_AND_VOLUME_PARAMETER_NUMBER = 37 local CONFIGURE_SOUND_TYPE = "type" local SOUND_TYPE_DEFAULT = 1 local CONFIGURE_VOLUME = "volume" local VOLUME_DEFAULT = 3 -local function can_handle_aeon_siren(opts, driver, device, ...) - return device.zwave_manufacturer_id == AEON_MFR and device.zwave_product_id == AEON_SIREN_PRODUCT_ID -end local function configure_sound(device, sound_type, volume) if sound_type == nil then sound_type = SOUND_TYPE_DEFAULT end @@ -64,7 +48,7 @@ end local aeon_siren = { NAME = "aeon-siren", - can_handle = can_handle_aeon_siren, + can_handle = require("aeon-siren.can_handle"), lifecycle_handlers = { doConfigure = do_configure, infoChanged = info_changed diff --git a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/can_handle.lua new file mode 100644 index 0000000000..31304e8688 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_doorbell_siren(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-doorbell-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("aeotec-doorbell-siren") + end + end + return false +end + +return can_handle_aeotec_doorbell_siren diff --git a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/fingerprints.lua new file mode 100644 index 0000000000..782dfbc0c9 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_DOORBELL_SIREN_FINGERPRINTS = { + { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A2}, -- Aeotec Doorbell 6 (EU) + { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A2}, -- Aeotec Doorbell 6 (US) + { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A2}, -- Aeotec Doorbell 6 (AU) + { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A4}, -- Aeotec Siren 6 (EU) + { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A4}, -- Aeotec Siren 6 (US) + { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A4}, -- Aeotec Siren 6 (AU) +} + +return AEOTEC_DOORBELL_SIREN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua index bab6909235..716c46186e 100644 --- a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -20,14 +10,6 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({version=1}) local preferencesMap = require "preferences" -local AEOTEC_DOORBELL_SIREN_FINGERPRINTS = { - { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A2}, -- Aeotec Doorbell 6 (EU) - { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A2}, -- Aeotec Doorbell 6 (US) - { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A2}, -- Aeotec Doorbell 6 (AU) - { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A4}, -- Aeotec Siren 6 (EU) - { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A4}, -- Aeotec Siren 6 (US) - { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A4}, -- Aeotec Siren 6 (AU) -} local COMPONENT_NAME = "componentName" local TONE = "tone" @@ -51,15 +33,6 @@ local BUTTON_BATTERY_NORMAL = 99 local DEVICE_PROFILE_CHANGE_IN_PROGRESS = "device_profile_change_in_progress" local NEXT_BUTTON_BATTERY_EVENT_DETAILS = "next_button_battery_event_details" -local function can_handle_aeotec_doorbell_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(AEOTEC_DOORBELL_SIREN_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function querySoundStatus(device) for endpoint = 2, NUMBER_OF_SOUND_COMPONENTS do device:send_to_component(Basic:Get({}), "sound"..endpoint) @@ -234,6 +207,7 @@ local function changeDeviceProfileIfNeeded(device, endpoint) end end +-- Note that endpoint should be a number not a dst_channels table. local function setActiveEndpoint(device, endpoint) if (endpoint) then device:set_field(LAST_TRIGGERED_ENDPOINT, endpoint, {persist = true}) @@ -297,26 +271,28 @@ end local function alarmChimeOnOff(device, command, newValue) if (device and command and newValue) then + -- Note that zwave/device.lua send_to_component expects the component_to_endpoint function to + -- return a dst_channels table, not a single endpoint number local endpoint = component_to_endpoint(device, command.component) - device:send(Basic:Set({value = newValue})):to_endpoint(endpoint) + device:send_to_component(Basic:Set({value = newValue}), command.component) if (newValue == ON) then - setActiveEndpoint(endpoint) + setActiveEndpoint(device, endpoint[1]) end end end -local function alarm_chime_on(device, command) +local function alarm_chime_on(self, device, command) resetActiveEndpoint(device) alarmChimeOnOff(device, command, ON) end -local function alarm_chime_off(device, command) +local function alarm_chime_off(self, device, command) alarmChimeOnOff(device, command, OFF) end local aeotec_doorbell_siren = { NAME = "aeotec-doorbell-siren", - can_handle = can_handle_aeotec_doorbell_siren, + can_handle = require("aeotec-doorbell-siren.can_handle"), lifecycle_handlers = { added = device_added, @@ -332,6 +308,9 @@ local aeotec_doorbell_siren = { [Notification.REPORT] = notification_report_handler } }, + -- This typo is a bug. There are many unit tests that fail, when + -- it is enabled, and it is not clear what the correct functionality is + -- without real device testing. capabilities_handlers = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh diff --git a/drivers/SmartThings/zwave-siren/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-siren/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-siren/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-siren/src/configurations.lua b/drivers/SmartThings/zwave-siren/src/configurations.lua index b17b3cbc0f..5165ee4551 100644 --- a/drivers/SmartThings/zwave-siren/src/configurations.lua +++ b/drivers/SmartThings/zwave-siren/src/configurations.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { YALE_SIREN = { diff --git a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/can_handle.lua new file mode 100644 index 0000000000..5803087913 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ecolink_wireless_siren(opts, driver, device, ...) + local FINGERPRINTS = require("ecolink-wireless-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("ecolink-wireless-siren") + end + end + return false +end + +return can_handle_ecolink_wireless_siren diff --git a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/fingerprints.lua new file mode 100644 index 0000000000..9edaef0374 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ECOLINK_WIRELESS_SIREN_FINGERPRINTS = { + { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000A }, -- Ecolink Siren +} + +return ECOLINK_WIRELESS_SIREN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua index 0413221185..6b5a2a25ad 100644 --- a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,18 +10,7 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) --- @type st.zwave.CommandClass.SwitchBinary local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) -local ECOLINK_WIRELESS_SIREN_FINGERPRINTS = { - { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000A }, -- Ecolink Siren -} -local function can_handle_ecolink_wireless_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(ECOLINK_WIRELESS_SIREN_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function basic_set_handler(driver, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value @@ -103,7 +82,7 @@ local ecolink_wireless_siren = { lifecycle_handlers = { init = device_init }, - can_handle = can_handle_ecolink_wireless_siren, + can_handle = require("ecolink-wireless-siren.can_handle"), } return ecolink_wireless_siren diff --git a/drivers/SmartThings/zwave-siren/src/fortrezz/can_handle.lua b/drivers/SmartThings/zwave-siren/src/fortrezz/can_handle.lua new file mode 100644 index 0000000000..83289ab86a --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/fortrezz/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fortrezz_siren(opts, self, device, ...) + if device.zwave_manufacturer_id == 0x0084 and + device.zwave_product_type == 0x0313 and + device.zwave_product_id == 0x010B then + return true, require("fortrezz") + end + return false +end + +return can_handle_fortrezz_siren diff --git a/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua b/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua index 73a3e6e0d4..8bf31178f6 100644 --- a/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua +++ b/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua @@ -1,26 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) -local function can_handle_fortrezz_siren(opts, self, device, ...) - return device.zwave_manufacturer_id == 0x0084 and - device.zwave_product_type == 0x0313 and - device.zwave_product_id == 0x010B -end local function set_and_get(value) return function (self, device, command) @@ -47,7 +32,7 @@ end local fortrezz_siren = { NAME = "fortrezz-siren", - can_handle = can_handle_fortrezz_siren, + can_handle = require("fortrezz.can_handle"), capability_handlers = { [capabilities.alarm.ID] = { [capabilities.alarm.commands.siren.NAME] = set_and_get(0x42), @@ -67,4 +52,4 @@ local fortrezz_siren = { } } -return fortrezz_siren \ No newline at end of file +return fortrezz_siren diff --git a/drivers/SmartThings/zwave-siren/src/init.lua b/drivers/SmartThings/zwave-siren/src/init.lua index a725e8b79c..94e8b723b9 100644 --- a/drivers/SmartThings/zwave-siren/src/init.lua +++ b/drivers/SmartThings/zwave-siren/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cap_defaults = require "st.capabilities.defaults" @@ -90,26 +80,16 @@ local driver_template = { capabilities.tamperAlert, capabilities.temperatureMeasurement, capabilities.relativeHumidityMeasurement, - capabilities.chime - }, - sub_drivers = { - require("multifunctional-siren"), - require("zwave-sound-sensor"), - require("ecolink-wireless-siren"), - require("philio-sound-siren"), - require("aeotec-doorbell-siren"), - require("aeon-siren"), - require("yale-siren"), - require("zipato-siren"), - require("utilitech-siren"), - require("fortrezz"), - require("apiv6_bugfix"), + capabilities.chime, + capabilities.powerSource }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { infoChanged = info_changed, doConfigure = do_configure, added = added_handler - } + }, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-siren/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-siren/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/can_handle.lua new file mode 100644 index 0000000000..a3c62d02aa --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multifunctional_siren(opts, driver, device, ...) + local FINGERPRINTS = require("multifunctional-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("multifunctional-siren") + end + end + return false +end + +return can_handle_multifunctional_siren diff --git a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/fingerprints.lua new file mode 100644 index 0000000000..d2bcf5402d --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MULTIFUNCTIONAL_SIREN_FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x000C, productId = 0x0003 }, -- Zooz S2 Multisiren ZSE19 + { manufacturerId = 0x0060, productType = 0x000C, productId = 0x0003 } -- Everspring Indoor Voice Siren +} + +return MULTIFUNCTIONAL_SIREN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua index 2c8af3bb26..b1bca996d5 100644 --- a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,24 +10,12 @@ local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) --- @type st.zwave.CommandClass.Battery local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local MULTIFUNCTIONAL_SIREN_FINGERPRINTS = { - { manufacturerId = 0x027A, productType = 0x000C, productId = 0x0003 }, -- Zooz S2 Multisiren ZSE19 - { manufacturerId = 0x0060, productType = 0x000C, productId = 0x0003 } -- Everspring Indoor Voice Siren -} --- Determine whether the passed device is multifunctional siren --- --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_multifunctional_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(MULTIFUNCTIONAL_SIREN_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end --- Default handler for notification command class reports --- @@ -74,7 +52,7 @@ local multifunctional_siren = { doConfigure = do_configure }, NAME = "multifunctional siren", - can_handle = can_handle_multifunctional_siren, + can_handle = require("multifunctional-siren.can_handle"), } return multifunctional_siren diff --git a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/can_handle.lua new file mode 100644 index 0000000000..23e614a2e2 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_philio_sound_siren(opts, driver, device, ...) + local FINGERPRINTS = require("philio-sound-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("philio-sound-siren") + end + end + return false +end + +return can_handle_philio_sound_siren diff --git a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/fingerprints.lua new file mode 100644 index 0000000000..e1f34617fc --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local PHILIO_SOUND_SIREN = { + { manufacturerId = 0x013C, productType = 0x0004, productId = 0x000A } +} + +return PHILIO_SOUND_SIREN diff --git a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua index 6397022dd8..23e310bf14 100644 --- a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" @@ -19,9 +9,6 @@ local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({version=2}) local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) local preferencesMap = require "preferences" -local PHILIO_SOUND_SIREN = { - { manufacturerId = 0x013C, productType = 0x0004, productId = 0x000A } -} local PARAMETER_SOUND = "sound" local SMOKE = 0 @@ -43,14 +30,6 @@ local sounds = { [SMOKE] = {notificationType = Notification.notification_type.SMOKE, event = Notification.event.smoke.DETECTED_LOCATION_PROVIDED} } -local function can_handle_philio_sound_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(PHILIO_SOUND_SIREN) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function device_added(self, device) device:refresh() @@ -157,7 +136,7 @@ end local philio_sound_siren = { NAME = "Philio sound siren", - can_handle = can_handle_philio_sound_siren, + can_handle = require("philio-sound-siren.can_handle"), lifecycle_handlers = { added = device_added }, diff --git a/drivers/SmartThings/zwave-siren/src/preferences.lua b/drivers/SmartThings/zwave-siren/src/preferences.lua index 3e9f8333ab..3cf2fe91b5 100644 --- a/drivers/SmartThings/zwave-siren/src/preferences.lua +++ b/drivers/SmartThings/zwave-siren/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { PHILIO_SOUND_SIREN = { @@ -56,7 +46,32 @@ local devices = { PARAMETERS = { alarmLength = {parameter_number = 1, size = 2} } - } + }, + ZOOZ_ZSE50_SIREN = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x0004, + product_ids = 0x0369 + }, + PARAMETERS = { + playbackMode = { parameter_number = 1, size = 1 }, + playbackDuration = { parameter_number = 2, size = 2 }, + playbackLoop = { parameter_number = 3, size = 1 }, + playbackTone = { parameter_number = 4, size = 1 }, + playbackVolume = { parameter_number = 5, size = 1 }, + ledMode = { parameter_number = 6, size = 1 }, + ledColor = { parameter_number = 7, size = 1 }, + lowBattery = { parameter_number = 8, size = 1 }, + ledBatteryMode = { parameter_number = 9, size = 1 }, + btnToneSelection = { parameter_number = 10, size = 1 }, + btnVolSelection = { parameter_number = 11, size = 1 }, + basicSetGrp2 = { parameter_number = 12, size = 1 }, --Not Used + systemVolume = { parameter_number = 13, size = 1 }, + ledBrightness = { parameter_number = 14, size = 1 }, + batteryFrequency = { parameter_number = 15, size = 1 }, + batteryThreshold = { parameter_number = 16, size = 1 } + } + }, } local preferences = {} @@ -80,4 +95,5 @@ preferences.to_numeric_value = function(new_value) end return numeric end + return preferences diff --git a/drivers/SmartThings/zwave-siren/src/sub_drivers.lua b/drivers/SmartThings/zwave-siren/src/sub_drivers.lua new file mode 100644 index 0000000000..52d5035a16 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/sub_drivers.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("multifunctional-siren"), + lazy_load_if_possible("zooz-zse50"), + lazy_load_if_possible("zwave-sound-sensor"), + lazy_load_if_possible("ecolink-wireless-siren"), + lazy_load_if_possible("philio-sound-siren"), + lazy_load_if_possible("aeotec-doorbell-siren"), + lazy_load_if_possible("aeon-siren"), + lazy_load_if_possible("yale-siren"), + lazy_load_if_possible("zipato-siren"), + lazy_load_if_possible("utilitech-siren"), + lazy_load_if_possible("fortrezz"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua index 714dc17a10..30b8333c5b 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -64,6 +54,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -87,6 +80,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -115,7 +111,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -143,7 +142,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) @@ -172,7 +174,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -200,7 +205,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -228,7 +236,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -252,7 +263,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -277,7 +291,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -303,7 +320,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua index c2dc708cfe..31fb57fed2 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -121,7 +111,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("main", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -156,7 +149,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound2", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -191,7 +187,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound3", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -226,7 +225,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound4", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -261,7 +263,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound5", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -296,7 +301,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound6", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -331,7 +339,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound7", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -366,7 +377,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound8", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -409,7 +423,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -453,7 +468,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -497,7 +513,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -541,7 +558,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -585,7 +603,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -629,7 +648,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -673,7 +693,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -717,7 +738,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -755,7 +777,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -792,7 +817,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -829,7 +857,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -866,7 +897,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -903,7 +937,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -940,7 +977,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -977,7 +1017,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1014,7 +1057,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1051,7 +1097,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1088,7 +1137,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1125,7 +1177,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1162,7 +1217,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1199,7 +1257,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1236,7 +1297,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1273,7 +1337,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1310,7 +1377,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1347,7 +1417,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1384,7 +1457,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1421,7 +1497,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1458,7 +1537,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1495,7 +1577,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1532,7 +1617,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1569,7 +1657,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1606,7 +1697,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1643,7 +1737,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1680,7 +1777,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1717,7 +1817,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1754,7 +1857,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1791,7 +1897,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1828,7 +1937,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1865,7 +1977,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1902,7 +2017,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1939,7 +2057,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1976,7 +2097,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2013,7 +2137,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2050,7 +2177,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2087,7 +2217,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2124,7 +2257,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2161,7 +2297,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2198,7 +2337,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2235,7 +2377,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2272,7 +2417,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2309,7 +2457,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2346,7 +2497,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2383,7 +2537,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2420,7 +2577,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2457,7 +2617,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2494,7 +2657,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -2515,7 +2681,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -2537,7 +2704,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -2568,7 +2736,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2581,7 +2752,10 @@ test.register_coroutine_test( _preferences.configureSoundAndVolume = false test.socket.device_lifecycle:__queue_receive(mock_siren:generate_info_changed({ preferences = _preferences})) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2603,7 +2777,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2614,7 +2791,10 @@ test.register_coroutine_test( _preferences.triggerButtonUnpairing = false test.socket.device_lifecycle:__queue_receive(mock_siren:generate_info_changed({ preferences = _preferences})) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2636,7 +2816,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2647,7 +2830,10 @@ test.register_coroutine_test( _preferences.triggerButtonPairing = false test.socket.device_lifecycle:__queue_receive(mock_siren:generate_info_changed({ preferences = _preferences})) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -2672,6 +2858,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound3", capabilities.battery.battery(BUTTON_BATTERY_LOW)) } + }, + { + min_api_version = 17 } ) @@ -2697,6 +2886,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound3", capabilities.battery.battery(BUTTON_BATTERY_NORMAL)) } + }, + { + min_api_version = 17 } ) @@ -2722,6 +2914,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound4", capabilities.battery.battery(BUTTON_BATTERY_LOW)) } + }, + { + min_api_version = 17 } ) @@ -2747,6 +2942,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound4", capabilities.battery.battery(BUTTON_BATTERY_NORMAL)) } + }, + { + min_api_version = 17 } ) @@ -2772,6 +2970,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound5", capabilities.battery.battery(BUTTON_BATTERY_LOW)) } + }, + { + min_api_version = 17 } ) @@ -2797,6 +2998,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound5", capabilities.battery.battery(BUTTON_BATTERY_NORMAL)) } + }, + { + min_api_version = 17 } ) @@ -2830,7 +3034,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -2847,7 +3054,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound5", capabilities.battery.battery(BUTTON_BATTERY_LOW)) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua index f20e5c6074..c81ba1b262 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -76,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 17 } ) @@ -111,6 +104,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren1", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 17 } ) @@ -146,6 +142,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren2", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 17 } ) @@ -171,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 17 } ) @@ -206,6 +208,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren1", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 17 } ) @@ -241,6 +246,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren2", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 17 } ) @@ -276,7 +284,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 4 } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -300,7 +311,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Receiving the both command from siren1 component should generate the correct commands including delayed commands", @@ -331,7 +345,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -355,7 +372,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -378,7 +398,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {4}}) ) ) - end + end, + { + min_api_version = 17 + } ) @@ -402,7 +425,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {dst_channels = {1}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Receiving the off command from siren1 should generate the correct commands", @@ -424,7 +450,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {2}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Receiving the off command from siren2 should generate the correct commands", @@ -446,7 +475,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {3}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Receiving the off command from siren3 should generate the correct commands", @@ -468,7 +500,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {4}}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua index e23b6d3caf..61ae247d58 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -69,7 +59,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.both({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.on({}))) - end + end, + { + min_api_version = 17 + } ) @@ -100,7 +93,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.siren({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.on({}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -130,7 +126,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.strobe({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.on({}))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -160,7 +159,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.off({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.off({}))) - end + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua index 23ad20a1fd..432de972dd 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -73,7 +63,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -103,7 +94,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -143,7 +135,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -174,7 +167,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -206,7 +200,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -228,7 +223,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -250,7 +246,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -282,7 +279,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("main", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -306,7 +306,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -328,7 +331,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -352,7 +358,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -376,7 +385,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -400,7 +412,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -424,7 +439,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -442,7 +460,10 @@ test.register_coroutine_test( Basic:Set({value=0x00}) ) ) - end + end, + { + min_api_version = 17 + } ) do @@ -461,7 +482,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -506,7 +531,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -528,7 +557,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua index 4c24c069ad..33801f1dbd 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -51,6 +41,9 @@ test.register_message_test( direction = "receive", message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Battery:Report({ battery_level = 0x00 })) } } + }, + { + min_api_version = 17 } ) @@ -68,7 +61,10 @@ test.register_coroutine_test( mock_siren, Battery:Get({}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua index 072a55c3d3..275cc89ce9 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 17 } ) @@ -80,6 +73,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 17 } ) @@ -115,7 +111,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) @@ -151,7 +150,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -186,7 +188,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -220,7 +225,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -253,7 +261,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -298,7 +309,10 @@ test.register_coroutine_test( mock_siren, Configuration:Get({parameter_number = 4}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -315,7 +329,10 @@ test.register_coroutine_test( mock_siren, Battery:Get({}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua index fbfbdd2db7..0d39f0f990 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -71,7 +61,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -99,7 +92,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -116,7 +112,10 @@ test.register_coroutine_test( mock_siren, Battery:Get({}) )) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -134,6 +133,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 17 } ) @@ -152,6 +154,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua b/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua new file mode 100644 index 0000000000..1454458687 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua @@ -0,0 +1,717 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) +local t_utils = require "integration_test.utils" + +local siren_endpoints = { + { + command_classes = { + { value = zw.SOUND_SWITCH }, + { value = zw.NOTIFICATION }, + { value = zw.VERSION }, + { value = zw.BASIC } + } + } +} + +--- { manufacturerId = 0x027A, productType = 0x0004, productId = 0x0369 } -- Zooz ZSE50 Siren & Chime +local mock_siren = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("zooz-zse50.yml"), + zwave_endpoints = siren_endpoints, + zwave_manufacturer_id = 0x027A, + zwave_product_type = 0x0004, + zwave_product_id = 0x0369, +}) + +local tones_list = { + [1] = { name = "test_tone1", duration = 2 }, + [2] = { name = "test_tone2", duration = 4 } +} + +local function test_init() + -- Initialize some fields to help with testing + mock_siren:set_field("TONE_DEFAULT", 1, { persist = true }) + mock_siren:set_field("TOTAL_TONES", 2, { persist = true }) + mock_siren:set_field("TONES_LIST", tones_list, { persist = true }) + + test.mock_device.add_test_device(mock_siren) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "init should rebuild tones when tone cache is missing", + function() + mock_siren:set_field("TONES_LIST", nil) + mock_siren:set_field("TONE_DEFAULT", nil) + + test.socket.device_lifecycle:__queue_receive({ mock_siren.id, "init" }) + test.socket.capability:__expect_send( + mock_siren:generate_test_message("main", capabilities.mode.mode("Rebuild List")) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonesNumberGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "added should set startup volume and refresh", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zwave:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_siren.id, "added" }) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 10 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Version:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationGet({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infoChanged should update config and send delayed Basic Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.device_lifecycle:__queue_receive( + mock_siren:generate_info_changed({ preferences = { ledColor = 255 } }) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Configuration:Set({ parameter_number = 7, size = 1, configuration_value = -1 }) + ) + ) + + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Set({ value = 0x00 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infoChanged should update playbackDuration and send delayed Basic Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.device_lifecycle:__queue_receive( + mock_siren:generate_info_changed({ preferences = { playbackDuration = 90 } }) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Configuration:Set({ parameter_number = 2, size = 2, configuration_value = 90 }) + ) + ) + + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Set({ value = 0x00 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Version report should update firmware version", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Version:Report({ + application_version = 2, + application_sub_version = 5 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.firmwareUpdate.currentVersion({ value = "2.05" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Notification report AC_MAINS_DISCONNECTED should set power source to battery", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.AC_MAINS_DISCONNECTED + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Notification report AC_MAINS_RE_CONNECTED should set power source to mains", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.AC_MAINS_RE_CONNECTED + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch ConfigurationReport should update volume", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ConfigurationReport({ + volume = 75, + default_tone_identifer = 5 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.audioVolume.volume(75)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch TonesNumberReport should request info on each tone", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonesNumberReport({ + supported_tones = 2 + })) } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ToneInfoGet({ tone_identifier = 1 }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ToneInfoGet({ tone_identifier = 2 }) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch ToneInfoReport should update supported modes when all tones received", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ToneInfoReport({ + tone_identifier = 1, + name = "test_tone1", + tone_duration = 2 + })) } + }, + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ToneInfoReport({ + tone_identifier = 2, + name = "test_tone2", + tone_duration = 4 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.supportedModes({ "Rebuild List", "Off", "1: test_tone1 (2s)", "2: test_tone2 (4s)" })) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.supportedArguments({ "Off", "1: test_tone1 (2s)", "2: test_tone2 (4s)" })) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch TonePlayReport for tone 1 should set alarm on, chime on, and mode to tone name", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonePlayReport({ + tone_identifier = 1 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.both()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.chime()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("1: test_tone1 (2s)")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch TonePlayReport for tone 0 should set alarm off, chime off, and mode Off", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonePlayReport({ + tone_identifier = 0 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("Off")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Basic report 0x00 should be handled as alarm off, chime off, and mode Off", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_siren.id, + zw_test_utils.zwave_test_build_receive_command(Basic:Report({ value = 0 })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("Off")) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "volumeUp should increase volume by 2", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "volumeUp", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 52 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "volumeUp should decrease volume by 2", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "volumeDown", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 48 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setVolume should set volume to specified value", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "setVolume", args = { 75 } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 75 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "alarm.both() should send TonePlaySet with default tone and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "alarm", component = "main", command = "both", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0xFF }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "alarm.off() should send TonePlaySet with tone 0x00 and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "chime.chime() should send TonePlaySet with default tone and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "chime", component = "main", command = "chime", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0xFF }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "chime.off() should send TonePlaySet with tone 0x00 and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "chime", component = "main", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode should play the specified tone", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "1: test_tone1 (2s)" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 1 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode to Off should turn off the tone", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "Off" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode to Rebuild List should emit mode and send TonesNumberGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "Rebuild List" } } + }) + test.socket.capability:__expect_send( + mock_siren:generate_test_message("main", capabilities.mode.mode("Rebuild List")) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonesNumberGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "refresh should send a series of Z-Wave Gets", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Version:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationGet({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua index c0d58bb952..61893d7dd6 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -60,6 +50,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 17 } ) @@ -79,6 +72,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -98,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({value = 25})) } + }, + { + min_api_version = 17 } ) @@ -117,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 25, unit = 'C'})) } + }, + { + min_api_version = 17 } ) @@ -142,7 +144,10 @@ test.register_coroutine_test( Basic:Get({}) )) mock_siren:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua index 73de507946..e80d79e338 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -59,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -102,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -126,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua index 75713b89b7..3e33929169 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua @@ -1,4 +1,4 @@ ----@diagnostic disable: param-type-mismatch, undefined-field +-- Copyright 2022 SmartThings, Inc. -- Copyright 2022 SmartThings -- -- Licensed under the Apache License, Version 2.0 (the "License"); @@ -82,6 +82,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -105,6 +108,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -128,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -151,6 +160,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -174,6 +186,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -197,6 +212,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -220,6 +238,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -243,6 +264,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -271,7 +295,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -299,7 +326,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) @@ -328,7 +358,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -356,7 +389,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -384,7 +420,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -422,7 +461,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_siren_basic.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua index e88b1e1852..b796b1b9d0 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -58,6 +48,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.detected()) } + }, + { + min_api_version = 17 } ) @@ -77,6 +70,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.not_detected()) } + }, + { + min_api_version = 17 } ) @@ -93,6 +89,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.not_detected()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-siren/src/utilitech-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/utilitech-siren/can_handle.lua new file mode 100644 index 0000000000..72f6a963b9 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/utilitech-siren/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_utilitech_siren(opts, driver, device, ...) + local UTILITECH_MFR = 0x0060 + local UTILITECH_SIREN_PRODUCT_ID = 0x0001 + if device.zwave_manufacturer_id == UTILITECH_MFR and device.zwave_product_id == UTILITECH_SIREN_PRODUCT_ID then + return true, require("utilitech-siren") + end + return false +end + +return can_handle_utilitech_siren diff --git a/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua b/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua index 9e6818c6e4..0088fa6dd8 100644 --- a/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua @@ -1,29 +1,12 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local Basic = (require "st.zwave.CommandClass.Basic")({version=1,strict=true}) local Battery = (require "st.zwave.CommandClass.Battery")({version=1}) local BatteryDefaults = require "st.zwave.defaults.battery" -local UTILITECH_MFR = 0x0060 -local UTILITECH_SIREN_PRODUCT_ID = 0x0001 - -local function can_handle_utilitech_siren(opts, driver, device, ...) - return device.zwave_manufacturer_id == UTILITECH_MFR and device.zwave_product_id == UTILITECH_SIREN_PRODUCT_ID -end - local function device_added(self, device) device:send(Basic:Get({})) device:send(Battery:Get({})) @@ -39,7 +22,7 @@ end local utilitech_siren = { NAME = "utilitech-siren", - can_handle = can_handle_utilitech_siren, + can_handle = require("utilitech-siren.can_handle"), zwave_handlers = { [cc.BATTERY] = { [Battery.REPORT] = battery_report_handler diff --git a/drivers/SmartThings/zwave-siren/src/yale-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/yale-siren/can_handle.lua new file mode 100644 index 0000000000..1e29867936 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/yale-siren/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_yale_siren(opts, self, device, ...) + local YALE_MFR = 0x0129 + if device.zwave_manufacturer_id == YALE_MFR then + return true, require("yale-siren") + end + return false +end + +return can_handle_yale_siren diff --git a/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua b/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua index de548aee95..3757a2c3a9 100644 --- a/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" @@ -20,12 +10,6 @@ local Configuration = (require "st.zwave.CommandClass.Configuration")({version=1 local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=1}) local preferencesMap = require "preferences" -local YALE_MFR = 0x0129 - -local function can_handle_yale_siren(opts, self, device, ...) - return device.zwave_manufacturer_id == YALE_MFR -end - local function siren_set_helper(device, value) device:send(Basic:Set({value = value})) local query_device = function() @@ -98,7 +82,7 @@ end local yale_siren = { NAME = "yale-siren", - can_handle = can_handle_yale_siren, + can_handle = require("yale-siren.can_handle"), capability_handlers = { [capabilities.alarm.ID] = { [capabilities.alarm.commands.both.NAME] = siren_on, diff --git a/drivers/SmartThings/zwave-siren/src/zipato-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/zipato-siren/can_handle.lua new file mode 100644 index 0000000000..cb1170db2e --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zipato-siren/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zipato_siren(opts, driver, device, ...) + local ZIPATO_MFR = 0x0131 + if device.zwave_manufacturer_id == ZIPATO_MFR then + return true, require("zipato-siren") + end + return false +end + +return can_handle_zipato_siren diff --git a/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua b/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua index e22e643ae1..2af57daa19 100644 --- a/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -18,13 +8,9 @@ local AlarmDefaults = require "st.zwave.defaults.alarm" local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) local Battery = (require "st.zwave.CommandClass.Battery")({version=1}) -local ZIPATO_MFR = 0x0131 local BASIC_AND_SWITCH_BINARY_REPORT_STROBE_LIMIT = 33 local BASIC_AND_SWITCH_BINARY_REPORT_SIREN_LIMIT = 66 -local function can_handle_zipato_siren(opts, driver, device, ...) - return device.zwave_manufacturer_id == ZIPATO_MFR -end local function basic_report_handler(driver, device, cmd) local value = cmd.args.value @@ -64,7 +50,7 @@ end local zipato_siren = { NAME = "zipato-siren", - can_handle = can_handle_zipato_siren, + can_handle = require("zipato-siren.can_handle"), capability_handlers = { [capabilities.alarm.ID] = { [capabilities.alarm.commands.both.NAME] = siren_on, diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua new file mode 100644 index 0000000000..90ce30f76b --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multifunctional_siren(opts, driver, device, ...) + local FINGERPRINTS = require("zooz-zse50.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zooz-zse50") + end + end + return false +end + +return can_handle_multifunctional_siren diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua new file mode 100644 index 0000000000..a8c37a04e6 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZSE50_FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x0004, productId = 0x0369 } -- Zooz ZSE50 Siren & Chime +} + +return ZSE50_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua new file mode 100644 index 0000000000..a1d58b262a --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua @@ -0,0 +1,367 @@ +-- Copyright 2026 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local preferencesMap = require "preferences" + +local log = require "log" +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local defaults = require "st.zwave.defaults" + +local cc = require "st.zwave.CommandClass" +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) +local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({ version = 1 }) +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) + +--- @param self st.zwave.Driver +--- @param device st.zwave.Device +local function update_firmwareUpdate_capability(self, device, component, major, minor) + if device:supports_capability_by_id(capabilities.firmwareUpdate.ID, component.id) then + local fmtFirmwareVersion = string.format("%d.%02d", major, minor) + device:emit_component_event(component, capabilities.firmwareUpdate.currentVersion({ value = fmtFirmwareVersion })) + end +end + +--- Update the built in capability firmwareUpdate's currentVersion attribute with the +--- Zwave version information received during pairing of the device. +--- @param self st.zwave.Driver +--- @param device st.zwave.Device +local function updateFirmwareVersion(self, device) + local fw_major = (((device.st_store or {}).zwave_version or {}).firmware or {}).major + local fw_minor = (((device.st_store or {}).zwave_version or {}).firmware or {}).minor + if fw_major and fw_minor then + update_firmwareUpdate_capability(self, device, device.profile.components.main, fw_major, fw_minor) + else + device.log.warn("Firmware major or minor version not available.") + end +end + +local function getModeName(toneId, toneInfo) + return string.format("%s: %s (%ss)", toneId, toneInfo.name, toneInfo.duration) +end + +local function playTone(device, tone_id) + local tones_list = device:get_field("TONES_LIST") + local default_tone = device:get_field("TONE_DEFAULT") or 1 + local playbackMode = tonumber(device.preferences.playbackMode) + local duration = 0 + + if playbackMode == 1 then + duration = device.preferences.playbackDuration + elseif playbackMode == 2 then + duration = duration * device.preferences.playbackLoop + elseif tones_list ~= nil and tone_id > 0 then + if tone_id == 0xFF then + duration = tones_list[tonumber(default_tone)].duration + else + duration = tones_list[tonumber(tone_id)].duration + end + end + + log.info(string.format("Playing Tone: %s, playbackMode %s, duration %ss", tone_id, playbackMode, duration)) + + device:send(SoundSwitch:TonePlaySet({ tone_identifier = tone_id })) + device:send(SoundSwitch:TonePlayGet({})) + + local soundSwitch_refresh = function() + local chime = device:get_latest_state("main", capabilities.chime.ID, capabilities.chime.chime.NAME) + local mode = device:get_latest_state("main", capabilities.mode.ID, capabilities.mode.mode.NAME) + log.info(string.format("Running SoundSwitch Refresh: %s | %s", chime, mode)) + if chime ~= "off" or mode ~= "Off" then + device:send(SoundSwitch:TonePlayGet({})) + end + end + + if tone_id > 0 and playbackMode <= 2 then + local minDuration = math.max(duration, 4) + device.thread:call_with_delay(minDuration + 0.5, soundSwitch_refresh) + device.thread:call_with_delay(minDuration + 4, soundSwitch_refresh) + end + +end + +local function rebuildTones(device) + device:emit_event(capabilities.mode.mode("Rebuild List")) + device:send(SoundSwitch:TonesNumberGet({})) +end + +local function refresh_handler(self, device) + device:default_refresh() + device:send(Version:Get({})) + device:send(Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + })) + device:send(SoundSwitch:ConfigurationGet({})) + device:send(SoundSwitch:TonePlayGet({})) +end + +local function setMode_handler(self, device, command) + local mode_value = command.args.mode + local mode_split = string.find(mode_value, ":") + + if mode_split ~= nil then + mode_value = string.sub(mode_value, 1, mode_split - 1) + end + log.info(string.format("Command: setMode (%s)", mode_value)) + + if mode_value == 'Rebuild List' then + rebuildTones(device) + elseif mode_value == 'Off' then + playTone(device, 0x00) + else + playTone(device, tonumber(mode_value)) + end +end + +local function setVolume_handler(self, device, cmd) + local new_volume = st_utils.clamp_value(cmd.args.volume, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = new_volume })) +end + +local function volumeUp_handler(self, device, cmd) + local volume = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) or 50 + volume = st_utils.clamp_value(volume + 2, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = volume })) +end + +local function volumeDown_handler(self, device, cmd) + local volume = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) or 50 + volume = st_utils.clamp_value(volume - 2, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = volume })) +end + +local function tone_on(self, device) + playTone(device, 0xFF) +end + +local function tone_off(self, device) + playTone(device, 0x00) +end + +local function tones_number_report_handler(self, device, cmd) + local total_tones = cmd.args.supported_tones + + --Max 50 tones per Zooz settings + if total_tones > 50 then + total_tones = 50 + end + + local tones_list = { } + device:set_field("TOTAL_TONES", total_tones) + device:set_field("TONES_LIST_TMP", tones_list) + + --Get info on all tones + for tone = 1, total_tones do + device:send(SoundSwitch:ToneInfoGet({ tone_identifier = tone })) + end +end + +local function tone_info_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.tone_identifier) + local tone_name = cmd.args.name + local duration = cmd.args.tone_duration + local total_tones = device:get_field("TOTAL_TONES") + local tones_list = device:get_field("TONES_LIST_TMP") or {} + + tones_list[tone_id] = { name = tone_name, duration = duration } + device:set_field("TONES_LIST_TMP", tones_list) + + if tone_id >= total_tones or #tones_list >= total_tones then + log.info(string.format("Received info on all tones: tone_id %s, #tones_list %s, total_tones %s", tone_id, #tones_list, total_tones)) + + local tones_arguments = { "Off" } + for il, vl in ipairs(tones_list) do + table.insert(tones_arguments, getModeName(il, vl)) + end + + device:set_field("TONES_LIST", tones_list, { persist = true }) + device:emit_event(capabilities.mode.supportedModes({ "Rebuild List", table.unpack(tones_arguments) })) + device:emit_event(capabilities.mode.supportedArguments(tones_arguments)) + device:send(SoundSwitch:TonePlayGet({})) + end +end + +--- Handle when tone is played (TONE_PLAY_REPORT or BASIC_REPORT) +local function tone_playing(self, device, tone_id) + local tones_list = device:get_field("TONES_LIST") + + if tones_list == nil or tones_list == {} then + rebuildTones(device) + end + + if tone_id == 0 then + device:emit_event(capabilities.alarm.alarm.off()) + device:emit_event(capabilities.chime.chime.off()) + device:emit_event(capabilities.mode.mode("Off")) + else + local toneInfo = (tones_list or {})[tone_id] or { name = "Unknown", duration = "0" } + local modeName = getModeName(tone_id, toneInfo) + device:emit_event(capabilities.alarm.alarm.both()) + device:emit_event(capabilities.chime.chime.chime()) + device:emit_event(capabilities.mode.mode(modeName)) + end +end + +local function tone_play_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.tone_identifier) + tone_playing(self, device, tone_id) +end + +local function basic_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.value) + tone_playing(self, device, tone_id) +end + +--- Handle SoundSwitch Config Reports (volume) +local function soundSwitch_configuration_report(self, device, cmd) + local volume = st_utils.clamp_value(cmd.args.volume, 0, 100) + local default_tone = cmd.args.default_tone_identifer + device:emit_event(capabilities.audioVolume.volume(volume)) + device:set_field("TONE_DEFAULT", default_tone, { persist = true }) +end + +--- Handle power source changes +local function notification_report_handler(self, device, cmd) + if cmd.args.notification_type == Notification.notification_type.POWER_MANAGEMENT then + local event = cmd.args.event + local powerManagement = Notification.event.power_management + + if event == powerManagement.AC_MAINS_DISCONNECTED then + device:emit_event(capabilities.powerSource.powerSource.battery()) + elseif event == powerManagement.AC_MAINS_RE_CONNECTED or event == powerManagement.STATE_IDLE then + device:emit_event(capabilities.powerSource.powerSource.mains()) + end + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Version.Report +local function version_report_handler(driver, device, cmd) + local major = cmd.args.application_version + local minor = cmd.args.application_sub_version + + -- Update the built in firmware capability, if available + update_firmwareUpdate_capability(driver, device, device.profile.components.main, major, minor) +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +local function device_init(driver, device) + if (device:get_field("TONES_LIST") == nil or device:get_field("TONE_DEFAULT") == nil) then + rebuildTones(device) + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +local function device_added(driver, device) + device:send(SoundSwitch:ConfigurationSet({ volume = 10 })) + updateFirmwareVersion(driver, device) + device:refresh() +end + +--- Handle preference changes (same as default but added hack for unsigned parameters) +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param event table +--- @param args +local function info_changed(driver, device, event, args) + local preferences = preferencesMap.get_device_parameters(device) + + if preferences then + local did_configuration_change = false + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences[id] then + local new_parameter_value = preferencesMap.to_numeric_value(device.preferences[id]) + --Hack to convert to signed integer + local size_factor = math.floor(256 ^ preferences[id].size) + if new_parameter_value >= (size_factor / 2) then + new_parameter_value = new_parameter_value - size_factor + end + --END Hack + device:send(Configuration:Set({ parameter_number = preferences[id].parameter_number, size = preferences[id].size, configuration_value = new_parameter_value })) + did_configuration_change = true + end + end + + if did_configuration_change then + local delayed_command = function() + device:send(Basic:Set({ value = 0x00 })) + end + device.thread:call_with_delay(1, delayed_command) + end + + end +end + +local zooz_zse50 = { + NAME = "Zooz ZSE50", + can_handle = require("zooz-zse50.can_handle"), + + supported_capabilities = { + capabilities.battery, + capabilities.chime, + capabilities.mode, + capabilities.audioVolume, + capabilities.powerSource, + capabilities.firmwareUpdate, + capabilities.configuration, + capabilities.refresh + }, + + zwave_handlers = { + [cc.BASIC] = { + [Basic.REPORT] = basic_report_handler + }, + [cc.SOUND_SWITCH] = { + [SoundSwitch.TONES_NUMBER_REPORT] = tones_number_report_handler, + [SoundSwitch.TONE_INFO_REPORT] = tone_info_report_handler, + [SoundSwitch.TONE_PLAY_REPORT] = tone_play_report_handler, + [SoundSwitch.CONFIGURATION_REPORT] = soundSwitch_configuration_report + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + }, + [cc.VERSION] = { + [Version.REPORT] = version_report_handler + } + }, + + capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = setMode_handler + }, + [capabilities.audioVolume.ID] = { + [capabilities.audioVolume.commands.setVolume.NAME] = setVolume_handler, + [capabilities.audioVolume.commands.volumeUp.NAME] = volumeUp_handler, + [capabilities.audioVolume.commands.volumeDown.NAME] = volumeDown_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + }, + [capabilities.alarm.ID] = { + [capabilities.alarm.commands.both.NAME] = tone_on, + [capabilities.alarm.commands.off.NAME] = tone_off + }, + [capabilities.chime.ID] = { + [capabilities.chime.commands.chime.NAME] = tone_on, + [capabilities.chime.commands.off.NAME] = tone_off + }, + + }, + + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed + } +} + +defaults.register_for_default_handlers(zooz_zse50, zooz_zse50.supported_capabilities) + +return zooz_zse50 diff --git a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/can_handle.lua b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/can_handle.lua new file mode 100644 index 0000000000..c3fb8b343e --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zwave_sound_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-sound-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zwave-sound-sensor") + end + end + return false +end + +return can_handle_zwave_sound_sensor diff --git a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/fingerprints.lua new file mode 100644 index 0000000000..e0c8ef4762 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_SOUND_SENSOR_FINGERPRINTS = { + { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000F } --Ecolink Firefighter +} + +return ZWAVE_SOUND_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua index c591f8a89b..c50153ec26 100644 --- a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua +++ b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,23 +8,12 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Alarm local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 2 }) -local ZWAVE_SOUND_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000F } --Ecolink Firefighter -} --- Determine whether the passed device is zwave-sound-sensor --- --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_zwave_sound_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_SOUND_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end --- Default handler for alarm command class reports --- @@ -73,7 +52,7 @@ local zwave_sound_sensor = { added = added_handler, }, NAME = "zwave sound sensor", - can_handle = can_handle_zwave_sound_sensor, + can_handle = require("zwave-sound-sensor.can_handle"), } return zwave_sound_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/fingerprints.yml b/drivers/SmartThings/zwave-smoke-alarm/fingerprints.yml index 6a73e78428..39b541becb 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/fingerprints.yml +++ b/drivers/SmartThings/zwave-smoke-alarm/fingerprints.yml @@ -63,3 +63,9 @@ zwaveManufacturer: productType: 0x0004 productId: 0x0003 deviceProfileName: co-battery + - id: "1051/1/1040" + deviceLabel: SMCO410 + manufacturerId: 0x041B + productId: 0x0410 + productType: 0x0001 + deviceProfileName: smoke-co-battery diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-smoke-alarm/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/can_handle.lua new file mode 100644 index 0000000000..4a3f51183b --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_smoke_sensor(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("fibaro-smoke-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("fibaro-smoke-sensor") + end + end + return false +end + +return can_handle_fibaro_smoke_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/fingerprints.lua b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/fingerprints.lua new file mode 100644 index 0000000000..81c04eccfc --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_SMOKE_SENSOR_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1002 }, -- Fibaro Smoke Sensor + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1003 }, -- Fibaro Smoke Sensor + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x3002 }, -- Fibaro Smoke Sensor + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x4002 } -- Fibaro Smoke Sensor +} + +return FIBARO_SMOKE_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua index 1e0b456002..bf7d554613 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -24,26 +14,12 @@ local WakeUp = (require "st.zwave.CommandClass.WakeUp")({version=1}) local FIBARO_SMOKE_SENSOR_WAKEUP_INTERVAL = 21600 --seconds -local FIBARO_SMOKE_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1002 }, -- Fibaro Smoke Sensor - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1003 }, -- Fibaro Smoke Sensor - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x3002 }, -- Fibaro Smoke Sensor - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x4002 } -- Fibaro Smoke Sensor -} --- Determine whether the passed device is fibaro smoke sensro --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is fibaro smoke sensor -local function can_handle_fibaro_smoke_sensor(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_SMOKE_SENSOR_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function device_added(self, device) device:send(WakeUp:IntervalSet({node_id = self.environment_info.hub_zwave_id, seconds = FIBARO_SMOKE_SENSOR_WAKEUP_INTERVAL})) @@ -76,7 +52,8 @@ local fibaro_smoke_sensor = { added = device_added }, NAME = "fibaro smoke sensor", - can_handle = can_handle_fibaro_smoke_sensor, + can_handle = require("fibaro-smoke-sensor.can_handle"), + health_check = false, } return fibaro_smoke_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/init.lua index 17f6348474..1ee506f453 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -30,7 +20,7 @@ local preferencesMap = require "preferences" local function update_preferences(self, device, args) local preferences = preferencesMap.get_device_parameters(device) for id, value in pairs(device.preferences) do - if not (args and args.old_st_store) or (args.old_st_store.preferences[id] ~= value and preferences and preferences[id]) then + if not (args and args.old_st_store and args.old_st_store.preferences) or (args.old_st_store.preferences[id] ~= value and preferences and preferences[id]) then local new_parameter_value = preferencesMap.to_numeric_value(device.preferences[id]) device:send(Configuration:Set({parameter_number = preferences[id].parameter_number, size = preferences[id].size, configuration_value = new_parameter_value})) end @@ -83,18 +73,14 @@ local driver_template = { capabilities.temperatureAlarm, capabilities.temperatureMeasurement }, - sub_drivers = { - require("zwave-smoke-co-alarm-v1"), - require("zwave-smoke-co-alarm-v2"), - require("fibaro-smoke-sensor"), - require("apiv6_bugfix"), - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { init = device_init, infoChanged = info_changed, doConfigure = do_configure, added = device_added - } + }, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-smoke-alarm/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua b/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua index 25606a5255..55fa70d48b 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { FIBARO = { diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/sub_drivers.lua b/drivers/SmartThings/zwave-smoke-alarm/src/sub_drivers.lua new file mode 100644 index 0000000000..39901aaf21 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-smoke-co-alarm-v1"), + lazy_load_if_possible("zwave-smoke-co-alarm-v2"), + lazy_load_if_possible("fibaro-smoke-sensor"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua index 7739ebe2ca..e860e4608f 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -78,7 +68,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = ACOUSTIC_SIGNALS, configuration_value = EXCEEDING_THE_TEMPERATURE}) )) mock_fibaro_CO_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -94,6 +87,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -119,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -144,6 +143,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 70.7, unit = 'F' })) } + }, + { + min_api_version = 17 } ) @@ -168,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 17 } ) @@ -192,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 17 } ) @@ -216,6 +224,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 17 } ) @@ -240,6 +251,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 17 } ) @@ -263,6 +277,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.tested()) } + }, + { + min_api_version = 17 } ) @@ -288,6 +305,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 17 } ) @@ -312,6 +332,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -336,6 +359,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 17 } ) @@ -365,7 +391,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -373,8 +400,9 @@ test.register_message_test( test.register_coroutine_test( "Device should be configured after changing device settings", function() + test.socket.zwave:__set_channel_ordering("relaxed") test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") - local _preferences = {zwaveNotifications = 3} --"Both actions enabled" + local _preferences = {zwaveNotifications = 3, overheatThreshold = 55} --"Both actions enabled" test.socket.device_lifecycle():__queue_receive(mock_fibaro_CO_sensor:generate_info_changed({ preferences = _preferences })) test.socket.zwave:__expect_send( @@ -387,13 +415,28 @@ test.register_coroutine_test( }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_CO_sensor, Configuration:Get({ parameter_number = 2 }) ) ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_fibaro_CO_sensor, + Configuration:Set({ + parameter_number = 22, + configuration_value = 55, + size = 1 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_fibaro_CO_sensor, + Configuration:Get({ parameter_number = 22 }) + ) + ) test.wait_for_events() @@ -402,9 +445,14 @@ test.register_coroutine_test( Configuration:Report({ parameter_number = 2, configuration_value = 3 }) }) + test.socket.zwave:__queue_receive({ + mock_fibaro_CO_sensor.id, + Configuration:Report({ parameter_number = 22, configuration_value = 55 }) + }) + test.mock_time.advance_time(1) - _preferences = {overheatThreshold = 50} --"120 °F / 50°C" + _preferences = {overheatThreshold = 50, zwaveNotifications = 0} --"120 °F / 50°C" test.socket.device_lifecycle():__queue_receive(mock_fibaro_CO_sensor:generate_info_changed({ preferences = _preferences })) test.socket.zwave:__expect_send( @@ -417,13 +465,28 @@ test.register_coroutine_test( }) ) ) - test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_fibaro_CO_sensor, Configuration:Get({ parameter_number = 22 }) ) ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_fibaro_CO_sensor, + Configuration:Set({ + parameter_number = 2, + configuration_value = 0, + size = 1 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_fibaro_CO_sensor, + Configuration:Get({ parameter_number = 2 }) + ) + ) test.wait_for_events() @@ -431,7 +494,15 @@ test.register_coroutine_test( mock_fibaro_CO_sensor.id, Configuration:Report({ parameter_number = 22, configuration_value = 50 }) }) - end + test.socket.zwave:__queue_receive({ + mock_fibaro_CO_sensor.id, + Configuration:Report({ parameter_number = 2, configuration_value = 3 }) + }) + + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua index ebb50eaa6e..39c79e0498 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -158,7 +148,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number=32, size=2, configuration_value=4320}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -191,7 +184,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -211,7 +207,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua index fd2996b9d1..bdeb10535f 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -100,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 17 } ) @@ -119,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 17 } ) @@ -161,6 +163,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) }, + }, + { + min_api_version = 17 } ) @@ -180,6 +185,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -199,6 +207,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua index e42edd4828..60710942bc 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -63,6 +53,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 17 } ) @@ -82,6 +75,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) }, + }, + { + min_api_version = 17 } ) test.register_message_test( @@ -100,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) }, + }, + { + min_api_version = 17 } ) @@ -119,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 17 } ) @@ -138,6 +140,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.tested()) } + }, + { + min_api_version = 17 } ) @@ -157,6 +162,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 17 } ) @@ -171,6 +179,9 @@ test.register_message_test( alarm_level = 1 })) } }, + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua index 95121b1673..c62dab7ec6 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -64,6 +54,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) }, + }, + { + min_api_version = 17 } ) @@ -102,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) }, + }, + { + min_api_version = 17 } ) @@ -121,6 +120,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -140,6 +142,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -159,6 +164,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) } + }, + { + min_api_version = 17 } ) @@ -178,6 +186,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -192,6 +203,9 @@ test.register_message_test( alarm_level = 1 })) } }, + }, + { + min_api_version = 17 } ) @@ -238,7 +252,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -258,6 +273,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -277,6 +295,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -296,6 +317,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -315,6 +339,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 17 } ) @@ -334,6 +361,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 17 } ) @@ -376,7 +406,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/can_handle.lua new file mode 100644 index 0000000000..dc78d0e4ac --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_v1_alarm(opts, driver, device, cmd, ...) + -- The default handlers for the Alarm/Notification command class(es) for the + -- Smoke Detector and Carbon Monoxide Detector only handles V3 and up. + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version < 3 then + return true, require("zwave-smoke-co-alarm-v1") + end + return false +end + +return can_handle_v1_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua index 923c516167..8e3cc4e267 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -23,17 +12,6 @@ local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) -- manufacturerId = 0x0138, productType = 0x0001, productId = 0x0002 -- First Alert Smoke & CO Detector -- manufacturerId = 0x0138, productType = 0x0001, productId = 0x0003 -- First Alert Smoke & CO Detector ---- Determine whether the passed device only supports V1 or V2 of the Alarm command class ---- ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @return boolean true if the device is smoke co alarm -local function can_handle_v1_alarm(opts, driver, device, cmd, ...) - -- The default handlers for the Alarm/Notification command class(es) for the - -- Smoke Detector and Carbon Monoxide Detector only handles V3 and up. - return opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version < 3 -end - --- Default handler for alarm command class reports --- --- This converts alarm V1 reports to correct smoke events @@ -75,7 +53,7 @@ local zwave_alarm = { } }, NAME = "Z-Wave smoke and CO alarm V1", - can_handle = can_handle_v1_alarm, + can_handle = require("zwave-smoke-co-alarm-v1.can_handle"), } return zwave_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/can_handle.lua new file mode 100644 index 0000000000..6666e7abc2 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_v2_alarm(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("zwave-smoke-co-alarm-v2.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zwave-smoke-co-alarm-v2") + end + end + return false +end + +return can_handle_v2_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/can_handle.lua new file mode 100644 index 0000000000..baa0d7bbf0 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_co_sensor(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5") + end + end + return false +end + +return can_handle_fibaro_co_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/fingerprints.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/fingerprints.lua new file mode 100644 index 0000000000..37fb1f508c --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_CO_SENSORS_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor +} + +return FIBARO_CO_SENSORS_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua index bde1cbc877..0559b83983 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) @@ -21,10 +11,6 @@ local TAMPERING_AND_EXCEEDING_THE_TEMPERATURE = 3 local ACOUSTIC_SIGNALS = 4 local EXCEEDING_THE_TEMPERATURE = 2 -local FIBARO_CO_SENSORS_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor -} local function parameterNumberToParameterName(preferences,parameterNumber) for id, parameter in pairs(preferences) do @@ -40,14 +26,6 @@ end --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is smoke co alarm -local function can_handle_fibaro_co_sensor(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_CO_SENSORS_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function update_preferences(self, device, args) local preferences = preferencesMap.get_device_parameters(device) @@ -108,7 +86,7 @@ local fibaro_co_sensor = { init = device_init, infoChanged = info_changed }, - can_handle = can_handle_fibaro_co_sensor + can_handle = require("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5.can_handle"), } return fibaro_co_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fingerprints.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fingerprints.lua new file mode 100644 index 0000000000..8dc021cc28 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SMOKE_CO_ALARM_V2_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor +} + +return SMOKE_CO_ALARM_V2_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua index b49b010cdb..5e69f7108d 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,24 +11,12 @@ local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 2 }) --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local SMOKE_CO_ALARM_V2_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor -} --- Determine whether the passed device is Smoke Alarm --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is smoke co alarm -local function can_handle_v2_alarm(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(SMOKE_CO_ALARM_V2_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local device_added = function(self, device) device:emit_event(capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) @@ -94,13 +73,11 @@ local zwave_alarm = { } }, NAME = "Z-Wave smoke and CO alarm V2", - can_handle = can_handle_v2_alarm, + can_handle = require("zwave-smoke-co-alarm-v2.can_handle"), lifecycle_handlers = { added = device_added }, - sub_drivers = { - require("zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5") - } + sub_drivers = require("zwave-smoke-co-alarm-v2.sub_drivers"), } return zwave_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/sub_drivers.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/sub_drivers.lua new file mode 100644 index 0000000000..0699743371 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-switch/fingerprints.yml b/drivers/SmartThings/zwave-switch/fingerprints.yml index 30e6ba5530..2ca0fa3c73 100644 --- a/drivers/SmartThings/zwave-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-switch/fingerprints.yml @@ -23,7 +23,7 @@ zwaveManufacturer: productType: 0x4457 deviceProfileName: switch-level-indicator - id: 0063/4944 - deviceLabel: GE Dimmer Switch + deviceLabel: JascoPro In-Wall Dimmer manufacturerId: 0x0063 productType: 0x4944 deviceProfileName: switch-level-indicator @@ -45,17 +45,29 @@ zwaveManufacturer: productId: 0x0000 deviceProfileName: switch-level - id: "Inovelli/Dimmer/Power/Energy" - deviceLabel: Inovelli Dimmer Switch + deviceLabel: Inovelli Dimmer Red Series manufacturerId: 0x031E productType: 0x0001 productId: 0x0001 deviceProfileName: inovelli-dimmer-power-energy - id: "Inovelli/Dimmer" - deviceLabel: Inovelli Dimmer Switch + deviceLabel: Inovelli Dimmer Black Series manufacturerId: 0x031E productType: 0x0003 productId: 0x0001 deviceProfileName: inovelli-dimmer + - id: "Inovelli/VZW31-SN" + deviceLabel: Inovelli Dimmer Red Series + manufacturerId: 0x031E + productType: 0x0015 + productId: 0x0001 + deviceProfileName: inovelli-dimmer-vzw31-sn + - id: "Inovelli/VZW32-SN" + deviceLabel: Inovelli mmWave Dimmer Red Series + manufacturerId: 0x031E + productType: 0x0017 + productId: 0x0001 + deviceProfileName: inovelli-mmwave-dimmer-vzw32-sn - id: "010F/0403" deviceLabel: Fibaro Single Switch manufacturerId: 0x010F @@ -387,7 +399,7 @@ zwaveManufacturer: productId: 0xA002 deviceProfileName: switch-binary - id: 0063/4952 - deviceLabel: GE Switch + deviceLabel: JascoPro In-Wall Switch manufacturerId: 0x0063 productType: 0x4952 deviceProfileName: switch-binary-indicator @@ -448,6 +460,18 @@ zwaveManufacturer: productType: 0x1603 productId: 0x0334 deviceProfileName: smartplug-binary + - id: 001D/0041/0002 + deviceLabel: Leviton ZW6HD In-wall Dimmer + manufacturerId: 0x001D + productType: 0x0041 + productId: 0x0002 + deviceProfileName: leviton-ZW6HD + - id: 001D/0042/0002 + deviceLabel: Leviton ZW15S In-wall Switch + manufacturerId: 0x001D + productType: 0x0042 + productId: 0x0002 + deviceProfileName: leviton-ZW15S - id: 011A/0101/0102 deviceLabel: Enerwave Switch manufacturerId: 0x011A @@ -541,7 +565,7 @@ zwaveManufacturer: productId: 0xC001 deviceProfileName: smartplug-binary - id: 0312/FF00/FF07 - deviceLabel: Minoston Outlet + deviceLabel: Smart Plug manufacturerId: 0x0312 productType: 0xFF00 productId: 0xFF07 @@ -784,11 +808,11 @@ zwaveManufacturer: productType: 0x1801 productId: 0x1000 deviceProfileName: metering-switch - - id: 0086/004E - deviceLabel: Aeotec Switch + - id: "Aeotec/Heavy Duty" + deviceLabel: Aeotec Heavy Duty manufacturerId: 0x0086 productId: 0x004E - deviceProfileName: metering-switch + deviceProfileName: aeotec-heavy-duty - id: 0258/0003/0087 deviceLabel: NEO Coolcam Outlet manufacturerId: 0x0258 @@ -814,17 +838,17 @@ zwaveManufacturer: productId: 0x0054 deviceProfileName: metering-switch - id: 0371/0003/00AF - deviceLabel: Aeotec Outlet + deviceLabel: Aeotec Smart Switch 7 EU manufacturerId: 0x0371 productType: 0x0003 productId: 0x00AF - deviceProfileName: metering-switch + deviceProfileName: aeotec-smart-switch-7-eu - id: 0371/0103/0017 - deviceLabel: Aeotec Outlet + deviceLabel: Aeotec Smart Switch 7 US manufacturerId: 0x0371 productType: 0x0103 productId: 0x0017 - deviceProfileName: metering-switch + deviceProfileName: aeotec-smart-switch-7-us - id: 0060/0004/000B deviceLabel: Everspring Outlet manufacturerId: 0x0060 @@ -898,6 +922,19 @@ zwaveManufacturer: manufacturerId: 0x010F productType: 0x0102 deviceProfileName: fibaro-dimmer-2 + - id: 013C/0005/008A + deviceLabel: Philio Dimmer Switch PAD19 + manufacturerId: 0x013C + productType: 0x0005 + productId: 0x008A + deviceProfileName: switch-level +#Zooz + - id: "Zooz/ZEN05" + deviceLabel: Zooz Outdoor Plug ZEN05 + manufacturerId: 0x027A + productType: 0x7000 + productId: 0xB001 + deviceProfileName: zooz-zen05-plug #Shelly/Qubino - id: 1120/2/137 deviceLabel: Wave Plug UK @@ -911,6 +948,66 @@ zwaveManufacturer: productId: 0x0088 productType: 0x0002 deviceProfileName: smartplug-switch-power-energy + - id: 1120/2/129 + deviceLabel: Wave 2PM 1 + manufacturerId: 0x0460 + productId: 0x0081 + productType: 0x0002 + deviceProfileName: metering-switch + - id: 1120/2/131 + deviceLabel: Wave 1 + manufacturerId: 0x0460 + productId: 0x0083 + productType: 0x0002 + deviceProfileName: switch-binary + - id: 1120/2/132 + deviceLabel: Wave 1PM + manufacturerId: 0x0460 + productId: 0x0084 + productType: 0x0002 + deviceProfileName: metering-switch + - id: 1120/2/135 + deviceLabel: Shelly Wave Plug S + manufacturerId: 0x0460 + productId: 0x0087 + productType: 0x0002 + deviceProfileName: smartplug-switch-power-energy + - id: 1120/2/138 + deviceLabel: Wave Pro 1 + manufacturerId: 0x0460 + productId: 0x008A + productType: 0x0002 + deviceProfileName: switch-binary + - id: 1120/2/139 + deviceLabel: Wave Pro 1PM + manufacturerId: 0x0460 + productId: 0x008B + productType: 0x0002 + deviceProfileName: metering-switch + - id: 1120/2/140 + deviceLabel: Wave Pro 2 1 + manufacturerId: 0x0460 + productId: 0x008C + productType: 0x0002 + deviceProfileName: switch-binary + - id: 1120/2/141 + deviceLabel: Wave Pro 2PM 1 + manufacturerId: 0x0460 + productId: 0x008D + productType: 0x0002 + deviceProfileName: metering-switch + - id: 1120/2/142 + deviceLabel: Wave 1 Mini + manufacturerId: 0x0460 + productId: 0x008E + productType: 0x0002 + deviceProfileName: switch-binary + - id: 1120/2/143 + deviceLabel: Wave 1PM Mini + manufacturerId: 0x0460 + productId: 0x008F + productType: 0x0002 + deviceProfileName: metering-switch zwaveGeneric: - id: "GenericSwitch/1" deviceLabel: Switch diff --git a/drivers/SmartThings/zwave-switch/profiles/aeotec-heavy-duty.yml b/drivers/SmartThings/zwave-switch/profiles/aeotec-heavy-duty.yml new file mode 100644 index 0000000000..5f7c54d0f7 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/aeotec-heavy-duty.yml @@ -0,0 +1,126 @@ +name: aeotec-heavy-duty +components: +- id: main + label: "Switch" + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +metadata: + deviceType: Switch + ocfDeviceType: oic.d.switch + deviceTypeId: Switch +preferences: + - title: "3. Overload Protection" + name: overloadProtection + description: "Determines if Load is turned off if more then 39.5A current is used for more than 5 seconds." + required: true + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 + - title: "20. Output power after power restore" + name: ledAfterPower + description: "Determines what happens to the output state after a power restoration" + required: true + preferenceType: enumeration + definition: + options: + 0: "Last Status" + 1: "Always On" + 2: "Always Off" + default: 0 + - title: "80. Automatic Report Type" + name: autoReportType + description: "Determines what type of report is sent by HDSS when status changes by hub or button press" + required: true + preferenceType: enumeration + definition: + options: + 0: "Nothing" + 1: "hail CC" + 2: "basic CC Report" + default: 2 + - title: "90. Enable/Disable Threshold Report" + name: powerThreshold + description: "Disable or Enable Threshold reporting" + required: true + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 + - title: "101. Group 1 Sensors" + name: group1Sensors + description: "Group 1 sensor reports." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 15 + default: 12 + - title: "102. Group 2 Sensors" + name: group2Sensors + description: "Group 2 sensor reports." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 15 + default: 0 + - title: "103. Group 3 Sensors" + name: group3Sensors + description: "Group 3 sensor reports." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 15 + default: 0 + - title: "111. Group 1 sensor report time" + name: group1Time + description: "set timed report of Group 1 sensor in seconds" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 86400 + default: 300 + - title: "112. Group 2 sensor report time" + name: group2Time + description: "set timed report of Group 2 sensor in second" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 86400 + default: 3600 + - title: "113. Group 3 sensor report time" + name: group3Time + description: "set timed report of Group 3 sensor in second" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 86400 + default: 3600 + - title: "Association Group 1: Lifeline" + name: assocGroup1 + description: "Enter a comma delimited list of hex IDs to be sent general notifications (5 node max)" + required: true + preferenceType: string + definition: + stringType: text + default: "" diff --git a/drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-eu.yml b/drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-eu.yml new file mode 100644 index 0000000000..9a3c5402ef --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-eu.yml @@ -0,0 +1,68 @@ +name: aeotec-smart-switch-7-eu +components: + - id: main + label: "Switch" + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: colorControl + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +metadata: + deviceType: Switch + ocfDeviceType: oic.d.switch + deviceTypeId: Switch +preferences: + - title: "91. Watt Threshold" + name: thresholdPower + description: "Determines how much change in Watt induces a report." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 2300 + default: 0 + - title: "92. kWh Threshold" + name: thresholdKwh + description: "Determines how much change in kWh induces a report." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 10000 + default: 0 + - title: "101. Group 1 Sensors" + name: group1Sensors + description: "Determines the value of change kWh should be reported, checked once every 10 seconds." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 15 + default: 3 + - title: "111. Group 1 sensor report time" + name: group1Time + description: "Set timed report of Group 1 sensor in seconds" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 86400 + default: 300 + - title: "Association Group 1: Lifeline" + name: assocGroup1 + description: "Enter a comma delimited list of hex IDs to be sent general notifications (5 node max)" + required: true + preferenceType: string + definition: + stringType: text + default: '' diff --git a/drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-us.yml b/drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-us.yml new file mode 100644 index 0000000000..6a9dc7c3e0 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/aeotec-smart-switch-7-us.yml @@ -0,0 +1,48 @@ +name: aeotec-smart-switch-7-us +components: + - id: main + label: "Switch" + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +metadata: + deviceType: Switch + ocfDeviceType: oic.d.switch + deviceTypeId: Switch +preferences: + - title: "20. kWh Threshold" + name: thresholdKwh + description: "Determines how much change in kWh induces a report." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 10000 + default: 100 + - title: "21. Watt Threshold" + name: thresholdPower + description: "Determines how much change in Watt induces a report." + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 2500 + default: 0 + - title: "Association Group 1: Lifeline" + name: assocGroup1 + description: "Enter a comma delimited list of hex IDs to be sent general notifications (5 node max)." + required: true + preferenceType: string + definition: + stringType: text + default: "" \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-power-energy.yml b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-power-energy.yml index c3ee571c10..077c80e3a3 100644 --- a/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-power-energy.yml +++ b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-power-energy.yml @@ -27,24 +27,48 @@ components: categories: - name: Light - id: button1 + label: Down Button capabilities: - id: button version: 1 categories: - name: RemoteController - id: button2 + label: Up Button capabilities: - id: button version: 1 categories: - name: RemoteController - id: button3 + label: Config Button capabilities: - id: button version: 1 categories: - name: RemoteController preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "0": "Clear" + "1": "Solid" + "2": "Chase" + "3": "Fast Blink" + "4": "Slow Blink" + "5": "Pulse" + default: 1 - name: "dimmingSpeed" title: "Dimming Speed" description: "How fast or slow the light changes state when you hold the switch. diff --git a/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml new file mode 100644 index 0000000000..e2da4a3239 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml @@ -0,0 +1,284 @@ +name: inovelli-dimmer-vzw31-sn +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +- id: button1 + label: Down Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button2 + label: Up Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button3 + label: Config Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "255": "Clear" + "1": "Solid" + "2": "Fast Blink" + "3": "Slow Blink" + "4": "Pulse" + "5": "Chase" + "6": "Open/Close" + "7": "Small-to-Big" + "8": "Aurora" + "9": "Slow Falling" + "10": "Medium Falling" + "11": "Fast Falling" + "12": "Slow Rising" + "13": "Medium Rising" + "14": "Fast Rising" + "15": "Medium Blink" + "16": "Slow Chase" + "17": "Fast Chase" + "18": "Fast Siren" + "19": "Slow Siren" + default: 1 + - name: "parameter158" + title: "158. Switch Mode" + description: "Use as a Dimmer or an On/Off switch" + required: true + preferenceType: enumeration + definition: + options: + "0": "Dimmer (default)" + "1": "On/Off" + default: 0 + - name: "parameter52" + title: "52. Smart Bulb Mode" + description: "For use with Smart Bulbs that need constant power and are controlled via commands rather than power. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: true + preferenceType: enumeration + definition: + options: + "0": "Disabled (default)" + "1": "Smart Bulb Mode" + default: 0 + - name: "parameter22" + title: "22. Aux Switch Type" + description: "Set the Aux switch type. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: true + preferenceType: enumeration + definition: + options: + "0": "None (default)" + "1": "3-Way Dumb Switch" + "2": "3-Way Aux Switch" + "3": "Single Pole Full Sine Wave" + default: 0 + - name: "parameter1" + title: "1. Dimming Speed (Remote)" + description: "This changes the speed that the light dims up when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + Default=25 (2500ms or 2.5s)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 25 + - name: "parameter2" + title: "2. Dimming Speed (Local)" + description: "This changes the speed that the light dims up when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter3" + title: "3. Ramp Rate (Remote)" + description: "This changes the speed that the light turns on when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter4" + title: "4. Ramp Rate (Local)" + description: "This changes the speed that the light turns on when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 3)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter9" + title: "9. Minimum Level" + description: "The minimum level that the light can be dimmed. Useful when the user has a light that does not turn on or flickers at a lower level." + required: true + preferenceType: number + definition: + minimum: 1 + maximum: 99 + default: 1 + - name: "parameter10" + title: "10. Maximum Level" + description: "The maximum level that the light can be dimmed. Useful when the user wants to limit the maximum brighness." + required: true + preferenceType: number + definition: + minimum: 2 + maximum: 100 + default: 100 + - name: "parameter15" + title: "15. Level After Power Restored" + description: "The level the switch will return to when power is restored after power failure. + 0=Off + 1-100=Set Level + 101=Use previous level." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 101 + default: 101 + - name: "parameter18" + title: "18. Active Power Reports" + description: "Power level change that will result in a new power report being sent. + 0 = Disabled + 1-32767 = 0.1W-3276.7W." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter19" + title: "19. Periodic Power & Energy Reports" + description: "Time period between consecutive power & energy reports being sent (in seconds). The timer is reset after each report is sent." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 3600 + - name: "parameter20" + title: "20. Active Energy Reports" + description: "Energy level change that will result in a new energy report being sent. + 0 = Disabled + 1-32767 = 0.01kWh-327.67kWh." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter50" + title: "50. Button Press Delay" + description: "Adjust the delay used in scene control. 0=no delay (disables multi-tap scenes), 1=100ms, 2=200ms, 3=300ms, etc." + required: true + preferenceType: enumeration + definition: + options: + "0": "0ms" + "1": "100ms" + "2": "200ms" + "3": "300ms" + "4": "400ms" + "5": "500ms (default)" + "6": "600ms" + "7": "700ms" + "8": "800ms" + "9": "900ms" + default: 5 + - name: "parameter95" + title: "95. LED Indicator Color (w/On)" + description: "Set the color of the Full LED Indicator when the load is on." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter96" + title: "96. LED Indicator Color (w/Off)" + description: "Set the color of the Full LED Indicator when the load is off." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter97" + title: "97. LED Indicator Intensity (w/On)" + description: "Set the intensity of the Full LED Indicator when the load is on." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 50 + - name: "parameter98" + title: "98. LED Indicator Intensity (w/Off)" + description: "Set the intensity of the Full LED Indicator when the load is off." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 5 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/profiles/inovelli-mmwave-dimmer-vzw32-sn.yml b/drivers/SmartThings/zwave-switch/profiles/inovelli-mmwave-dimmer-vzw32-sn.yml new file mode 100644 index 0000000000..d85a661839 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/inovelli-mmwave-dimmer-vzw32-sn.yml @@ -0,0 +1,400 @@ +name: inovelli-mmwave-dimmer-vzw32-sn +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + config: + values: + - key: "illuminance.value" + range: [0, 5000] + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +- id: button1 + label: Down Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button2 + label: Up Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button3 + label: Config Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "255": "Clear" + "1": "Solid" + "2": "Fast Blink" + "3": "Slow Blink" + "4": "Pulse" + "5": "Chase" + "6": "Open/Close" + "7": "Small-to-Big" + "8": "Aurora" + "9": "Slow Falling" + "10": "Medium Falling" + "11": "Fast Falling" + "12": "Slow Rising" + "13": "Medium Rising" + "14": "Fast Rising" + "15": "Medium Blink" + "16": "Slow Chase" + "17": "Fast Chase" + "18": "Fast Siren" + "19": "Slow Siren" + default: 1 + - name: "parameter158" + title: "158. Switch Mode" + description: "Use as a Dimmer or an On/Off switch" + required: true + preferenceType: enumeration + definition: + options: + "0": "Dimmer (default)" + "1": "On/Off" + default: 0 + - name: "parameter52" + title: "52. Smart Bulb Mode" + description: "For use with Smart Bulbs that need constant power and are controlled via commands rather than power. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: true + preferenceType: enumeration + definition: + options: + "0": "Disabled (default)" + "1": "Smart Bulb Mode" + default: 0 + - name: "parameter1" + title: "1. Dimming Speed (Remote)" + description: "This changes the speed that the light dims up when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + Default=25 (2500ms or 2.5s)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 25 + - name: "parameter2" + title: "2. Dimming Speed (Local)" + description: "This changes the speed that the light dims up when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter3" + title: "3. Ramp Rate (Remote)" + description: "This changes the speed that the light turns on when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter4" + title: "4. Ramp Rate (Local)" + description: "This changes the speed that the light turns on when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 3)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter9" + title: "9. Minimum Level" + description: "The minimum level that the light can be dimmed. Useful when the user has a light that does not turn on or flickers at a lower level." + required: true + preferenceType: number + definition: + minimum: 1 + maximum: 99 + default: 1 + - name: "parameter10" + title: "10. Maximum Level" + description: "The maximum level that the light can be dimmed. Useful when the user wants to limit the maximum brighness." + required: true + preferenceType: number + definition: + minimum: 2 + maximum: 100 + default: 100 + - name: "parameter15" + title: "15. Level After Power Restored" + description: "The level the switch will return to when power is restored after power failure. + 0=Off + 1-100=Set Level + 101=Use previous level." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 101 + default: 101 + - name: "parameter18" + title: "18. Active Power Reports" + description: "Power level change that will result in a new power report being sent. + 0 = Disabled + 1-32767 = 0.1W-3276.7W." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter19" + title: "19. Periodic Power & Energy Reports" + description: "Time period between consecutive power & energy reports being sent (in seconds). The timer is reset after each report is sent." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 3600 + - name: "parameter20" + title: "20. Active Energy Reports" + description: "Energy level change that will result in a new energy report being sent. + 0 = Disabled + 1-32767 = 0.01kWh-327.67kWh." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter50" + title: "50. Button Press Delay" + description: "Adjust the delay used in scene control. 0=no delay (disables multi-tap scenes), 1=100ms, 2=200ms, 3=300ms, etc." + required: true + preferenceType: enumeration + definition: + options: + "0": "0ms" + "1": "100ms" + "2": "200ms" + "3": "300ms" + "4": "400ms" + "5": "500ms (default)" + "6": "600ms" + "7": "700ms" + "8": "800ms" + "9": "900ms" + default: 5 + - name: "parameter95" + title: "95. LED Indicator Color (w/On)" + description: "Set the color of the Full LED Indicator when the load is on." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter96" + title: "96. LED Indicator Color (w/Off)" + description: "Set the color of the Full LED Indicator when the load is off." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter97" + title: "97. LED Indicator Intensity (w/On)" + description: "Set the intensity of the Full LED Indicator when the load is on." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 50 + - name: "parameter98" + title: "98. LED Indicator Intensity (w/Off)" + description: "Set the intensity of the Full LED Indicator when the load is off." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 5 + - name: "parameter101" + title: "101. mmWave Height Minimum (Floor)" + description: "Minimum range of the Z-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: -300 + - name: "parameter102" + title: "102. mmWave Height Maximum (Ceiling)" + description: "Maximum range of the Z-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: 300 + - name: "parameter103" + title: "103. mmWave Width Minimum (Left)" + description: "Minimum range of the X-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: -600 + - name: "parameter104" + title: "104. mmWave Width Maximum (Right)" + description: "Maximum range of the X-Axis in cm" + required: true + preferenceType: number + definition: + minimum: -600 + maximum: 600 + default: 600 + - name: "parameter105" + title: "105. mmWave Depth Minimum (Near)" + description: "Minimum range of the Y-Axis in cm" + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 600 + default: 0 + - name: "parameter106" + title: "106. mmWave Depth Maximum (Far)" + description: "Maximum range of the Y-Axis in cm" + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 600 + default: 600 + - name: "parameter108" + title: "108. mmWave Stay Life" + description: "Optimize detection in areas where user may be still for a long time. The delay time of the stay area is set to 50ms when it is set to 1, to 1 second when it is set to 20, and the default value is 300, that is, 15 seconds" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 4294967295 + default: 300 + - name: "parameter110" + title: "Light On Presence Behavior" + description: "When presence is detected, choose how to control the light load" + required: true + preferenceType: enumeration + definition: + options: + "0": "Disabled" + "1": "Auto On/Off when occupied (default)" + "2": "Auto Off when vacant" + "3": "Auto On when occupied" + "4": "Auto On/Off when Vacant" + "5": "Auto On when Vacant" + "6": "Auto Off when Occupied" + default: 1 + - name: "parameter111" + title: "111. mmWave Control Commands" + description: "Advanced commands to send to the mmWave Module (Please see documentation)" + required: false + preferenceType: enumeration + definition: + options: + "1": "Set Interference Area" + "3": "Clear Interference Area" + "0": "Factory Reset Module" + default: 3 + - name: "parameter112" + title: "112. mmWave Sensitivity" + description: "Adjust the sensitivity of the mmWave sensor. 0-Low, 1-Medium, 2-High." + required: false + preferenceType: enumeration + definition: + options: + "0": "Low" + "1": "Medium" + "2": "High (default)" + default: 2 + - name: "parameter113" + title: "113. mmWave Detection Delay" + description: "The time from detecting a person to triggering an action. 0-Low (5s), 1-Medium (1s), 2-Fast (0.2s)." + required: false + preferenceType: enumeration + definition: + options: + "0": "5 seconds" + "1": "1 second" + "2": "0.2 seconds (default)" + default: 2 + - name: "parameter114" + title: "mmWave Detection Timeout" + description: "Adjust the timeout after presence is no longer detected. After the timeout the load will turn off." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 4294967296 + default: 30 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/profiles/leviton-zw15s.yml b/drivers/SmartThings/zwave-switch/profiles/leviton-zw15s.yml new file mode 100644 index 0000000000..d91aae7621 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/leviton-zw15s.yml @@ -0,0 +1,19 @@ +# in-wall-switch-profile +name: leviton-ZW15S +components: +- id: main + capabilities: + - id: switch + version: 1 + categories: + - name: Switch +preferences: + - name: "statusLEDmode" + title: "Status LED Configuration" + description: "0: always off, 254: Status LED ON when load is ON, 255: Status LED ON when load is OFF (locator mode)" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 255 + default: 255 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/profiles/leviton-zw6hd.yml b/drivers/SmartThings/zwave-switch/profiles/leviton-zw6hd.yml new file mode 100644 index 0000000000..3791a4422e --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/leviton-zw6hd.yml @@ -0,0 +1,99 @@ +# in-wall-dimmer-profile +name: leviton-ZW6HD +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - name: "fadeOnTime" + title: "Fade On Time" + description: "0: instant on, 1-127: 1-127 seconds, 128-253: 1-126 minutes" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 253 + default: 2 + - name: "fadeOffTime" + title: "Fade Off Time" + description: "0: instant off, 1-127: 1-127 seconds, 128-253: 1-126 minutes" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 253 + default: 2 + - name: "minLevel" + title: "Minimum Dim Light Level" + description: "0-99" + required: true + preferenceType: integer + definition: + minimum: 1 + maximum: 99 + default: 10 + - name: "maxLevel" + title: "Maximum Brightness Light Level" + description: "10-99" + required: true + preferenceType: integer + definition: + minimum: 10 + maximum: 99 + default: 99 + - name: "presetLevel" + title: "Preset level / Return to last level" + description: "0-99. 0 to return to previous level" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 99 + default: 0 + - name: "dimBarTimeout" + title: "LED Dim Bar Behavior" + description: "0: always off 1-254: 1-254 seconds 255: always on" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 255 + default: 3 + - name: "statusLEDmode" + title: "Status LED Configuration" + description: "0: always off, 254: Status LED ON when load is ON, 255: Status LED ON when load is OFF (locator mode)" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "loadType" + title: "Load Type" + description: "0: incandescent 1: LED 2: CFL 3: MLV" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 3 + default: 1 + - name: "controlTiming" + title: "Control Timing" + description: "0: Normal 1: Medium 2: Extended" + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 2 + default: 0 diff --git a/drivers/SmartThings/zwave-switch/profiles/rgbw-bulb.yml b/drivers/SmartThings/zwave-switch/profiles/rgbw-bulb.yml new file mode 100644 index 0000000000..d87d32adc6 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/rgbw-bulb.yml @@ -0,0 +1,16 @@ +name: rgbw-bulb +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: colorTemperature + version: 1 + - id: colorControl + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/zwave-switch/profiles/zooz-zen05-plug.yml b/drivers/SmartThings/zwave-switch/profiles/zooz-zen05-plug.yml new file mode 100644 index 0000000000..43685ff2c7 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/zooz-zen05-plug.yml @@ -0,0 +1,90 @@ +name: zooz-zen05-plug +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug +preferences: + - name: "driverInfo" + title: "Driver Info" + description: "This driver may have limited features, check the online Zooz documentation for information about custom drivers." + preferenceType: string + definition: + stringType: paragraph + #param 1 + - name: "ledMode" + title: "LED Indicator Behavior" + description: "Decide how the LED indicator behaves depending on the on/off status of the plug." + required: false + preferenceType: enumeration + definition: + options: + 0: "On when on, off when off *" + 1: "On when off, off when on" + 2: "Always off" + default: 0 + #param 7 + - name: "ledBrightness" + title: "LED Brightness" + description: "Choose the LED indicator brightness level." + required: false + preferenceType: enumeration + definition: + options: + 0: "High" + 1: "Medium" + 2: "Low *" + default: 2 + #param 2 + - name: "autoTurnOff" + title: "Auto-off Timer" + description: "Default: 0=disabled; Auto-off timer will automatically turn the Smart Plug off after x minutes once it has been turned on." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65535 + default: 0 + #param 4 + - name: "autoTurnOn" + title: "Auto-on Timer" + description: "Default: 0=disabled; Auto-on timer will automatically turn the Smart Plug on after x minutes once it has been turned off." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65535 + default: 0 + #param 6 + - name: "powerRecovery" + title: "On/Off Status After Power Failure" + description: "Choose the recovery state for your Smart Plug if power outage occurs." + required: false + preferenceType: enumeration + definition: + options: + 2: "Restores Prior Status *" + 0: "Always Off once restored" + 1: "Always On once restored" + default: 2 + #param 8 + - name: "manualControl" + title: "Manual Control" + description: "Disable or enable manual control (turning the Smart Plug on and off using the physical button)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled *" + default: 1 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/aeon-smart-strip/can_handle.lua b/drivers/SmartThings/zwave-switch/src/aeon-smart-strip/can_handle.lua new file mode 100644 index 0000000000..5efc0d1df0 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/aeon-smart-strip/can_handle.lua @@ -0,0 +1,24 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +--- Determine whether the passed device is Aeon smart strip +--- +--- @param driver Driver driver instance +--- @param device Device device isntance +--- @return boolean true if the device proper, else false +local function can_handle_aeon_smart_strip(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x0086, prod = 0x0003, model = 0x000B}, -- Aeon Smart Strip DSC11-ZWUS + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("aeon-smart-strip") + return true, subdriver + end + end + return false +end + + +return can_handle_aeon_smart_strip diff --git a/drivers/SmartThings/zwave-switch/src/aeon-smart-strip/init.lua b/drivers/SmartThings/zwave-switch/src/aeon-smart-strip/init.lua index f3bafc0815..4bc16ba392 100644 --- a/drivers/SmartThings/zwave-switch/src/aeon-smart-strip/init.lua +++ b/drivers/SmartThings/zwave-switch/src/aeon-smart-strip/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -24,29 +13,10 @@ local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) --- @type st.zwave.CommandClass.SwitchBinary local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) -local AEON_SMART_STRIP_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0003, model = 0x000B}, -- Aeon Smart Strip DSC11-ZWUS -} - local ENERGY_UNIT_KWH = "kWh" local ENERGY_UNIT_KVAH = "kVAh" local POWER_UNIT_WATT = "W" ---- Determine whether the passed device is Aeon smart strip ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_aeon_smart_strip(opts, driver, device, ...) - for _, fingerprint in ipairs(AEON_SMART_STRIP_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("aeon-smart-strip") - return true, subdriver - end - end - return false -end - local function binary_event_helper(self, device, cmd) local value = cmd.args.value and cmd.args.value or cmd.args.target_value local event = value == SwitchBinary.value.OFF_DISABLE and capabilities.switch.switch.off() or capabilities.switch.switch.on() @@ -108,7 +78,7 @@ local aeon_smart_strip = { [Meter.REPORT] = meter_report_handler } }, - can_handle = can_handle_aeon_smart_strip, + can_handle = require("aeon-smart-strip.can_handle"), } return aeon_smart_strip diff --git a/drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/can_handle.lua b/drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/can_handle.lua new file mode 100644 index 0000000000..7c5995ea76 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device, ...) + local fingerprints = { + { mfr = 0x0086, model = 0x004E } + } + + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, nil, fingerprint.model) then + local subdriver = require("aeotec-heavy-duty") + return true, subdriver + end + end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/init.lua b/drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/init.lua new file mode 100644 index 0000000000..2a47c4e122 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/aeotec-heavy-duty/init.lua @@ -0,0 +1,107 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.st.zwave.CommandClass.Configuration +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) + +local preferencesMap = require "preferences" + + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local function emit_power_consumption_report_event(device, value, channel) + -- powerConsumptionReport report interval + local current_time = os.time() + local last_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_time = last_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_time then + return + end + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) + local raw_value = value.value * 1000 -- 'Wh' + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state('main', capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + device:emit_event_for_endpoint(channel, capabilities.powerConsumptionReport.powerConsumption({ + energy = raw_value, + deltaEnergy = delta_energy + })) +end + +local function meter_report_handler(driver, device, cmd, zb_rx) + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + emit_power_consumption_report_event(device, { value = event_arguments.value }, cmd.src_channel) + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) + end +end + +--- Handle preference changes +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param event table +--- @param args +local function info_changed(driver, device, event, args) + local preferences = preferencesMap.get_device_parameters(device) + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences and preferences[id] then + local new_parameter_value = preferencesMap.to_numeric_value(device.preferences[id]) + device:send(Configuration:Set({ + parameter_number = preferences[id].parameter_number, + size = preferences[id].size, + configuration_value = new_parameter_value + })) + end + end +end + +local driver_template = { + supported_capabilities = { + capabilities.switch, + capabilities.energyMeter, + capabilities.powerMeter, + capabilities.powerConsumptionReport + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler + } + }, + NAME = "Aeotec Heavy Duty", + lifecycle_handlers = { + infoChanged = info_changed + }, + can_handle = require("aeotec-heavy-duty.can_handle") +} + +return driver_template; diff --git a/drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/can_handle.lua new file mode 100644 index 0000000000..b8a5c5587c --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x0086, prodId = 0x0060}, + {mfr = 0x0371, prodId = 0x00AF}, -- Smart Switch 7 EU + {mfr = 0x0371, prodId = 0x0017} -- Smart Switch 7 US + } + + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, nil, fingerprint.prodId) then + local subdriver = require("aeotec-smart-switch") + return true, subdriver + end + end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/init.lua b/drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/init.lua index fdb98705a8..0b34f1ba85 100644 --- a/drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/aeotec-smart-switch/init.lua @@ -1,35 +1,69 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version=1 }) +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +local SwitchColor = (require "st.zwave.CommandClass.SwitchColor")({ version = 3 }) +--- @type st.zwave.constants +local constants = require "st.zwave.constants" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" -local FINGERPRINTS = { - {mfr = 0x0086, prodId = 0x0060}, - {mfr = 0x0371, prodId = 0x00AF}, - {mfr = 0x0371, prodId = 0x0017} -} +local utils = require "st.utils" + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local function emit_power_consumption_report_event(device, value, channel) + -- powerConsumptionReport report interval + local current_time = os.time() + local last_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_time = last_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_time then + return + end + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) + local raw_value = value.value * 1000 -- 'Wh' + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state('main', capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + device:emit_event_for_endpoint(channel, capabilities.powerConsumptionReport.powerConsumption({ + energy = raw_value, + deltaEnergy = delta_energy + })) +end -local function can_handle(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:id_match(fingerprint.mfr, nil, fingerprint.prodId) then - local subdriver = require("aeotec-smart-switch") - return true, subdriver - end +local function meter_report_handler(driver, device, cmd, zb_rx) + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + emit_power_consumption_report_event(device, { value = event_arguments.value }, cmd.src_channel) + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) end - return false end -- Despite the NIF indicating that this device supports the Switch Multilevel @@ -42,15 +76,48 @@ local function on_off_factory(onOff) end end +local function set_color(driver, device, command) + local r, g, b = utils.hsl_to_rgb(command.args.color.hue, command.args.color.saturation, command.args.color.lightness) + + local set = SwitchColor:Set({ + color_components = { + { color_component_id=SwitchColor.color_component_id.RED, value=r }, + { color_component_id=SwitchColor.color_component_id.GREEN, value=g }, + { color_component_id=SwitchColor.color_component_id.BLUE, value=b }, + } + }) + device:send(set) + + local query_color = function() + device:send( + SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }), + command.component + ) + end + + device.thread:call_with_delay(constants.DEFAULT_GET_STATUS_DELAY, query_color) +end + local aeotec_smart_switch = { NAME = "Aeotec Smart Switch", + supported_capabilities = { + capabilities.powerConsumptionReport + }, capability_handlers = { [capabilities.switch.ID] = { [capabilities.switch.commands.on.NAME] = on_off_factory(0xFF), [capabilities.switch.commands.off.NAME] = on_off_factory(0x00) + }, + [capabilities.colorControl.ID] = { + [capabilities.colorControl.commands.setColor.NAME] = set_color + } + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler } }, - can_handle = can_handle + can_handle = require("aeotec-smart-switch.can_handle") } return aeotec_smart_switch diff --git a/drivers/SmartThings/zwave-switch/src/configurations.lua b/drivers/SmartThings/zwave-switch/src/configurations.lua index b2c283bd7c..199cf03237 100644 --- a/drivers/SmartThings/zwave-switch/src/configurations.lua +++ b/drivers/SmartThings/zwave-switch/src/configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local devices = { AEOTEC_METERING_SWITCH = { diff --git a/drivers/SmartThings/zwave-switch/src/dawon-smart-plug/can_handle.lua b/drivers/SmartThings/zwave-switch/src/dawon-smart-plug/can_handle.lua new file mode 100644 index 0000000000..86fb4d7e68 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/dawon-smart-plug/can_handle.lua @@ -0,0 +1,24 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +--- Determine whether the passed device is Dawon smart plug +--- +--- @param driver Driver driver instance +--- @param device Device device isntance +--- @return boolean true if the device proper, else false +local function can_handle_dawon_smart_plug(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x018C, prod = 0x0042, model = 0x0005}, -- Dawon Smart Plug + {mfr = 0x018C, prod = 0x0042, model = 0x0008} -- Dawon Smart Multitab + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("dawon-smart-plug") + return true, subdriver + end + end + return false +end + +return can_handle_dawon_smart_plug diff --git a/drivers/SmartThings/zwave-switch/src/dawon-smart-plug/init.lua b/drivers/SmartThings/zwave-switch/src/dawon-smart-plug/init.lua index 6280842f6b..84643a500f 100644 --- a/drivers/SmartThings/zwave-switch/src/dawon-smart-plug/init.lua +++ b/drivers/SmartThings/zwave-switch/src/dawon-smart-plug/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,25 +7,7 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local DAWON_SMART_PLUG_FINGERPRINTS = { - {mfr = 0x018C, prod = 0x0042, model = 0x0005}, -- Dawon Smart Plug - {mfr = 0x018C, prod = 0x0042, model = 0x0008} -- Dawon Smart Multitab -} ---- Determine whether the passed device is Dawon smart plug ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_dawon_smart_plug(opts, driver, device, ...) - for _, fingerprint in ipairs(DAWON_SMART_PLUG_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("dawon-smart-plug") - return true, subdriver - end - end - return false -end --- Default handler for notification reports --- @@ -60,7 +31,7 @@ local dawon_smart_plug = { [Notification.REPORT] = notification_report_handler } }, - can_handle = can_handle_dawon_smart_plug + can_handle = require("dawon-smart-plug.can_handle") } return dawon_smart_plug diff --git a/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/can_handle.lua new file mode 100644 index 0000000000..cad81076d1 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +--- Determine whether the passed device is Dawon wall smart switch +--- +--- @param driver Driver driver instance +--- @param device Device device isntance +--- @return boolean true if the device proper, else false +local function can_handle_dawon_wall_smart_switch(opts, driver, device, ...) + local fingerprints = require("dawon-wall-smart-switch.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("dawon-wall-smart-switch") + return true, subdriver + end + end + return false +end + +return can_handle_dawon_wall_smart_switch diff --git a/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/fingerprints.lua b/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/fingerprints.lua new file mode 100644 index 0000000000..ab6633bac4 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = 0x018C, prod = 0x0061, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 1 KR + {mfr = 0x018C, prod = 0x0062, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 2 KR + {mfr = 0x018C, prod = 0x0063, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 3 KR + {mfr = 0x018C, prod = 0x0064, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 1 US + {mfr = 0x018C, prod = 0x0065, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 2 US + {mfr = 0x018C, prod = 0x0066, model = 0x0001} -- Dawon Multipurpose Sensor + Smart Switch endpoint 3 US +} diff --git a/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/init.lua b/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/init.lua index f7978ad54d..4f4278c057 100644 --- a/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/dawon-wall-smart-switch/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -24,30 +13,6 @@ local Notification = (require "st.zwave.CommandClass.Notification")({ version = --- @type st.zwave.CommandClass.SensorMultilevel local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) -local DAWON_WALL_SMART_SWITCH_FINGERPRINTS = { - {mfr = 0x018C, prod = 0x0061, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 1 KR - {mfr = 0x018C, prod = 0x0062, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 2 KR - {mfr = 0x018C, prod = 0x0063, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 3 KR - {mfr = 0x018C, prod = 0x0064, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 1 US - {mfr = 0x018C, prod = 0x0065, model = 0x0001}, -- Dawon Multipurpose Sensor + Smart Switch endpoint 2 US - {mfr = 0x018C, prod = 0x0066, model = 0x0001} -- Dawon Multipurpose Sensor + Smart Switch endpoint 3 US -} - ---- Determine whether the passed device is Dawon wall smart switch ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_dawon_wall_smart_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(DAWON_WALL_SMART_SWITCH_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("dawon-wall-smart-switch") - return true, subdriver - end - end - return false -end - --- Default handler for notification reports --- --- @param self st.zwave.Driver @@ -97,7 +62,7 @@ local dawon_wall_smart_switch = { doConfigure = do_configure, infoChanged = info_changed }, - can_handle = can_handle_dawon_wall_smart_switch, + can_handle = require("dawon-wall-smart-switch.can_handle"), } return dawon_wall_smart_switch diff --git a/drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/can_handle.lua b/drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/can_handle.lua new file mode 100644 index 0000000000..274676cbed --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + + +local function can_handle_eaton_5_scene_keypad(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x001A, prod = 0x574D, model = 0x0000}, -- Eaton 5-Scene Keypad + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("eaton-5-scene-keypad") + return true, subdriver + end + end + return false +end + +return can_handle_eaton_5_scene_keypad diff --git a/drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/init.lua b/drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/init.lua index 29fccc9261..f8315135fe 100644 --- a/drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/init.lua +++ b/drivers/SmartThings/zwave-switch/src/eaton-5-scene-keypad/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.capabilities local capabilities = require "st.capabilities" @@ -29,10 +18,6 @@ local SceneControllerConf = (require "st.zwave.CommandClass.SceneControllerConf" local INDICATOR_SWITCH_STATES = "Indicator_switch_states" -local EATON_5_SCENE_KEYPAD_FINGERPRINT = { - {mfr = 0x001A, prod = 0x574D, model = 0x0000}, -- Eaton 5-Scene Keypad -} - local function upsert_after_bit_update_at_index(device, bit_position, new_bit) local old_value = device:get_field(INDICATOR_SWITCH_STATES) or 0 local mask = ~(0x1 << (bit_position - 1)) @@ -108,16 +93,6 @@ local function do_configure(self, device) device:set_field(INDICATOR_SWITCH_STATES, 0, { persist = true}) end -local function can_handle_eaton_5_scene_keypad(opts, driver, device, ...) - for _, fingerprint in ipairs(EATON_5_SCENE_KEYPAD_FINGERPRINT) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("eaton-5-scene-keypad") - return true, subdriver - end - end - return false -end - local eaton_5_scene_keypad = { NAME = "Eaton 5-Scene Keypad", zwave_handlers = { @@ -146,7 +121,7 @@ local eaton_5_scene_keypad = { lifecycle_handlers = { doConfigure = do_configure, }, - can_handle = can_handle_eaton_5_scene_keypad, + can_handle = require("eaton-5-scene-keypad.can_handle"), } return eaton_5_scene_keypad diff --git a/drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/can_handle.lua b/drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/can_handle.lua new file mode 100644 index 0000000000..6b608b042b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_eaton_accessory_dimmer(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x001A, prod = 0x4441, model = 0x0000} -- Eaton Dimmer Switch + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("eaton-accessory-dimmer") + return true, subdriver + end + end + return false +end + +return can_handle_eaton_accessory_dimmer diff --git a/drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/init.lua b/drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/init.lua index 3a9d5c9a47..bb49665075 100644 --- a/drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/init.lua +++ b/drivers/SmartThings/zwave-switch/src/eaton-accessory-dimmer/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.utils @@ -24,20 +13,6 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version = 4 }) -local EATON_ACCESSORY_DIMMER_FINGERPRINTS = { - {mfr = 0x001A, prod = 0x4441, model = 0x0000} -- Eaton Dimmer Switch -} - -local function can_handle_eaton_accessory_dimmer(opts, driver, device, ...) - for _, fingerprint in ipairs(EATON_ACCESSORY_DIMMER_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("eaton-accessory-dimmer") - return true, subdriver - end - end - return false -end - local function dimmer_event(driver, device, cmd) local level = cmd.args.value and cmd.args.value or cmd.args.target_value @@ -110,7 +85,7 @@ local eaton_accessory_dimmer = { [capabilities.switchLevel.commands.setLevel.NAME] = switch_level_set } }, - can_handle = can_handle_eaton_accessory_dimmer, + can_handle = require("eaton-accessory-dimmer.can_handle"), } return eaton_accessory_dimmer diff --git a/drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/can_handle.lua new file mode 100644 index 0000000000..67aadecb8b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + + +local function can_handle_eaton_anyplace_switch(opts, driver, device, ...) + local fingerprints = { + { manufacturerId = 0x001A, productType = 0x4243, productId = 0x0000 } -- Eaton Anyplace Switch + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subdriver = require("eaton-anyplace-switch") + return true, subdriver + end + end + return false +end + +return can_handle_eaton_anyplace_switch diff --git a/drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/init.lua b/drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/init.lua index 720f88b56f..488babc276 100644 --- a/drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/eaton-anyplace-switch/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,19 +7,7 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Basic local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) -local EATON_ANYPLACE_SWITCH_FINGERPRINTS = { - { manufacturerId = 0x001A, productType = 0x4243, productId = 0x0000 } -- Eaton Anyplace Switch -} - -local function can_handle_eaton_anyplace_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(EATON_ANYPLACE_SWITCH_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - local subdriver = require("eaton-anyplace-switch") - return true, subdriver - end - end - return false -end +local switch_utils = require "switch_utils" local function basic_set_handler(self, device, cmd) if cmd.args.value == 0xFF then @@ -46,7 +23,7 @@ local function basic_get_handler(self, device, cmd) end local function device_added(driver, device) - device:emit_event(capabilities.switch.switch.off()) + switch_utils.emit_event_if_latest_state_missing(device, "main", capabilities.switch, capabilities.switch.switch.NAME, capabilities.switch.switch.off()) end local function switch_on_handler(driver, device) @@ -74,7 +51,7 @@ local eaton_anyplace_switch = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_eaton_anyplace_switch + can_handle = require("eaton-anyplace-switch.can_handle") } return eaton_anyplace_switch diff --git a/drivers/SmartThings/zwave-switch/src/ecolink-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/ecolink-switch/can_handle.lua new file mode 100644 index 0000000000..87771168f4 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/ecolink-switch/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_ecolink(opts, driver, device, ...) + local fingerprints = require("ecolink-switch.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("ecolink-switch") + return true, subdriver + end + end + return false +end + +return can_handle_ecolink diff --git a/drivers/SmartThings/zwave-switch/src/ecolink-switch/fingerprints.lua b/drivers/SmartThings/zwave-switch/src/ecolink-switch/fingerprints.lua new file mode 100644 index 0000000000..742590dc1d --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/ecolink-switch/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = 0x014A, prod = 0x0006, model = 0x0002}, + {mfr = 0x014A, prod = 0x0006, model = 0x0003}, + {mfr = 0x014A, prod = 0x0006, model = 0x0004}, + {mfr = 0x014A, prod = 0x0006, model = 0x0005}, + {mfr = 0x014A, prod = 0x0006, model = 0x0006} +} diff --git a/drivers/SmartThings/zwave-switch/src/ecolink-switch/init.lua b/drivers/SmartThings/zwave-switch/src/ecolink-switch/init.lua index 3e69399801..081cf113dd 100644 --- a/drivers/SmartThings/zwave-switch/src/ecolink-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/ecolink-switch/init.lua @@ -1,39 +1,10 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) -local ECOLINK_FINGERPRINTS = { - {mfr = 0x014A, prod = 0x0006, model = 0x0002}, - {mfr = 0x014A, prod = 0x0006, model = 0x0003}, - {mfr = 0x014A, prod = 0x0006, model = 0x0004}, - {mfr = 0x014A, prod = 0x0006, model = 0x0005}, - {mfr = 0x014A, prod = 0x0006, model = 0x0006} -} - -local function can_handle_ecolink(opts, driver, device, ...) - for _, fingerprint in ipairs(ECOLINK_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("ecolink-switch") - return true, subdriver - end - end - return false -end - local function basic_set_handler(driver, device, cmd) if cmd.args.value == 0xFF then device:emit_event(capabilities.switch.switch.on()) @@ -49,7 +20,7 @@ local ecolink_switch = { [Basic.SET] = basic_set_handler } }, - can_handle = can_handle_ecolink + can_handle = require("ecolink-switch.can_handle") } return ecolink_switch diff --git a/drivers/SmartThings/zwave-switch/src/fibaro-double-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/fibaro-double-switch/can_handle.lua new file mode 100644 index 0000000000..5645551e3b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/fibaro-double-switch/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_fibaro_double_switch(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x010F, prod = 0x0203, model = 0x1000}, -- Fibaro Switch + {mfr = 0x010F, prod = 0x0203, model = 0x2000}, -- Fibaro Switch + {mfr = 0x010F, prod = 0x0203, model = 0x3000} -- Fibaro Switch + } + + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("fibaro-double-switch") + return true, subdriver + end + end + return false +end + +return can_handle_fibaro_double_switch diff --git a/drivers/SmartThings/zwave-switch/src/fibaro-double-switch/init.lua b/drivers/SmartThings/zwave-switch/src/fibaro-double-switch/init.lua index 8bccc75464..e02d986b91 100644 --- a/drivers/SmartThings/zwave-switch/src/fibaro-double-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/fibaro-double-switch/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local st_device = require "st.device" local capabilities = require "st.capabilities" @@ -34,22 +23,6 @@ local ENDPOINTS = { child = 2 } -local FIBARO_DOUBLE_SWITCH_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x0203, model = 0x1000}, -- Fibaro Switch - {mfr = 0x010F, prod = 0x0203, model = 0x2000}, -- Fibaro Switch - {mfr = 0x010F, prod = 0x0203, model = 0x3000} -- Fibaro Switch -} - -local function can_handle_fibaro_double_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_DOUBLE_SWITCH_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("fibaro-double-switch") - return true, subdriver - end - end - return false -end - local function do_refresh(driver, device, command) local component = command and command.component and command.component or "main" device:send_to_component(SwitchBinary:Get({}), component) @@ -140,7 +113,7 @@ local fibaro_double_switch = { init = device_init, added = device_added }, - can_handle = can_handle_fibaro_double_switch, + can_handle = require("fibaro-double-switch.can_handle") } return fibaro_double_switch diff --git a/drivers/SmartThings/zwave-switch/src/fibaro-single-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/fibaro-single-switch/can_handle.lua new file mode 100644 index 0000000000..80d41e924b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/fibaro-single-switch/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_fibaro_single_switch(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x010F, prod = 0x0403, model = 0x1000}, -- Fibaro Switch + {mfr = 0x010F, prod = 0x0403, model = 0x2000}, -- Fibaro Switch + {mfr = 0x010F, prod = 0x0403, model = 0x3000} -- Fibaro Switch + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("fibaro-single-switch") + return true, subdriver + end + end + return false +end + +return can_handle_fibaro_single_switch diff --git a/drivers/SmartThings/zwave-switch/src/fibaro-single-switch/init.lua b/drivers/SmartThings/zwave-switch/src/fibaro-single-switch/init.lua index e36703ccd6..8a3252cc32 100644 --- a/drivers/SmartThings/zwave-switch/src/fibaro-single-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/fibaro-single-switch/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local ButtonDefaults = require "st.zwave.defaults.button" @@ -28,22 +17,6 @@ local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = --- @type st.zwave.CommandClass.Basic local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) -local FIBARO_SINGLE_SWITCH_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x0403, model = 0x1000}, -- Fibaro Switch - {mfr = 0x010F, prod = 0x0403, model = 0x2000}, -- Fibaro Switch - {mfr = 0x010F, prod = 0x0403, model = 0x3000} -- Fibaro Switch -} - -local function can_handle_fibaro_single_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_SINGLE_SWITCH_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("fibaro-single-switch") - return true, subdriver - end - end - return false -end - local function central_scene_notification_handler(self, device, cmd) if cmd.src_channel == nil or cmd.src_channel == 0 then ButtonDefaults.zwave_handlers[cc.CENTRAL_SCENE][CentralScene.NOTIFICATION](self, device, cmd) @@ -94,7 +67,7 @@ local fibaro_single_switch = { [capabilities.switch.commands.off.NAME] = switch_handler_factory(0x00), } }, - can_handle = can_handle_fibaro_single_switch, + can_handle = require("fibaro-single-switch.can_handle") } return fibaro_single_switch diff --git a/drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/can_handle.lua b/drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/can_handle.lua new file mode 100644 index 0000000000..2a4c8f5b40 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_fibaro_wall_plug(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x010F, prod = 0x1401, model = 0x1001}, -- Fibaro Outlet + {mfr = 0x010F, prod = 0x1401, model = 0x2000}, -- Fibaro Outlet + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("fibaro-wall-plug-us") + return true, subdriver + end + end + return false +end + +return can_handle_fibaro_wall_plug diff --git a/drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/init.lua b/drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/init.lua index 6224421c75..8800e029ab 100644 --- a/drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/init.lua +++ b/drivers/SmartThings/zwave-switch/src/fibaro-wall-plug-us/init.lua @@ -1,31 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local FIBARO_WALL_PLUG_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1401, model = 0x1001}, -- Fibaro Outlet - {mfr = 0x010F, prod = 0x1401, model = 0x2000}, -- Fibaro Outlet -} - -local function can_handle_fibaro_wall_plug(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_WALL_PLUG_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("fibaro-wall-plug-us") - return true, subdriver - end - end - return false -end +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local function component_to_endpoint(device, component_id) if component_id == "main" then @@ -54,7 +28,7 @@ local fibaro_wall_plug = { lifecycle_handlers = { init = device_init }, - can_handle = can_handle_fibaro_wall_plug, + can_handle = require("fibaro-wall-plug-us.can_handle"), } return fibaro_wall_plug diff --git a/drivers/SmartThings/zwave-switch/src/init.lua b/drivers/SmartThings/zwave-switch/src/init.lua index e27ec98a0e..7d622e586f 100644 --- a/drivers/SmartThings/zwave-switch/src/init.lua +++ b/drivers/SmartThings/zwave-switch/src/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -103,19 +92,6 @@ local function switch_multilevel_stop_level_change_handler(driver, device, cmd) device:send(SwitchMultilevel:Get({})) end -local function lazy_load_if_possible(sub_driver_name) - -- gets the current lua libs api version - local version = require "version" - - -- version 9 will include the lazy loading functions - if version.api >= 9 then - return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) - else - return require(sub_driver_name) - end - -end - ------------------------------------------------------------------------------------------- -- Register message handlers and run driver ------------------------------------------------------------------------------------------- @@ -126,6 +102,7 @@ local driver_template = { capabilities.battery, capabilities.energyMeter, capabilities.powerMeter, + capabilities.voltageMeasurement, capabilities.colorControl, capabilities.button, capabilities.temperatureMeasurement, @@ -142,36 +119,19 @@ local driver_template = { [SwitchMultilevel.STOP_LEVEL_CHANGE] = switch_multilevel_stop_level_change_handler } }, - sub_drivers = { - lazy_load_if_possible("eaton-accessory-dimmer"), - lazy_load_if_possible("inovelli-LED"), - lazy_load_if_possible("dawon-smart-plug"), - lazy_load_if_possible("inovelli-2-channel-smart-plug"), - lazy_load_if_possible("zwave-dual-switch"), - lazy_load_if_possible("eaton-anyplace-switch"), - lazy_load_if_possible("fibaro-wall-plug-us"), - lazy_load_if_possible("dawon-wall-smart-switch"), - lazy_load_if_possible("zooz-power-strip"), - lazy_load_if_possible("aeon-smart-strip"), - lazy_load_if_possible("qubino-switches"), - lazy_load_if_possible("fibaro-double-switch"), - lazy_load_if_possible("fibaro-single-switch"), - lazy_load_if_possible("eaton-5-scene-keypad"), - lazy_load_if_possible("ecolink-switch"), - lazy_load_if_possible("multi-metering-switch"), - lazy_load_if_possible("zooz-zen-30-dimmer-relay"), - lazy_load_if_possible("multichannel-device"), - lazy_load_if_possible("aeotec-smart-switch") - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { init = device_init, infoChanged = info_changed, doConfigure = do_configure, added = device_added - } + }, + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) +defaults.register_for_default_handlers(driver_template, + driver_template.supported_capabilities, + {native_capability_cmds_enabled = true, native_capability_attrs_enabled = true}) --- @type st.zwave.Driver local switch = ZwaveDriver("zwave_switch", driver_template) switch:run() diff --git a/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/can_handle.lua new file mode 100644 index 0000000000..2bc88d57c5 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_inovelli_2_channel_smart_plug(opts, driver, device, ...) + local fingerprints = require("inovelli-2-channel-smart-plug.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("inovelli-2-channel-smart-plug") + return true, subdriver + end + end + return false +end + +return can_handle_inovelli_2_channel_smart_plug diff --git a/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/fingerprints.lua b/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/fingerprints.lua new file mode 100644 index 0000000000..ab2150468d --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = 0x015D, prod = 0x0221, model = 0x251C}, -- Show Home Outlet + {mfr = 0x0312, prod = 0x0221, model = 0x251C}, -- Inovelli Outlet + {mfr = 0x0312, prod = 0xB221, model = 0x251C}, -- Inovelli Outlet + {mfr = 0x0312, prod = 0x0221, model = 0x611C}, -- Inovelli Outlet + {mfr = 0x015D, prod = 0x0221, model = 0x611C}, -- Inovelli Outlet + {mfr = 0x015D, prod = 0x6100, model = 0x6100}, -- Inovelli Outlet + {mfr = 0x0312, prod = 0x6100, model = 0x6100}, -- Inovelli Outlet + {mfr = 0x015D, prod = 0x2500, model = 0x2500}, -- Inovelli Outlet +} diff --git a/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/init.lua index d97f85063c..313280d131 100644 --- a/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/init.lua +++ b/drivers/SmartThings/zwave-switch/src/inovelli-2-channel-smart-plug/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -24,27 +13,6 @@ local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = --- @type st.zwave.CommandClass.Association local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) -local INOVELLI_2_CHANNEL_SMART_PLUG_FINGERPRINTS = { - {mfr = 0x015D, prod = 0x0221, model = 0x251C}, -- Show Home Outlet - {mfr = 0x0312, prod = 0x0221, model = 0x251C}, -- Inovelli Outlet - {mfr = 0x0312, prod = 0xB221, model = 0x251C}, -- Inovelli Outlet - {mfr = 0x0312, prod = 0x0221, model = 0x611C}, -- Inovelli Outlet - {mfr = 0x015D, prod = 0x0221, model = 0x611C}, -- Inovelli Outlet - {mfr = 0x015D, prod = 0x6100, model = 0x6100}, -- Inovelli Outlet - {mfr = 0x0312, prod = 0x6100, model = 0x6100}, -- Inovelli Outlet - {mfr = 0x015D, prod = 0x2500, model = 0x2500}, -- Inovelli Outlet -} - -local function can_handle_inovelli_2_channel_smart_plug(opts, driver, device, ...) - for _, fingerprint in ipairs(INOVELLI_2_CHANNEL_SMART_PLUG_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("inovelli-2-channel-smart-plug") - return true, subdriver - end - end - return false -end - local function handle_main_switch_event(device, value) if value == SwitchBinary.value.ON_ENABLE then device:emit_event(capabilities.switch.switch.on()) @@ -125,7 +93,7 @@ local inovelli_2_channel_smart_plug = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_inovelli_2_channel_smart_plug, + can_handle = require("inovelli-2-channel-smart-plug.can_handle") } return inovelli_2_channel_smart_plug diff --git a/drivers/SmartThings/zwave-switch/src/inovelli-LED/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli-LED/init.lua deleted file mode 100644 index 36bf1e0716..0000000000 --- a/drivers/SmartThings/zwave-switch/src/inovelli-LED/init.lua +++ /dev/null @@ -1,111 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local capabilities = require "st.capabilities" ---- @type st.utils -local utils = require "st.utils" ---- @type st.zwave.constants -local constants = require "st.zwave.constants" ---- @type st.zwave.CommandClass -local cc = require "st.zwave.CommandClass" ---- @type st.zwave.CommandClass.Configuration -local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) - -local LED_COLOR_CONTROL_PARAMETER_NUMBER = 13 -local LED_GENERIC_SATURATION = 100 -local INOVELLI_MANUFACTURER_ID = 0x031E -local INOVELLI_LZW31SN_PRODUCT_TYPE = 0x0001 -local INOVELLI_LZW31_PRODUCT_TYPE = 0x0003 -local INOVELLI_DIMMER_PRODUCT_ID = 0x0001 -local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" - -local function huePercentToZwaveValue(value) - if value <= 2 then - return 0 - elseif value >= 98 then - return 255 - else - return utils.round(value / 100 * 255) - end -end - -local function zwaveValueToHuePercent(value) - if value <= 2 then - return 0 - elseif value >= 254 then - return 100 - else - return utils.round(value / 255 * 100) - end -end - -local function configuration_report(driver, device, cmd) - if cmd.args.parameter_number == LED_COLOR_CONTROL_PARAMETER_NUMBER then - local hue = zwaveValueToHuePercent(cmd.args.configuration_value) - - local ledBarComponent = device.profile.components[LED_BAR_COMPONENT_NAME] - if ledBarComponent ~= nil then - device:emit_component_event(ledBarComponent, capabilities.colorControl.hue(hue)) - device:emit_component_event(ledBarComponent, capabilities.colorControl.saturation(LED_GENERIC_SATURATION)) - end - end -end - -local function set_color(driver, device, cmd) - local value = huePercentToZwaveValue(cmd.args.color.hue) - local config = Configuration:Set({ - parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER, - configuration_value=value, - size=2 - }) - device:send(config) - - local query_configuration = function() - device:send(Configuration:Get({ parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER })) - end - - device.thread:call_with_delay(constants.DEFAULT_GET_STATUS_DELAY, query_configuration) -end - -local function can_handle_inovelli_led(opts, driver, device, ...) - if device:id_match( - INOVELLI_MANUFACTURER_ID, - {INOVELLI_LZW31SN_PRODUCT_TYPE, INOVELLI_LZW31_PRODUCT_TYPE}, - INOVELLI_DIMMER_PRODUCT_ID - ) then - local subdriver = require("inovelli-LED") - return true, subdriver - end - return false -end - -local inovelli_led = { - NAME = "Inovelli LED", - zwave_handlers = { - [cc.CONFIGURATION] = { - [Configuration.REPORT] = configuration_report - } - }, - capability_handlers = { - [capabilities.colorControl.ID] = { - [capabilities.colorControl.commands.setColor.NAME] = set_color - } - }, - can_handle = can_handle_inovelli_led, - sub_drivers = { - require("inovelli-LED/inovelli-lzw31sn") - } -} - -return inovelli_led diff --git a/drivers/SmartThings/zwave-switch/src/inovelli-LED/inovelli-lzw31sn/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli-LED/inovelli-lzw31sn/init.lua deleted file mode 100644 index 307ee9b46a..0000000000 --- a/drivers/SmartThings/zwave-switch/src/inovelli-LED/inovelli-lzw31sn/init.lua +++ /dev/null @@ -1,112 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local capabilities = require "st.capabilities" ---- @type st.zwave.CommandClass -local cc = require "st.zwave.CommandClass" ---- @type st.zwave.CommandClass.CentralScene -local CentralScene = (require "st.zwave.CommandClass.CentralScene")({version=3}) - -local INOVELLI_MANUFACTURER_ID = 0x031E -local INOVELLI_LZW31SN_PRODUCT_TYPE = 0x0001 -local INOVELLI_DIMMER_PRODUCT_ID = 0x0001 -local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" - -local supported_button_values = { - ["button1"] = {"pushed", "pushed_2x", "pushed_3x", "pushed_4x", "pushed_5x"}, - ["button2"] = {"pushed", "pushed_2x", "pushed_3x", "pushed_4x", "pushed_5x"}, - ["button3"] = {"pushed"} -} - -local function device_added(driver, device) - for _, component in pairs(device.profile.components) do - if component.id ~= "main" and component.id ~= LED_BAR_COMPONENT_NAME then - device:emit_component_event( - component, - capabilities.button.supportedButtonValues( - supported_button_values[component.id], - { visibility = { displayed = false } } - ) - ) - device:emit_component_event( - component, - capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) - ) - end - end - device:refresh() -end - -local map_scene_number_to_component = { - [1] = "button2", - [2] = "button1", - [3] = "button3" -} - - -local map_key_attribute_to_capability = { - [CentralScene.key_attributes.KEY_PRESSED_1_TIME] = capabilities.button.button.pushed, - [CentralScene.key_attributes.KEY_PRESSED_2_TIMES] = capabilities.button.button.pushed_2x, - [CentralScene.key_attributes.KEY_PRESSED_3_TIMES] = capabilities.button.button.pushed_3x, - [CentralScene.key_attributes.KEY_PRESSED_4_TIMES] = capabilities.button.button.pushed_4x, - [CentralScene.key_attributes.KEY_PRESSED_5_TIMES] = capabilities.button.button.pushed_5x, -} - -local function central_scene_notification_handler(self, device, cmd) - if ( cmd.args.scene_number ~= nil and cmd.args.scene_number ~= 0 ) then - local capability_attribute = map_key_attribute_to_capability[cmd.args.key_attributes] - local additional_fields = { - state_change = true - } - - local event - if capability_attribute ~= nil then - event = capability_attribute(additional_fields) - end - - if event ~= nil then - -- device reports scene notifications from endpoint 0 (main) but central scene events have to be emitted for button components: 1,2,3 - local comp = device.profile.components[map_scene_number_to_component[cmd.args.scene_number]] - if comp ~= nil then - device:emit_component_event(comp, event) - end - end - end -end - -local function can_handle_inovelli_lzw31sn(opts, driver, device, ...) - if device:id_match( - INOVELLI_MANUFACTURER_ID, - INOVELLI_LZW31SN_PRODUCT_TYPE, - INOVELLI_DIMMER_PRODUCT_ID - ) then - return true - end - return false -end - -local inovelli_led_lzw31sn = { - NAME = "Inovelli LED LZW 31SN", - zwave_handlers = { - [cc.CENTRAL_SCENE] = { - [CentralScene.NOTIFICATION] = central_scene_notification_handler - } - }, - lifecycle_handlers = { - added = device_added - }, - can_handle = can_handle_inovelli_lzw31sn -} - -return inovelli_led_lzw31sn diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua new file mode 100644 index 0000000000..c8ba7ac681 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local INOVELLI_FINGERPRINTS = { + { mfr = 0x031E, prod = 0x0017, model = 0x0001 }, -- Inovelli VZW32-SN + { mfr = 0x031E, prod = 0x0015, model = 0x0001 }, -- Inovelli VZW31-SN + { mfr = 0x031E, prod = 0x0001, model = 0x0001 }, -- Inovelli LZW31SN + { mfr = 0x031E, prod = 0x0003, model = 0x0001 }, -- Inovelli LZW31 +} + +local function can_handle_inovelli(opts, driver, device, ...) + for _, fingerprint in ipairs(INOVELLI_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("inovelli") + return true, subdriver + end + end + return false +end + +return can_handle_inovelli \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli/init.lua new file mode 100644 index 0000000000..84ed2c7df7 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/init.lua @@ -0,0 +1,501 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Configuration +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +--- @type st.zwave.CommandClass.Association +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +--- @type st.zwave.CommandClass.SwitchBinary +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) +--- @type st.zwave.CommandClass.Basic +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +--- @type st.zwave.CommandClass.SwitchMultilevel +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +local preferencesMap = require "preferences" + +--- @type st.utils +local utils = require "st.utils" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local log = require "log" +local st_device = require "st.device" + +--- @type st.zwave.CommandClass.CentralScene +local CentralScene = (require "st.zwave.CommandClass.CentralScene")({version=3}) +--- @type st.zwave.constants +local constants = require "st.zwave.constants" + +local LATEST_CLOCK_SET_TIMESTAMP = "latest_clock_set_timestamp" + +local GEN3_NOTIFICATION_PARAMETER_NUMBER = 99 +local GEN2_NOTIFICATION_PARAMETER_NUMBER = 16 +local LED_COLOR_CONTROL_PARAMETER_NUMBER = 13 +local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" +local LED_GENERIC_SATURATION = 100 + +-- TODO: Remove after transition period - supportedButtonValues initialization +-- This table defines the supported button values for each button component. +-- Used to initialize supportedButtonValues on device_added and update devices with old values. +local supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed"} +} + +-- Device type detection helpers +local function is_gen2(device) + return device:id_match(0x031E, {0x0001, 0x0003}, 0x0001) +end + +local function is_gen3(device) + return device:id_match(0x031E, {0x0015, 0x0017}, 0x0001) +end + +-- Helper function to get the correct notification parameter number based on device type +local function get_notification_parameter_number(device) + -- For child devices, check the parent device type + local device_to_check = device + if device.network_type == st_device.NETWORK_TYPE_CHILD then + device_to_check = device:get_parent_device() + end + + if is_gen3(device_to_check) then + return GEN3_NOTIFICATION_PARAMETER_NUMBER + else + return GEN2_NOTIFICATION_PARAMETER_NUMBER + end +end + +local function button_to_component(buttonId) + if buttonId > 0 then + return string.format("button%d", buttonId) + end +end + +local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return utils.round(value / 100 * 255) + end +end + +local function valueToHuePercent(value) + if value <= 2 then + return 0 + elseif value >= 254 then + return 100 + else + return utils.round(value / 255 * 100) + end +end + +local preferences_to_numeric_value = function(new_value) + local numeric = tonumber(new_value) + if numeric == nil then -- in case the value is boolean + numeric = new_value and 1 or 0 + end + return numeric +end + +local preferences_calculate_parameter = function(new_value, type, number) + if type == 4 and new_value > 2147483647 then + return ((4294967296 - new_value) * -1) + elseif type == 2 and new_value > 32767 then + return ((65536 - new_value) * -1) + elseif type == 1 and new_value > 127 then + return ((256 - new_value) * -1) + else + return new_value + end +end + +local function add_child(driver,parent,profile,child_type) + local child_metadata = { + type = "EDGE_CHILD", + label = string.format("%s %s", parent.label, child_type:gsub("(%l)(%w*)", function(a,b) return string.upper(a)..b end)), + profile = profile, + parent_device_id = parent.id, + parent_assigned_child_key = child_type, + vendor_provided_label = string.format("%s %s", parent.label, child_type:gsub("(%l)(%w*)", function(a,b) return string.upper(a)..b end)) + } + driver:try_create_device(child_metadata) +end + +local function getNotificationValue(device, value) + local level = device:get_latest_state("main", capabilities.switchLevel.ID, capabilities.switchLevel.level.NAME) or 100 + local color = utils.round(device:get_latest_state("main", capabilities.colorControl.ID, capabilities.colorControl.hue.NAME) or 100) + local effect = device:get_parent_device().preferences.notificationType or 1 + local duration = 255 -- Default duration + + -- Get the parent device to check generation for child devices + local device_to_check = device + if device.network_type == st_device.NETWORK_TYPE_CHILD then + device_to_check = device:get_parent_device() + end + + local colorValue = huePercentToValue(value or color) + local notificationValue = 0 + + if is_gen3(device_to_check) then + -- Gen3 order: duration, level, color, effect (bytes 0-3 from low to high) + notificationValue = notificationValue + (effect * 16777216) -- byte 3 (highest) + notificationValue = notificationValue + (colorValue * 65536) -- byte 2 + notificationValue = notificationValue + (level * 256) -- byte 1 + notificationValue = notificationValue + (duration * 1) -- byte 0 (lowest) + else + -- Gen2 order: color, level, duration, effect (bytes 0-3 from low to high) + notificationValue = notificationValue + (effect * 16777216) -- byte 3 (highest) + notificationValue = notificationValue + (duration * 65536) -- byte 2 + notificationValue = notificationValue + (level * 256) -- byte 1 + notificationValue = notificationValue + (colorValue * 1) -- byte 0 (lowest) + end + + return notificationValue +end + +local function set_color(driver, device, command) + if device.network_type == st_device.NETWORK_TYPE_CHILD then + device:emit_event(capabilities.colorControl.hue(command.args.color.hue)) + device:emit_event(capabilities.colorControl.saturation(command.args.color.saturation)) + device:emit_event(capabilities.switch.switch("on")) + local dev = device:get_parent_device() + local config = Configuration:Set({ + parameter_number=get_notification_parameter_number(device), + configuration_value=getNotificationValue(device), + size=4 + }) + local send_configuration = function() + dev:send(config) + end + device.thread:call_with_delay(1,send_configuration) + else + local value = huePercentToValue(command.args.color.hue) + local config = Configuration:Set({ + parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER, + configuration_value=value, + size=2 + }) + device:send(config) + + local query_configuration = function() + device:send(Configuration:Get({ parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER })) + end + + device.thread:call_with_delay(constants.DEFAULT_GET_STATUS_DELAY, query_configuration) + end +end + +local function set_color_temperature(driver, device, command) + if device.network_type == st_device.NETWORK_TYPE_CHILD then + device:emit_event(capabilities.colorControl.hue(100)) + device:emit_event(capabilities.colorTemperature.colorTemperature(command.args.temperature)) + device:emit_event(capabilities.switch.switch("on")) + local dev = device:get_parent_device() + local config = Configuration:Set({ + parameter_number=get_notification_parameter_number(device), + configuration_value=getNotificationValue(device, 100), + size=4 + }) + local send_configuration = function() + dev:send(config) + end + device.thread:call_with_delay(1,send_configuration) + else + local value = huePercentToValue(100) + local config = Configuration:Set({ + parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER, + configuration_value=value, + size=2 + }) + device:send(config) + + local query_configuration = function() + device:send(Configuration:Get({ parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER })) + end + + device.thread:call_with_delay(constants.DEFAULT_GET_STATUS_DELAY, query_configuration) + end +end + +local function switch_level_set(driver, device, command) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + local level = utils.round(command.args.level) + level = utils.clamp_value(level, 0, 99) + + device:send(SwitchMultilevel:Set({ value=level, duration=command.args.rate or "default" })) + + device.thread:call_with_delay(3, function(d) + device:send(SwitchMultilevel:Get({})) + end) + else + device:emit_event(capabilities.switchLevel.level(command.args.level)) + device:emit_event(capabilities.switch.switch(command.args.level ~= 0 and "on" or "off")) + local dev = device:get_parent_device() + local config = Configuration:Set({ + parameter_number=get_notification_parameter_number(device), + configuration_value=getNotificationValue(device), + size=4 + }) + local send_configuration = function() + dev:send(config) + end + device.thread:call_with_delay(1,send_configuration) + end +end + +local function refresh_handler(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(SwitchMultilevel:Get({})) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.WATTS })) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) + end +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(Association:Set({grouping_identifier = 1, node_ids = {driver.environment_info.hub_zwave_id}})) + refresh_handler(driver, device) + if is_gen2(device) then + local ledBarComponent = device.profile.components[LED_BAR_COMPONENT_NAME] + if ledBarComponent ~= nil then + device:emit_component_event(ledBarComponent, capabilities.colorControl.hue(1)) + device:emit_component_event(ledBarComponent, capabilities.colorControl.saturation(1)) + end + end + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local function info_changed(driver, device, event, args) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + local time_diff = 3 + local last_clock_set_time = device:get_field(LATEST_CLOCK_SET_TIMESTAMP) + if last_clock_set_time ~= nil then + time_diff = os.difftime(os.time(), last_clock_set_time) + end + device:set_field(LATEST_CLOCK_SET_TIMESTAMP, os.time(), {persist = true}) + if time_diff > 2 then + local preferences = preferencesMap.get_device_parameters(device) + if args.old_st_store.preferences["notificationChild"] ~= device.preferences.notificationChild and args.old_st_store.preferences["notificationChild"] == false and device.preferences.notificationChild == true then + if not device:get_child_by_parent_assigned_key('notification') then + add_child(driver,device,'rgbw-bulb','notification') + end + end + + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences and preferences[id] then + local new_parameter_value = preferences_calculate_parameter(preferences_to_numeric_value(device.preferences[id]), preferences[id].size, id) + device:send(Configuration:Set({parameter_number = preferences[id].parameter_number, size = preferences[id].size, configuration_value = new_parameter_value})) + end + end + else + log.info("info_changed running more than once. Cancelling this run. Time diff: " .. time_diff) + end + end +end + +local function switch_set_on_off_handler(value) + return function(driver, device, command) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(Basic:Set({ value = value })) + device.thread:call_with_delay(3, function(d) + device:send(SwitchMultilevel:Get({})) + end) + else + device:emit_event(capabilities.switch.switch(value == 0 and "off" or "on")) + local dev = device:get_parent_device() + local config = Configuration:Set({ + parameter_number=get_notification_parameter_number(device), + configuration_value=(value == 0 and 0 or getNotificationValue(device)), + size=4 + }) + local send_configuration = function() + dev:send(config) + end + device.thread:call_with_delay(1,send_configuration) + end + end +end + +local function configuration_report(driver, device, cmd) + if cmd.args.parameter_number == LED_COLOR_CONTROL_PARAMETER_NUMBER and is_gen2(device) then + local hue = valueToHuePercent(cmd.args.configuration_value) + + local ledBarComponent = device.profile.components[LED_BAR_COMPONENT_NAME] + if ledBarComponent ~= nil then + device:emit_component_event(ledBarComponent, capabilities.colorControl.hue(hue)) + device:emit_component_event(ledBarComponent, capabilities.colorControl.saturation(LED_GENERIC_SATURATION)) + end + end +end + +local map_key_attribute_to_capability = { + [CentralScene.key_attributes.KEY_PRESSED_1_TIME] = capabilities.button.button.pushed, + [CentralScene.key_attributes.KEY_RELEASED] = capabilities.button.button.held, + [CentralScene.key_attributes.KEY_HELD_DOWN] = capabilities.button.button.down_hold, + [CentralScene.key_attributes.KEY_PRESSED_2_TIMES] = capabilities.button.button.pushed_2x, + [CentralScene.key_attributes.KEY_PRESSED_3_TIMES] = capabilities.button.button.pushed_3x, + [CentralScene.key_attributes.KEY_PRESSED_4_TIMES] = capabilities.button.button.pushed_4x, + [CentralScene.key_attributes.KEY_PRESSED_5_TIMES] = capabilities.button.button.pushed_5x, +} + +-- Map key attributes to their button value strings for support checking +-- TODO: This mapping and the support check below can likely be removed after a transition period. +-- Once users have interacted with their devices and the supportedButtonValues gets properly +-- set during device initialization, the driver will know which values are supported and +-- won't attempt to emit unsupported events. This code is a temporary safeguard to prevent +-- errors during the transition period. +local map_key_attribute_to_value = { + [CentralScene.key_attributes.KEY_RELEASED] = "held", + [CentralScene.key_attributes.KEY_HELD_DOWN] = "down_hold", +} + +-- TODO: Remove after transition period - button value support checking +-- Helper function to check if a button value is supported. +-- This function can likely be removed after a transition period once devices have +-- their supportedButtonValues properly set. See comment above map_key_attribute_to_value. +local function is_button_value_supported(device, component, value) + if value == nil then + return true -- If no value to check, assume supported + end + + local supported_values_state = device:get_latest_state( + component.id, + capabilities.button.ID, + capabilities.button.supportedButtonValues.NAME + ) + + -- Check multiple possible structures for supportedButtonValues + -- In SmartThings Edge, get_latest_state returns a state object + -- For supportedButtonValues, the array could be in: state.value, or state itself IS the array + local supported_values = nil + if supported_values_state ~= nil then + -- First check .value property (most common structure) + if supported_values_state.value ~= nil then + supported_values = supported_values_state.value + -- Check if state itself is an array (the state IS the array) + -- Check if index 1 exists - if it does and .value doesn't exist, the state itself is the array + elseif type(supported_values_state) == "table" and supported_values_state[1] ~= nil then + supported_values = supported_values_state + end + + -- Check .state.value structure (nested structure) + if supported_values == nil and supported_values_state.state ~= nil and supported_values_state.state.value ~= nil then + supported_values = supported_values_state.state.value + end + end + + if supported_values == nil then + return true -- If no supported values set, assume all are supported (backward compatibility) + end + + -- Check if the value is in the supported values array + if type(supported_values) == "table" then + for _, supported_value in ipairs(supported_values) do + if supported_value == value then + return true + end + end + end + + return false +end + +local function central_scene_notification_handler(self, device, cmd) + if ( cmd.args.scene_number ~= nil and cmd.args.scene_number ~= 0 ) then + local button_number = cmd.args.scene_number + local capability_attribute = map_key_attribute_to_capability[cmd.args.key_attributes] + local additional_fields = { + state_change = true + } + + local event + if capability_attribute ~= nil then + event = capability_attribute(additional_fields) + end + + if event ~= nil then + -- device reports scene notifications from endpoint 0 (main) but central scene events have to be emitted for button components: 1,2,3 + local component_name = button_to_component(button_number) + local comp = device.profile.components[component_name] + if comp ~= nil then + -- TODO: Remove after transition period - button value support checking + -- Check if held or down_hold are supported before emitting. + -- This support check can likely be removed after a transition period once devices + -- have their supportedButtonValues properly set. The driver will then only emit events + -- for values that are actually supported, preventing errors. See comment above map_key_attribute_to_value. + local button_value = map_key_attribute_to_value[cmd.args.key_attributes] + local is_supported = is_button_value_supported(device, comp, button_value) + if button_value == nil or is_supported then + device:emit_component_event(comp, event) + else + -- TODO: Remove after transition period - supportedButtonValues update for old devices + -- Update supportedButtonValues for devices with old values from previous driver versions. + -- After updating, emit the event since the value is now supported. + if supported_button_values[comp.id] ~= nil then + device:emit_component_event( + comp, + capabilities.button.supportedButtonValues( + supported_button_values[comp.id], + { visibility = { displayed = false } } + ) + ) + device:emit_component_event(comp, event) + end + end + end + end + end +end + +------------------------------------------------------------------------------------------- +-- Register message handlers and run driver +------------------------------------------------------------------------------------------- +local inovelli = { + NAME = "Inovelli Z-Wave Switch", + lifecycle_handlers = { + infoChanged = info_changed, + added = device_added, + }, + zwave_handlers = { + [cc.CENTRAL_SCENE] = { + [CentralScene.NOTIFICATION] = central_scene_notification_handler + }, + [cc.CONFIGURATION] = { + [Configuration.REPORT] = configuration_report + } + }, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.switch.on.NAME] = switch_set_on_off_handler(SwitchBinary.value.ON_ENABLE), + [capabilities.switch.switch.off.NAME] = switch_set_on_off_handler(SwitchBinary.value.OFF_DISABLE) + }, + [capabilities.colorControl.ID] = { + [capabilities.colorControl.commands.setColor.NAME] = set_color + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temperature + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = switch_level_set + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + } + }, + can_handle = require("inovelli.can_handle"), + sub_drivers = require("inovelli.sub_drivers"), +} + +return inovelli \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/can_handle.lua new file mode 100644 index 0000000000..6b70eb3c27 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_LZW31SN_PRODUCT_TYPE = 0x0001 +local INOVELLI_DIMMER_PRODUCT_ID = 0x0001 + +local function can_handle_lzw31sn(opts, driver, device, ...) + if device:id_match( + INOVELLI_MANUFACTURER_ID, + INOVELLI_LZW31SN_PRODUCT_TYPE, + INOVELLI_DIMMER_PRODUCT_ID + ) then + return true, require("inovelli.lzw31-sn") + end + return false +end + +return can_handle_lzw31sn diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/init.lua new file mode 100644 index 0000000000..77a5430681 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/lzw31-sn/init.lua @@ -0,0 +1,74 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.SwitchMultilevel +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +--- @type st.zwave.CommandClass.Association +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +--- @type st.device +local st_device = require "st.device" + +local supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed"} +} + +local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" + +local function refresh_handler(driver, device) + device:send(SwitchMultilevel:Get({})) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.WATTS })) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(Association:Set({grouping_identifier = 1, node_ids = {driver.environment_info.hub_zwave_id}})) + for _, component in pairs(device.profile.components) do + if component.id ~= "main" and component.id ~= LED_BAR_COMPONENT_NAME then + device:emit_component_event( + component, + capabilities.button.supportedButtonValues( + supported_button_values[component.id], + { visibility = { displayed = false } } + ) + ) + device:emit_component_event( + component, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + end + end + refresh_handler(driver, device) + local ledBarComponent = device.profile.components[LED_BAR_COMPONENT_NAME] + if ledBarComponent ~= nil then + device:emit_component_event(ledBarComponent, capabilities.colorControl.hue(1)) + device:emit_component_event(ledBarComponent, capabilities.colorControl.saturation(1)) + end + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local lzw31_sn = { + NAME = "Inovelli LZW31-SN Z-Wave Dimmer", + lifecycle_handlers = { + added = device_added, + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + } + }, + can_handle = require("inovelli.lzw31-sn.can_handle") +} + +return lzw31_sn \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua new file mode 100644 index 0000000000..2fdea81379 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("inovelli.lzw31-sn"), + lazy_load("inovelli.vzw31-sn"), + lazy_load("inovelli.vzw32-sn") +} diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua new file mode 100644 index 0000000000..2446c06dde --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_DIMMER_PRODUCT_ID = 0x0001 + +local function can_handle_vzw31_sn(opts, driver, device, ...) + if device:id_match( + INOVELLI_MANUFACTURER_ID, + INOVELLI_VZW31_SN_PRODUCT_TYPE, + INOVELLI_DIMMER_PRODUCT_ID + ) then + return true, require("inovelli.vzw31-sn") + end + return false +end + +return can_handle_vzw31_sn diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua new file mode 100644 index 0000000000..1dec88e745 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua @@ -0,0 +1,77 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.SwitchMultilevel +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +--- @type st.zwave.CommandClass.Association +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +--- @type st.device +local st_device = require "st.device" +local cc = require "st.zwave.CommandClass" + + +local supported_button_values = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} + + +local function refresh_handler(driver, device) + device:send(SwitchMultilevel:Get({})) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.WATTS })) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(Association:Set({grouping_identifier = 1, node_ids = {driver.environment_info.hub_zwave_id}})) + for _, component in pairs(device.profile.components) do + if component.id ~= "main" and component.id ~= "LEDColorConfiguration" then + device:emit_component_event( + component, + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + device:emit_component_event( + component, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + end + end + refresh_handler(driver, device) + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local function onoff_level_report_handler(driver, device, cmd) + local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value + device:emit_event(value == 0 and capabilities.switch.switch.off() or capabilities.switch.switch.on()) + device:emit_event(capabilities.switchLevel.level(value)) +end + +local vzw31_sn = { + NAME = "Inovelli VZW31-SN Dimmer", + lifecycle_handlers = { + added = device_added, + }, + zwave_handlers = { + [cc.SWITCH_MULTILEVEL] = { + [SwitchMultilevel.REPORT] = onoff_level_report_handler + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + } + }, + can_handle = require("inovelli.vzw31-sn.can_handle") +} + +return vzw31_sn \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/can_handle.lua new file mode 100644 index 0000000000..5ed4e272e0 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW32_SN_PRODUCT_TYPE = 0x0017 +local INOVELLI_DIMMER_PRODUCT_ID = 0x0001 + +local function can_handle_vzw32_sn(opts, driver, device, ...) + if device:id_match( + INOVELLI_MANUFACTURER_ID, + INOVELLI_VZW32_SN_PRODUCT_TYPE, + INOVELLI_DIMMER_PRODUCT_ID + ) then + return true, require("inovelli.vzw32-sn") + end + return false +end + +return can_handle_vzw32_sn diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/init.lua new file mode 100644 index 0000000000..0ad1d31430 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw32-sn/init.lua @@ -0,0 +1,73 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.SwitchMultilevel +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +--- @type st.zwave.CommandClass.SensorMultilevel +local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 7 }) +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +--- @type st.zwave.CommandClass.Association +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +--- @type st.device +local st_device = require "st.device" + +local supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} +} + +local function refresh_handler(driver, device) + device:send(SwitchMultilevel:Get({})) + device:send(SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ILLUMINANCE})) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.WATTS })) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) + device:send(Notification:Get({notification_type = Notification.notification_type.HOME_SECURITY, event = Notification.event.home_security.MOTION_DETECTION})) +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(Association:Set({grouping_identifier = 1, node_ids = {driver.environment_info.hub_zwave_id}})) + for _, component in pairs(device.profile.components) do + if component.id ~= "main" and component.id ~= "LEDColorConfiguration" then + device:emit_component_event( + component, + capabilities.button.supportedButtonValues( + supported_button_values[component.id], + { visibility = { displayed = false } } + ) + ) + device:emit_component_event( + component, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + end + end + refresh_handler(driver, device) + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local vzw32_sn = { + NAME = "Inovelli VZW32-SN mmWave Dimmer", + lifecycle_handlers = { + added = device_added, + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + } + }, + can_handle = require("inovelli.vzw32-sn.can_handle") +} + +return vzw32_sn \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-switch/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-switch/src/multi-metering-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/can_handle.lua new file mode 100644 index 0000000000..89fd6ef17f --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_multi_metering_switch(opts, driver, device, ...) + local fingerprints = require("multi-metering-switch.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("multi-metering-switch") + return true, subdriver + end + end + return false +end + +return can_handle_multi_metering_switch diff --git a/drivers/SmartThings/zwave-switch/src/multi-metering-switch/fingerprints.lua b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/fingerprints.lua new file mode 100644 index 0000000000..d35e06a5f0 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/fingerprints.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = 0x0086, prod = 0x0003, model = 0x0084}, -- Aeotec Nano Switch 1 + {mfr = 0x0086, prod = 0x0103, model = 0x0084}, -- Aeotec Nano Switch 1 + {mfr = 0x0086, prod = 0x0203, model = 0x0084}, -- AU Aeotec Nano Switch 1 + {mfr = 0x027A, prod = 0xA000, model = 0xA004}, -- Zooz ZEN Power Strip 1 + {mfr = 0x015F, prod = 0x3102, model = 0x0201}, -- WYFY Touch 1-button Switch + {mfr = 0x015F, prod = 0x3102, model = 0x0202}, -- WYFY Touch 2-button Switch + {mfr = 0x015F, prod = 0x3102, model = 0x0204}, -- WYFY Touch 4-button Switch + {mfr = 0x015F, prod = 0x3111, model = 0x5102}, -- WYFY Touch 1-button Switch + {mfr = 0x015F, prod = 0x3121, model = 0x5102}, -- WYFY Touch 2-button Switch + {mfr = 0x015F, prod = 0x3141, model = 0x5102}, -- WYFY Touch 4-button Switch + {mfr = 0x0460, prod = 0x0002, model = 0x0081}, -- Shelly Wave 2PM + {mfr = 0x0460, prod = 0x0002, model = 0x008C}, -- Shelly Wave Pro 2 + {mfr = 0x0460, prod = 0x0002, model = 0x008D}, -- Shelly Wave Pro 2PM +} diff --git a/drivers/SmartThings/zwave-switch/src/multi-metering-switch/init.lua b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/init.lua index a4113ad851..ca4c8951d9 100644 --- a/drivers/SmartThings/zwave-switch/src/multi-metering-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local st_device = require "st.device" local utils = require "st.utils" @@ -31,29 +20,6 @@ local MULTI_METERING_SWITCH_CONFIGURATION_MAP = require "multi-metering-switch/m local PARENT_ENDPOINT = 1 -local MULTI_METERING_SWITCH_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0003, model = 0x0084}, -- Aeotec Nano Switch 1 - {mfr = 0x0086, prod = 0x0103, model = 0x0084}, -- Aeotec Nano Switch 1 - {mfr = 0x0086, prod = 0x0203, model = 0x0084}, -- AU Aeotec Nano Switch 1 - {mfr = 0x027A, prod = 0xA000, model = 0xA004}, -- Zooz ZEN Power Strip 1 - {mfr = 0x015F, prod = 0x3102, model = 0x0201}, -- WYFY Touch 1-button Switch - {mfr = 0x015F, prod = 0x3102, model = 0x0202}, -- WYFY Touch 2-button Switch - {mfr = 0x015F, prod = 0x3102, model = 0x0204}, -- WYFY Touch 4-button Switch - {mfr = 0x015F, prod = 0x3111, model = 0x5102}, -- WYFY Touch 1-button Switch - {mfr = 0x015F, prod = 0x3121, model = 0x5102}, -- WYFY Touch 2-button Switch - {mfr = 0x015F, prod = 0x3141, model = 0x5102} -- WYFY Touch 4-button Switch -} - -local function can_handle_multi_metering_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(MULTI_METERING_SWITCH_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("multi-metering-switch") - return true, subdriver - end - end - return false -end - local function find_child(parent, ep_id) if ep_id == PARENT_ENDPOINT then return parent @@ -119,6 +85,20 @@ local function do_refresh(driver, device, command) -- should be deleted when v46 end end +-- Do not use native handlers due to unique component to endpoint mapping +local function switch_on_handler(driver, device, cmd) + switchDefaults.capability_handlers[capabilities.switch.commands.on](driver, device, cmd, false) +end + +local function switch_off_handler(driver, device, cmd) + switchDefaults.capability_handlers[capabilities.switch.commands.off](driver, device, cmd, false) +end + +local function set_level_handler(driver, device, cmd) + local defaults = require "st.zwave.defaults.switchLevel" + defaults.capability_handlers[capabilities.switchLevel.commands.setLevel](driver, device, cmd, false) +end + local function meter_report_handler(driver, device, cmd) -- We got a meter report from the root node, so refresh all children -- endpoint 0 should have its reports dropped @@ -158,7 +138,14 @@ local multi_metering_switch = { }, [capabilities.energyMeter.ID] = { [capabilities.energyMeter.commands.resetEnergyMeter.NAME] = reset - } + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_on_handler, + [capabilities.switch.commands.off.NAME] = switch_off_handler, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = set_level_handler, + }, }, zwave_handlers = { [cc.METER] = { @@ -175,7 +162,7 @@ local multi_metering_switch = { init = device_init, added = device_added }, - can_handle = can_handle_multi_metering_switch, + can_handle = require("multi-metering-switch.can_handle"), } return multi_metering_switch diff --git a/drivers/SmartThings/zwave-switch/src/multi-metering-switch/multi_metering_switch_configurations.lua b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/multi_metering_switch_configurations.lua index eadffba01c..af047646d5 100644 --- a/drivers/SmartThings/zwave-switch/src/multi-metering-switch/multi_metering_switch_configurations.lua +++ b/drivers/SmartThings/zwave-switch/src/multi-metering-switch/multi_metering_switch_configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local devices = { AEOTEC_NANO_SWITCH_1 = { @@ -113,7 +102,29 @@ local devices = { CONFIGURATION = { child_switch_device_profile = "switch-binary" } - } + }, + SHELLY_WAVE_2PM_AND_PRO = { + MATCHING_MATRIX = { + mfrs = 0x0460, + product_ids = {0x0081, 0x008D}, + product_types = 0x0002, + children = 1 + }, + CONFIGURATION = { + child_switch_device_profile = "metering-switch" + } + }, + SHELLY_WAVE_PRO_2 = { + MATCHING_MATRIX = { + mfrs = 0x0460, + product_ids = 0x008C, + product_types = 0x0002, + children = 1 + }, + CONFIGURATION = { + child_switch_device_profile = "switch-binary" + } + }, } local multi_metering_switch_configurations = {} diff --git a/drivers/SmartThings/zwave-switch/src/multichannel-device/can_handle.lua b/drivers/SmartThings/zwave-switch/src/multichannel-device/can_handle.lua new file mode 100644 index 0000000000..49464a3240 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/multichannel-device/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +local function can_handle_multichannel_device(opts, driver, device, ...) + if device:supports_capability(capabilities.zwMultichannel) then + local subdriver = require("multichannel-device") + return true, subdriver + end + return false +end + +return can_handle_multichannel_device diff --git a/drivers/SmartThings/zwave-switch/src/multichannel-device/init.lua b/drivers/SmartThings/zwave-switch/src/multichannel-device/init.lua index 54349673e8..82cba1330f 100644 --- a/drivers/SmartThings/zwave-switch/src/multichannel-device/init.lua +++ b/drivers/SmartThings/zwave-switch/src/multichannel-device/init.lua @@ -1,18 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local cc = require "st.zwave.CommandClass" -local capabilities = require "st.capabilities" local st_device = require "st.device" local MultiChannel = (require "st.zwave.CommandClass.MultiChannel")({ version = 3 }) local utils = require "st.utils" @@ -27,14 +15,6 @@ local map_device_class_to_profile = { [0xA1] = "generic-sensor" } -local function can_handle_multichannel_device(opts, driver, device, ...) - if device:supports_capability(capabilities.zwMultichannel) then - local subdriver = require("multichannel-device") - return true, subdriver - end - return false -end - local function find_child(device, src_channel) if src_channel == 0 then return device @@ -91,7 +71,7 @@ local multichannel_device = { [MultiChannel.CAPABILITY_REPORT] = capability_get_report_handler } }, - can_handle = can_handle_multichannel_device + can_handle = require("multichannel-device.can_handle") } -return multichannel_device \ No newline at end of file +return multichannel_device diff --git a/drivers/SmartThings/zwave-switch/src/preferences.lua b/drivers/SmartThings/zwave-switch/src/preferences.lua index 583632e76c..09155cdd7d 100644 --- a/drivers/SmartThings/zwave-switch/src/preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/preferences.lua @@ -1,16 +1,39 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HEAVY_DUTY_SWITCH = { + PARAMETERS = { + overloadProtection = { type = 'config', parameter_number = 3, size = 1 }, + ledAfterPower = { type = 'config', parameter_number = 20, size = 1 }, + autoReportType = { type = 'config', parameter_number = 80, size = 1 }, + powerThreshold = { type = 'config', parameter_number = 90, size = 1 }, + group1Sensors = { type = 'config', parameter_number = 101, size = 4 }, + group2Sensors = { type = 'config', parameter_number = 102, size = 4 }, + group3Sensors = { type = 'config', parameter_number = 103, size = 4 }, + group1Time = { type = 'config', parameter_number = 111, size = 4 }, + group2Time = { type = 'config', parameter_number = 112, size = 4 }, + group3Time = { type = 'config', parameter_number = 113, size = 4 }, + assocGroup1 = { type = 'assoc', group = 1, maxnodes = 5, addhub = true } + } +} + +local SWITCH_US = { + PARAMETERS = { + thresholdKwh = { type = 'config', parameter_number = 20, size = 2 }, + thresholdPower = { type = 'config', parameter_number = 21, size = 2 }, + assocGroup1 = { type = 'assoc', group = 1, maxnodes = 5, addhub = true } + } +} + +local SWITCH_EU = { + PARAMETERS = { + thresholdPower = { type = 'config', parameter_number = 91, size = 2 }, + thresholdKwh = { type = 'config', parameter_number = 92, size = 2 }, + group1Sensors = { type = 'config', parameter_number = 101, size = 4 }, + group1Time = { type = 'config', parameter_number = 111, size = 4 }, + assocGroup1 = { type = 'assoc', group = 1, maxnodes = 5, addhub = true } + } +} local devices = { INOVELLI = { @@ -36,6 +59,71 @@ local devices = { switchType = {parameter_number = 22, size = 1} } }, + INOVELLI_VZW31_SN = { + MATCHING_MATRIX = { + mfrs = 0x031E, + product_types = {0x0015}, + product_ids = 0x0001 + }, + PARAMETERS = { + parameter158 = {parameter_number = 158, size = 1}, + parameter52 = {parameter_number = 52, size = 1}, + parameter1 = {parameter_number = 1, size = 1}, + parameter2 = {parameter_number = 2, size = 1}, + parameter3 = {parameter_number = 3, size = 1}, + parameter4 = {parameter_number = 4, size = 1}, + parameter9 = {parameter_number = 9, size = 1}, + parameter10 = {parameter_number = 10, size = 1}, + parameter15 = {parameter_number = 15, size = 1}, + parameter18 = {parameter_number = 18, size = 1}, + parameter19 = {parameter_number = 19, size = 2}, + parameter20 = {parameter_number = 20, size = 2}, + parameter22 = {parameter_number = 22, size = 1}, + parameter50 = {parameter_number = 50, size = 1}, + parameter95 = {parameter_number = 95, size = 1}, + parameter96 = {parameter_number = 96, size = 1}, + parameter97 = {parameter_number = 97, size = 1}, + parameter98 = {parameter_number = 98, size = 1}, + } + }, + INOVELLI_VZW32_SN = { + MATCHING_MATRIX = { + mfrs = 0x031E, + product_types = {0x0017}, + product_ids = 0x0001 + }, + PARAMETERS = { + parameter158 = {parameter_number = 158, size = 1}, + parameter52 = {parameter_number = 52, size = 1}, + parameter1 = {parameter_number = 1, size = 1}, + parameter2 = {parameter_number = 2, size = 1}, + parameter3 = {parameter_number = 3, size = 1}, + parameter4 = {parameter_number = 4, size = 1}, + parameter9 = {parameter_number = 9, size = 1}, + parameter10 = {parameter_number = 10, size = 1}, + parameter15 = {parameter_number = 15, size = 1}, + parameter18 = {parameter_number = 18, size = 1}, + parameter19 = {parameter_number = 19, size = 2}, + parameter20 = {parameter_number = 20, size = 2}, + parameter50 = {parameter_number = 50, size = 1}, + parameter95 = {parameter_number = 95, size = 1}, + parameter96 = {parameter_number = 96, size = 1}, + parameter97 = {parameter_number = 97, size = 1}, + parameter98 = {parameter_number = 98, size = 1}, + parameter101 = {parameter_number = 101, size = 2}, + parameter102 = {parameter_number = 102, size = 2}, + parameter103 = {parameter_number = 103, size = 2}, + parameter104 = {parameter_number = 104, size = 2}, + parameter105 = {parameter_number = 105, size = 2}, + parameter106 = {parameter_number = 106, size = 2}, + parameter108 = {parameter_number = 108, size = 4}, + parameter110 = {parameter_number = 110, size = 2}, + parameter111 = {parameter_number = 111, size = 1}, + parameter112 = {parameter_number = 112, size = 1}, + parameter113 = {parameter_number = 113, size = 1}, + parameter114 = {parameter_number = 114, size = 4} + } + }, QUBINO_FLUSH_DIMMER = { MATCHING_MATRIX = { mfrs = 0x0159, @@ -301,6 +389,44 @@ local devices = { dimmerPaddleControl = {parameter_number = 27, size = 1} } }, + ZOOZ_ZEN05 = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x7000, + product_ids = 0xB001 + }, + PARAMETERS = { + ledMode = { parameter_number = 1, size = 1 }, + autoTurnOff = { parameter_number = 2, size = 4 }, + autoTurnOn = { parameter_number = 4, size = 4 }, + powerRecovery = { parameter_number = 6, size = 1 }, + ledBrightness = { parameter_number = 7, size = 1 }, + manualControl = { parameter_number = 8, size = 1 }, + } + }, + AEOTEC_HEAVY_DUTY = { + MATCHING_MATRIX = { + mfrs = 0x0086, + product_ids = 0x004E + }, + PARAMETERS = AEOTEC_HEAVY_DUTY_SWITCH.PARAMETERS + }, + AEOTEC_SMART_SWITCH_US = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = 0x0103, + product_ids = 0x0017 + }, + PARAMETERS = SWITCH_US.PARAMETERS + }, + AEOTEC_SMART_SWITCH_7_EU = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = 0x0003, + product_ids = 0x00AF + }, + PARAMETERS = SWITCH_EU.PARAMETERS + }, SWITCH_LEVEL_INDICATOR = { MATCHING_MATRIX = { mfrs = 0x0063, @@ -318,6 +444,34 @@ local devices = { PARAMETERS = { ledIndicator = {parameter_number = 3, size = 1} } + }, + LEVITON_DIMMER = { + MATCHING_MATRIX = { + mfrs = 0x001D, + product_types = 0x0041, + product_ids = 0x0002 + }, + PARAMETERS = { + fadeOnTime = {parameter_number = 1, size = 4}, + fadeOffTime = {parameter_number = 2, size = 4}, + minLevel = {parameter_number = 3, size = 4}, + maxLevel = {parameter_number = 4, size = 4}, + presetLevel = {parameter_number = 5, size = 4}, + dimBarTimeout = {parameter_number = 6, size = 4}, + statusLEDmode = {parameter_number = 7, size = 4}, + loadType = {parameter_number = 8, size = 4}, + controlTiming = {parameter_number = 9, size = 4} + } + }, + LEVITON_SWITCH = { + MATCHING_MATRIX = { + mfrs = 0x001D, + product_types = 0x0042, + product_ids = 0x0002 + }, + PARAMETERS = { + statusLEDmode = {parameter_number = 7, size = 4} + } } } diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/can_handle.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/can_handle.lua new file mode 100644 index 0000000000..3312c13838 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local constants = require "qubino-switches.constants.qubino-constants" + +local function can_handle_qubino_flush_relay(opts, driver, device, cmd, ...) + if device:id_match(constants.QUBINO_MFR) then + local subdriver = require("qubino-switches") + return true, subdriver + end + return false +end + +return can_handle_qubino_flush_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/constants/qubino-constants.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/constants/qubino-constants.lua index 31613f40b9..8395eb78fe 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/constants/qubino-constants.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/constants/qubino-constants.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 return { TEMP_SENSOR_WORK_THRESHOLD = -20, -- Celsius degrees diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/fingerprints.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/fingerprints.lua new file mode 100644 index 0000000000..056b743e8f --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/fingerprints.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = 0x0159, prod = 0x0001, model = 0x0051, deviceProfile = "qubino-flush-dimmer"}, -- Qubino Flush Dimmer + {mfr = 0x0159, prod = 0x0001, model = 0x0052, deviceProfile = "qubino-din-dimmer"}, -- Qubino DIN Dimmer + {mfr = 0x0159, prod = 0x0001, model = 0x0053, deviceProfile = "qubino-flush-dimmer-0-10V"}, -- Qubino Flush Dimmer 0-10V + {mfr = 0x0159, prod = 0x0001, model = 0x0055, deviceProfile = "qubino-mini-dimmer"}, -- Qubino Mini Dimmer + {mfr = 0x0159, prod = 0x0002, model = 0x0051, deviceProfile = "qubino-flush2-relay"}, -- Qubino Flush 2 Relay + {mfr = 0x0159, prod = 0x0002, model = 0x0052, deviceProfile = "qubino-flush1-relay"}, -- Qubino Flush 1 Relay + {mfr = 0x0159, prod = 0x0002, model = 0x0053, deviceProfile = "qubino-flush1d-relay"} -- Qubino Flush 1D Relay +} diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/init.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/init.lua index d883aa4821..15d541366a 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/init.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -26,19 +15,11 @@ local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=1}) local constants = require "qubino-switches/constants/qubino-constants" -local QUBINO_FINGERPRINTS = { - {mfr = 0x0159, prod = 0x0001, model = 0x0051, deviceProfile = "qubino-flush-dimmer"}, -- Qubino Flush Dimmer - {mfr = 0x0159, prod = 0x0001, model = 0x0052, deviceProfile = "qubino-din-dimmer"}, -- Qubino DIN Dimmer - {mfr = 0x0159, prod = 0x0001, model = 0x0053, deviceProfile = "qubino-flush-dimmer-0-10V"}, -- Qubino Flush Dimmer 0-10V - {mfr = 0x0159, prod = 0x0001, model = 0x0055, deviceProfile = "qubino-mini-dimmer"}, -- Qubino Mini Dimmer - {mfr = 0x0159, prod = 0x0002, model = 0x0051, deviceProfile = "qubino-flush2-relay"}, -- Qubino Flush 2 Relay - {mfr = 0x0159, prod = 0x0002, model = 0x0052, deviceProfile = "qubino-flush1-relay"}, -- Qubino Flush 1 Relay - {mfr = 0x0159, prod = 0x0002, model = 0x0053, deviceProfile = "qubino-flush1d-relay"} -- Qubino Flush 1D Relay -} +local fingerprints = require("qubino-switches.fingerprints") local function getDeviceProfile(device, isTemperatureSensorOnboard) local newDeviceProfile - for _, fingerprint in ipairs(QUBINO_FINGERPRINTS) do + for _, fingerprint in ipairs(fingerprints) do if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then newDeviceProfile = fingerprint.deviceProfile if(isTemperatureSensorOnboard) then @@ -51,14 +32,6 @@ local function getDeviceProfile(device, isTemperatureSensorOnboard) return nil end -local function can_handle_qubino_flush_relay(opts, driver, device, cmd, ...) - if device:id_match(constants.QUBINO_MFR) then - local subdriver = require("qubino-switches") - return true, subdriver - end - return false -end - local function add_temperature_sensor_if_needed(device) if not (device:supports_capability_by_id(capabilities.temperatureMeasurement.ID)) then local new_profile = getDeviceProfile(device, true) @@ -112,7 +85,7 @@ end local qubino_relays = { NAME = "Qubino Relays", - can_handle = can_handle_qubino_flush_relay, + can_handle = require("qubino-switches.can_handle"), zwave_handlers = { [cc.SENSOR_MULTILEVEL] = { [SensorMultilevel.REPORT] = sensor_multilevel_report @@ -126,10 +99,7 @@ local qubino_relays = { lifecycle_handlers = { added = device_added }, - sub_drivers = { - require("qubino-switches/qubino-relays"), - require("qubino-switches/qubino-dimmer") - } + sub_drivers = require("qubino-switches.sub_drivers"), } return qubino_relays diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/can_handle.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/can_handle.lua new file mode 100644 index 0000000000..9588fdccae --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_qubino_dimmer(opts, driver, device, ...) + local fingerprints = require("qubino-switches.qubino-dimmer.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("qubino-switches.qubino-dimmer") + end + end + return false +end + +return can_handle_qubino_dimmer diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/fingerprints.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/fingerprints.lua new file mode 100644 index 0000000000..32946fe973 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + {mfr = 0x0159, prod = 0x0001, model = 0x0051}, -- Qubino Flush Dimmer + {mfr = 0x0159, prod = 0x0001, model = 0x0052}, -- Qubino DIN Dimmer + {mfr = 0x0159, prod = 0x0001, model = 0x0053}, -- Qubino Flush Dimmer 0-10V + {mfr = 0x0159, prod = 0x0001, model = 0x0055} -- Qubino Mini Dimmer +} diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/init.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/init.lua index 03023adffa..d0f0e607a5 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/init.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/init.lua @@ -1,35 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local MultichannelAssociation = (require "st.zwave.CommandClass.MultiChannelAssociation")({ version = 3 }) -local QUBINO_DIMMER_FINGERPRINTS = { - {mfr = 0x0159, prod = 0x0001, model = 0x0051}, -- Qubino Flush Dimmer - {mfr = 0x0159, prod = 0x0001, model = 0x0052}, -- Qubino DIN Dimmer - {mfr = 0x0159, prod = 0x0001, model = 0x0053}, -- Qubino Flush Dimmer 0-10V - {mfr = 0x0159, prod = 0x0001, model = 0x0055} -- Qubino Mini Dimmer -} - -local function can_handle_qubino_dimmer(opts, driver, device, ...) - for _, fingerprint in ipairs(QUBINO_DIMMER_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local function do_configure(self, device) device:send(MultichannelAssociation:Remove({grouping_identifier = 1, node_ids = {}})) device:send(MultichannelAssociation:Set({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) @@ -40,10 +13,8 @@ local qubino_dimmer = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_qubino_dimmer, - sub_drivers = { - require("qubino-switches/qubino-dimmer/qubino-din-dimmer") - } + can_handle = require("qubino-switches.qubino-dimmer.can_handle"), + sub_drivers = require("qubino-switches.qubino-dimmer.sub_drivers"), } return qubino_dimmer diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/can_handle.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/can_handle.lua new file mode 100644 index 0000000000..b0da891a74 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_qubino_din_dimmer(opts, driver, device, ...) + -- Qubino Din Dimmer: mfr = 0x0159, prod = 0x0001, model = 0x0052 + if device:id_match(0x0159, 0x0001, 0x0052) then + return true, require("qubino-switches.qubino-dimmer.qubino-din-dimmer") + end + return false +end + +return can_handle_qubino_din_dimmer diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/init.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/init.lua index f763c36c34..e47d4b149f 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/init.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/qubino-din-dimmer/init.lua @@ -1,28 +1,9 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) local MultichannelAssociation = (require "st.zwave.CommandClass.MultiChannelAssociation")({ version = 3 }) -local function can_handle_qubino_din_dimmer(opts, driver, device, ...) - -- Qubino Din Dimmer: mfr = 0x0159, prod = 0x0001, model = 0x0052 - if device:id_match(0x0159, 0x0001, 0x0052) then - return true - end - return false -end - local function do_configure(self, device) device:send(MultichannelAssociation:Remove({grouping_identifier = 1, node_ids = {}})) device:send(MultichannelAssociation:Set({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) @@ -34,7 +15,7 @@ local qubino_din_dimmer = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_qubino_din_dimmer + can_handle = require("qubino-switches.qubino-dimmer.qubino-din-dimmer.can_handle") } return qubino_din_dimmer diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/sub_drivers.lua new file mode 100644 index 0000000000..96da43c3e6 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-dimmer/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("qubino-switches.qubino-dimmer.qubino-din-dimmer"), +} diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/can_handle.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/can_handle.lua new file mode 100644 index 0000000000..4eb7c04595 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_qubino_flush_relay(opts, driver, device, cmd, ...) + local fingerprints = { + {mfr = 0x0159, prod = 0x0002, model = 0x0051}, -- Qubino Flush 2 Relay + {mfr = 0x0159, prod = 0x0002, model = 0x0052}, -- Qubino Flush 1 Relay + {mfr = 0x0159, prod = 0x0002, model = 0x0053} -- Qubino Flush 1D Relay + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("qubino-switches.qubino-relays") + end + end + return false +end + +return can_handle_qubino_flush_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/init.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/init.lua index b6e3918cfb..8199235310 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/init.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/init.lua @@ -1,34 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local Association = (require "st.zwave.CommandClass.Association")({ version = 2 }) -local QUBINO_FLUSH_RELAY_FINGERPRINT = { - {mfr = 0x0159, prod = 0x0002, model = 0x0051}, -- Qubino Flush 2 Relay - {mfr = 0x0159, prod = 0x0002, model = 0x0052}, -- Qubino Flush 1 Relay - {mfr = 0x0159, prod = 0x0002, model = 0x0053} -- Qubino Flush 1D Relay -} - -local function can_handle_qubino_flush_relay(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(QUBINO_FLUSH_RELAY_FINGERPRINT) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local function do_configure(self, device) local association_cmd = Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}}) -- This command needs to be sent before creating component @@ -40,12 +14,8 @@ end local qubino_relays = { NAME = "Qubino Relays", - can_handle = can_handle_qubino_flush_relay, - sub_drivers = { - require("qubino-switches/qubino-relays/qubino-flush-2-relay"), - require("qubino-switches/qubino-relays/qubino-flush-1-relay"), - require("qubino-switches/qubino-relays/qubino-flush-1d-relay") - }, + can_handle = require("qubino-switches.qubino-relays.can_handle"), + sub_drivers = require("qubino-switches.qubino-relays.sub_drivers"), lifecycle_handlers = { doConfigure = do_configure }, diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/can_handle.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/can_handle.lua new file mode 100644 index 0000000000..f91589bce9 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local QUBINO_FLUSH_1_RELAY_FINGERPRINT = {mfr = 0x0159, prod = 0x0002, model = 0x0052} + +local function can_handle_qubino_flush_1_relay(opts, driver, device, ...) + if device:id_match(QUBINO_FLUSH_1_RELAY_FINGERPRINT.mfr, QUBINO_FLUSH_1_RELAY_FINGERPRINT.prod, QUBINO_FLUSH_1_RELAY_FINGERPRINT.model) then + return true, require("qubino-switches.qubino-relays.qubino-flush-1-relay") + end + return false +end + +return can_handle_qubino_flush_1_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/init.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/init.lua index 7fef39539b..61cf89394e 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/init.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1-relay/init.lua @@ -1,28 +1,11 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass.Association local Association = (require "st.zwave.CommandClass.Association")({version=2}) --- @type st.zwave.CommandClass.MultiChannelAssociation local MultiChannelAssociation = (require "st.zwave.CommandClass.MultiChannelAssociation")({version=3}) -local QUBINO_FLUSH_1_RELAY_FINGERPRINT = {mfr = 0x0159, prod = 0x0002, model = 0x0052} - -local function can_handle_qubino_flush_1_relay(opts, driver, device, ...) - return device:id_match(QUBINO_FLUSH_1_RELAY_FINGERPRINT.mfr, QUBINO_FLUSH_1_RELAY_FINGERPRINT.prod, QUBINO_FLUSH_1_RELAY_FINGERPRINT.model) -end - local function do_configure(self, device) -- Hub automatically adds device to multiChannelAssosciationGroup and this needs to be removed device:send(MultiChannelAssociation:Remove({grouping_identifier = 1, node_ids = {}})) @@ -40,7 +23,7 @@ local qubino_flush_1_relay = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_qubino_flush_1_relay + can_handle = require("qubino-switches.qubino-relays.qubino-flush-1-relay.can_handle") } return qubino_flush_1_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/can_handle.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/can_handle.lua new file mode 100644 index 0000000000..9d5f69bb34 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local QUBINO_FLUSH_1D_RELAY_FINGERPRINT = {mfr = 0x0159, prod = 0x0002, model = 0x0053} + +local function can_handle_qubino_flush_1d_relay(opts, driver, device, ...) + if device:id_match(QUBINO_FLUSH_1D_RELAY_FINGERPRINT.mfr, QUBINO_FLUSH_1D_RELAY_FINGERPRINT.prod, QUBINO_FLUSH_1D_RELAY_FINGERPRINT.model) then + return true, require("qubino-switches.qubino-relays.qubino-flush-1d-relay") + end + return false +end + +return can_handle_qubino_flush_1d_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/init.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/init.lua index 55b900920c..8803a85af2 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/init.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-1d-relay/init.lua @@ -1,25 +1,8 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local MultichannelAssociation = (require "st.zwave.CommandClass.MultiChannelAssociation")({ version = 3 }) -local QUBINO_FLUSH_1D_RELAY_FINGERPRINT = {mfr = 0x0159, prod = 0x0002, model = 0x0053} - -local function can_handle_qubino_flush_1d_relay(opts, driver, device, ...) - return device:id_match(QUBINO_FLUSH_1D_RELAY_FINGERPRINT.mfr, QUBINO_FLUSH_1D_RELAY_FINGERPRINT.prod, QUBINO_FLUSH_1D_RELAY_FINGERPRINT.model) -end - local function do_configure(self, device) device:send(MultichannelAssociation:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) device:refresh() @@ -30,7 +13,7 @@ local qubino_flush_1d_relay = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_qubino_flush_1d_relay + can_handle = require("qubino-switches.qubino-relays.qubino-flush-1d-relay.can_handle") } return qubino_flush_1d_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/can_handle.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/can_handle.lua new file mode 100644 index 0000000000..f3cb5f83e9 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local QUBINO_FLUSH_2_RELAY_FINGERPRINT = { mfr = 0x0159, prod = 0x0002, model = 0x0051 } + +local function can_handle_qubino_flush_2_relay(opts, driver, device, ...) + if device:id_match(QUBINO_FLUSH_2_RELAY_FINGERPRINT.mfr, QUBINO_FLUSH_2_RELAY_FINGERPRINT.prod, QUBINO_FLUSH_2_RELAY_FINGERPRINT.model) then + return true, require("qubino-switches.qubino-relays.qubino-flush-2-relay") + end + return false +end + +return can_handle_qubino_flush_2_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/init.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/init.lua index c0a8d0c967..8f60cc1e58 100644 --- a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/init.lua +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/qubino-flush-2-relay/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local st_device = require "st.device" local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -30,12 +19,6 @@ local utils = require "st.utils" local CHILD_SWITCH_EP = 2 local CHILD_TEMP_SENSOR_EP = 3 -local QUBINO_FLUSH_2_RELAY_FINGERPRINT = { mfr = 0x0159, prod = 0x0002, model = 0x0051 } - -local function can_handle_qubino_flush_2_relay(opts, driver, device, ...) - return device:id_match(QUBINO_FLUSH_2_RELAY_FINGERPRINT.mfr, QUBINO_FLUSH_2_RELAY_FINGERPRINT.prod, QUBINO_FLUSH_2_RELAY_FINGERPRINT.model) -end - local function component_to_endpoint(device, component_id) return { 1 } end @@ -178,7 +161,7 @@ local qubino_flush_2_relay = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - can_handle = can_handle_qubino_flush_2_relay + can_handle = require("qubino-switches.qubino-relays.qubino-flush-2-relay.can_handle") } return qubino_flush_2_relay diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/sub_drivers.lua new file mode 100644 index 0000000000..27319d943b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/qubino-relays/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" +return { + lazy_load("qubino-switches.qubino-relays.qubino-flush-1-relay"), + lazy_load("qubino-switches.qubino-relays.qubino-flush-1d-relay"), + lazy_load("qubino-switches.qubino-relays.qubino-flush-2-relay"), +} diff --git a/drivers/SmartThings/zwave-switch/src/qubino-switches/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/qubino-switches/sub_drivers.lua new file mode 100644 index 0000000000..09a60bf335 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/qubino-switches/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("qubino-switches.qubino-relays"), + lazy_load("qubino-switches.qubino-dimmer"), +} diff --git a/drivers/SmartThings/zwave-switch/src/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/sub_drivers.lua new file mode 100644 index 0000000000..b4cd3d57e9 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/sub_drivers.lua @@ -0,0 +1,28 @@ + +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("eaton-accessory-dimmer"), + lazy_load_if_possible("inovelli"), + lazy_load_if_possible("dawon-smart-plug"), + lazy_load_if_possible("inovelli-2-channel-smart-plug"), + lazy_load_if_possible("zwave-dual-switch"), + lazy_load_if_possible("eaton-anyplace-switch"), + lazy_load_if_possible("fibaro-wall-plug-us"), + lazy_load_if_possible("dawon-wall-smart-switch"), + lazy_load_if_possible("zooz-power-strip"), + lazy_load_if_possible("aeon-smart-strip"), + lazy_load_if_possible("qubino-switches"), + lazy_load_if_possible("fibaro-double-switch"), + lazy_load_if_possible("fibaro-single-switch"), + lazy_load_if_possible("eaton-5-scene-keypad"), + lazy_load_if_possible("ecolink-switch"), + lazy_load_if_possible("multi-metering-switch"), + lazy_load_if_possible("zooz-zen-30-dimmer-relay"), + lazy_load_if_possible("multichannel-device"), + lazy_load_if_possible("aeotec-smart-switch"), + lazy_load_if_possible("aeotec-heavy-duty") +} diff --git a/drivers/SmartThings/zwave-switch/src/switch_utils.lua b/drivers/SmartThings/zwave-switch/src/switch_utils.lua new file mode 100644 index 0000000000..66ad4715f9 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/switch_utils.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local switch_utils = {} + +switch_utils.emit_event_if_latest_state_missing = function(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +return switch_utils diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua index 0985633cdd..d0db48c559 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -161,6 +150,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {6}}) ) } + }, + { + min_api_version = 17 } ) @@ -247,6 +239,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {6}}) ) } + }, + { + min_api_version = 17 } ) @@ -295,6 +290,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {3}}) ) } + }, + { + min_api_version = 17 } ) @@ -343,6 +341,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {4}}) ) } + }, + { + min_api_version = 17 } ) @@ -374,6 +375,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("switch1", capabilities.energyMeter.energy({ value = 50.0, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -405,6 +409,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("switch2", capabilities.energyMeter.energy({ value = 50.0, unit = "kVAh" })) } + }, + { + min_api_version = 17 } ) @@ -436,6 +443,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("switch3", capabilities.powerMeter.power({ value = 50, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -503,6 +513,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {6}}) ) } + }, + { + min_api_version = 17 } ) @@ -534,6 +547,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("main", capabilities.powerMeter.power({ value = 50, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -603,7 +619,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 112, size = 4, configuration_value = 90}) )) mock_metering_switch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -734,7 +753,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -865,7 +887,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -953,7 +978,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1041,7 +1069,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1129,7 +1160,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1217,7 +1251,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua index f45ca1104f..ad9197c159 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -69,7 +58,10 @@ test.register_coroutine_test( Configuration:Set({ parameter_number=111, size=4, configuration_value=300 }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -106,7 +98,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -128,6 +121,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -138,7 +139,7 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -160,6 +161,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -170,7 +179,7 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -196,6 +205,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -204,6 +221,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 17 } ) @@ -229,11 +249,27 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(5)) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, { channel = "zwave", direction = "send", @@ -242,6 +278,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 17 } ) @@ -262,7 +301,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -284,6 +334,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -297,6 +350,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -332,6 +386,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -348,7 +403,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -362,6 +420,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -397,6 +456,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -413,7 +473,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -426,6 +489,8 @@ test.register_coroutine_test( mock_device.id, { capability = "switchLevel", command = "setLevel", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -443,7 +508,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua index c5c3331ba6..6f35d6200c 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -122,7 +111,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua new file mode 100644 index 0000000000..2d9b6e24c5 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua @@ -0,0 +1,523 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local capabilities = require "st.capabilities" + +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_smart_switch_endpoints = { + { + command_classes = { + { value = zw.SWITCH_BINARY }, + { value = zw.METER }, + { value = zw.CONFIGURATION } + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("aeotec-heavy-duty.yml"), + zwave_endpoints = aeotec_smart_switch_endpoints, + zwave_manufacturer_id = 0x0086, + zwave_product_type = 0x0003, + zwave_product_id = 0x004E +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Power meter report should be ignored", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 27 + }) + ) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 27, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Energy meter report should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Meter:Report({ + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }) + ) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Refresh shall include only energy Meter:Get()", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", command = "refresh", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchBinary:Get({}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Energy meter reset should send a reset command", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {} } }) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, Meter:Reset({}))) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + -- set time to trigger power consumption report + local current_time = os.time() - 60 * 20 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zwave:__queue_receive( + { + mock_device.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + })) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting switch (binary) off should generate correct zwave messages", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "switch", command = "off", args = {} } + } + ) + mock_device:expect_native_cmd_handler_registration("switch", "off") + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchBinary:Set({ + target_value = SwitchBinary.value.OFF_ENABLE, + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting switch (binary) on should generate correct zwave messages", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "switch", command = "on", args = {} } + } + ) + mock_device:expect_native_cmd_handler_registration("switch", "on") + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchBinary:Set({ + target_value = SwitchBinary.value.ON_ENABLE, + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: overloadProtection in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + overloadProtection = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 3, + configuration_value = 1, + size = 1 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: ledAfterPower in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + ledAfterPower = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 20, + configuration_value = 1, + size = 1 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: autoReportType in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + autoReportType = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 80, + configuration_value = 1, + size = 1 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: powerThreshold in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + powerThreshold = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 90, + configuration_value = 1, + size = 1 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: group1Sensors in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + group1Sensors = 13 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 101, + configuration_value = 13, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: group2Sensors in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + group2Sensors = 5 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 102, + configuration_value = 5, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: group3Sensors in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + group3Sensors = 5 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 103, + configuration_value = 5, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: group1Time in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + group1Time = 400 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 111, + configuration_value = 400, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: group2Time in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + group2Time = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 112, + configuration_value = 3500, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: group3Time in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + group3Time = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 113, + configuration_value = 3500, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle preference: group3Sensors in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ + preferences = { + group3Sensors = 5 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 103, + configuration_value = 5, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua index d0d87cce8e..c26c0403e4 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -74,7 +63,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua index 4dabb4c6bb..2c009b5ec7 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -93,7 +82,10 @@ test.register_coroutine_test( Configuration:Set({ parameter_number=113, size=4, configuration_value=300 }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -130,7 +122,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -152,6 +145,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -162,7 +163,7 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -184,6 +185,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -194,7 +203,7 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -220,6 +229,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -228,6 +245,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 17 } ) @@ -253,11 +273,27 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(5)) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, { channel = "zwave", direction = "send", @@ -266,6 +302,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 17 } ) @@ -286,7 +325,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -308,6 +358,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -321,6 +374,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -356,6 +410,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -372,7 +427,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -386,6 +444,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -421,6 +480,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -437,7 +497,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -450,6 +513,8 @@ test.register_coroutine_test( mock_device.id, { capability = "switchLevel", command = "setLevel", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -467,7 +532,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua index dba043703f..5a349d80cb 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -68,7 +57,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua index d5878b6fbc..c61e1835a8 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -69,7 +58,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -96,7 +88,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_configuration.lua deleted file mode 100644 index 70d1bdd92f..0000000000 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_configuration.lua +++ /dev/null @@ -1,64 +0,0 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zw = require "st.zwave" -local zw_test_utils = require "integration_test.zwave_test_utils" - -local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) - - -local aeotec_smart_switch_7_endpoints = { - { - command_classes = { - { value = zw.BASIC }, - { value = zw.SWITCH_BINARY }, - { value = zw.METER }, - } - } -} - -local mock_device = test.mock_device.build_test_zwave_device({ - profile = t_utils.get_profile_definition("metering-switch.yml"), - zwave_endpoints = aeotec_smart_switch_7_endpoints, - zwave_manufacturer_id = 0x0371, - zwave_product_type = 0x0103, - zwave_product_id = 0x0017 -}) - -local function test_init() - test.mock_device.add_test_device(mock_device) -end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "Device should be configured", - function() - test.socket.zwave:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) - test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( - mock_device, - Configuration:Set({ - parameter_number = 21, - size = 2, - configuration_value = 2 - }) - )) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua new file mode 100644 index 0000000000..fcaed8070a --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua @@ -0,0 +1,231 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" + +local utils = require "st.utils" + +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version=1 }) +local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) +local SwitchColor = (require "st.zwave.CommandClass.SwitchColor")({ version=3 }) +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_smart_switch_7_endpoints = { + { + command_classes = { + { value = zw.BASIC }, + { value = zw.SWITCH_BINARY }, + { value = zw.METER } + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("aeotec-smart-switch-7-eu.yml"), + zwave_endpoints = aeotec_smart_switch_7_endpoints, + zwave_manufacturer_id = 0x0371, + zwave_product_type = 0x0003, + zwave_product_id = 0x00AF +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device should use Basic SETs and GETs despite supporting Switch Multilevel (on)", + function () + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", command = "on", args = {}} + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Basic:Set({ + value = 0xFF + }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchBinary:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device should use Basic SETs and GETs despite supporting Switch Multilevel (off)", + function () + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", command = "off", args = {}} + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Basic:Set({ + value = 0x00 + }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchBinary:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Power meter report should be handled by main component", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.WATTS, + meter_value = 55 + }) + )} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Energy meter report should be handled by main component", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }) + )} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + + local current_time = os.time() - 60 * 20 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zwave:__queue_receive( + { + mock_device.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + })) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end, + { + min_api_version = 17 + } +) + +do + local hue = math.random(0, 100) + local sat = math.random(0, 100) + local r, g, b = utils.hsl_to_rgb(hue, sat) + test.register_coroutine_test( + "Color Control capability setColor commands should evoke the correct Z-Wave SETs and GETs", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "colorControl", + command = "setColor", + args = { + { + hue = hue, + saturation = sat + } + } + } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchColor:Set({ + color_components = { + { color_component_id = SwitchColor.color_component_id.RED, value = r }, + { color_component_id = SwitchColor.color_component_id.GREEN, value = g }, + { color_component_id = SwitchColor.color_component_id.BLUE, value = b }, + } + }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) + ) + ) + end, + { + min_api_version = 17 + } + + ) +end + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua new file mode 100644 index 0000000000..51f7b504ca --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua @@ -0,0 +1,200 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" + +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version=1 }) +local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_smart_switch_7_endpoints = { + { + command_classes = { + { value = zw.BASIC }, + { value = zw.SWITCH_BINARY }, + { value = zw.METER } + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("aeotec-smart-switch-7-us.yml"), + zwave_endpoints = aeotec_smart_switch_7_endpoints, + zwave_manufacturer_id = 0x0371, + zwave_product_type = 0x0103, + zwave_product_id = 0x0017 +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device should be configured", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_device, + Configuration:Set({ + parameter_number = 21, + size = 2, + configuration_value = 2 + }) + )) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device should use Basic SETs and GETs despite supporting Switch Multilevel (on)", + function () + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", command = "on", args = {}} + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Basic:Set({ + value = 0xFF + }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchBinary:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Device should use Basic SETs and GETs despite supporting Switch Multilevel (off)", + function () + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", command = "off", args = {}} + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + Basic:Set({ + value = 0x00 + }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + SwitchBinary:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Power meter report should be handled by main component", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.WATTS, + meter_value = 55 + }) + )} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Energy meter report should be handled by main component", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }) + )} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + + local current_time = os.time() - 60 * 20 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zwave:__queue_receive( + { + mock_device.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + })) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua index a1e3607ce3..565d6b57aa 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -64,7 +53,10 @@ test.register_coroutine_test( Configuration:Set({ parameter_number = 103, configuration_value = 0, size = 4 }) )) mock_switch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua b/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua index 543d3b0b97..6615af4a12 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -58,6 +47,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -77,6 +69,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua index bfa8073280..3fe41b79db 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -86,6 +75,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 22 })) } + }, + { + min_api_version = 17 } ) @@ -112,6 +104,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -141,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch1", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -170,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch1", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -199,6 +200,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch2", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -228,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch2", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -257,6 +264,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch3", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -286,6 +296,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch3", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -306,7 +319,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 1, size = 2, configuration_value = 10 * 60}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -368,7 +384,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua index 5157669f72..29ff5d699e 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -85,7 +74,10 @@ test.register_coroutine_test( capabilities.switch.switch.off() ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -124,7 +116,10 @@ test.register_coroutine_test( capabilities.switch.switch.on() ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -150,7 +145,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -235,7 +233,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -270,7 +271,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -327,7 +331,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -372,7 +379,10 @@ test.register_coroutine_test( } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -386,7 +396,10 @@ test.register_coroutine_test( ) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -443,7 +456,10 @@ test.register_coroutine_test( } ) -- if group_id and scene_id are same, do nothing. - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -465,7 +481,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -480,7 +499,10 @@ test.register_coroutine_test( } ) -- driver should do nothing. - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -497,7 +519,10 @@ test.register_coroutine_test( Indicator:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -514,7 +539,10 @@ test.register_coroutine_test( Indicator:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua index d858721630..103f856373 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -53,6 +42,9 @@ test.register_message_test( direction = "receive", message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Report({ value = 0xFF })) } } + }, + { + min_api_version = 17 } ) @@ -74,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(0)) } + }, + { + min_api_version = 17 } ) @@ -89,7 +84,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -122,7 +128,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 17 } + ) end @@ -164,6 +174,9 @@ test.register_message_test( SwitchMultilevel:Get({}) ) } + }, + { + min_api_version = 17 } ) end @@ -188,7 +201,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -211,7 +227,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -236,7 +255,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua index 93783aa1a4..3c1fdfc9eb 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -45,6 +34,7 @@ test.set_test_init_function(test_init) test.register_message_test( "Basic SET 0x00 should be handled as switch off", { + -- The initial switch event should be send during the device's first time onboarding { channel = "device_lifecycle", direction = "receive", @@ -60,17 +50,37 @@ test.register_message_test( direction = "receive", message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({value=0x00})) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) + }, + -- Avoid sending the initial switch event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + { + channel = "device_lifecycle", + direction = "receive", + message = {mock_device.id, "added"} + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({value=0x00})) } + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) test.register_message_test( "Basic SET 0xFF should be handled as switch on", { + -- The initial switch event should be send during the device's first time onboarding { channel = "device_lifecycle", direction = "receive", @@ -86,11 +96,30 @@ test.register_message_test( direction = "receive", message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({value=0xFF})) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + -- Avoid sending the initial switch event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + { + channel = "device_lifecycle", + direction = "receive", + message = {mock_device.id, "added"} + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({value=0xFF})) } + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -111,7 +140,10 @@ test.register_coroutine_test( Basic:Report({value=0xFF}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -131,7 +163,10 @@ test.register_coroutine_test( Basic:Report({value=0x00}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -147,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -163,6 +201,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua index d939e78d12..10a784d8b4 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -52,7 +41,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number=7, configuration_value=1, size=1}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua index 1369345eb2..51ad9b8299 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -73,7 +62,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -103,7 +93,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -120,6 +111,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -140,10 +134,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -164,10 +166,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -188,10 +198,26 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -212,10 +238,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -239,7 +273,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -263,7 +298,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua index 86b830be19..29e6b9566e 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -144,6 +133,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -193,6 +185,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) test.register_message_test( @@ -240,6 +235,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -288,6 +286,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -325,7 +326,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -362,7 +366,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -399,7 +406,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -436,7 +446,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -586,7 +599,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -635,7 +651,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua index cafd5ee79b..4c8d058acf 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -64,7 +53,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -82,7 +74,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -112,6 +107,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -142,6 +140,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -172,6 +173,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -202,6 +206,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -229,6 +236,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -255,6 +265,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -275,6 +288,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.held({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -301,6 +317,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -321,6 +340,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -347,6 +369,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -367,6 +392,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.double({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -393,6 +421,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -413,6 +444,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.pushed_3x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -439,6 +473,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -458,6 +495,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -484,6 +524,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 17 } ) @@ -503,6 +546,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -529,6 +575,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -640,7 +689,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua index 94ccbdb384..669b01f1bd 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -117,7 +106,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua index 65636f6c22..a051ce72b2 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -66,7 +55,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua index f0ed1f2520..2fafc237b2 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -80,6 +69,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {1}}) ) }, + }, + { + min_api_version = 17 } ) @@ -114,6 +106,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {1}}) ) }, + }, + { + min_api_version = 17 } ) @@ -145,6 +140,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -176,6 +174,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("smartplug1", capabilities.powerMeter.power({ value = 89, unit = "W" })) } + }, + { + min_api_version = 17 } ) @@ -207,6 +208,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -238,6 +242,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("smartplug1", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -249,7 +256,10 @@ test.register_coroutine_test( mock_device, SwitchBinary:Set({target_value=0xFF},{dst_channels={1}}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -328,7 +338,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua index fc42034137..3dd35e66b9 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -62,7 +51,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -82,7 +75,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -102,7 +99,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -122,7 +123,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -142,7 +147,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -163,7 +172,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -183,7 +196,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua index 11746559a5..2ced0ef6a3 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -126,6 +115,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -179,6 +171,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -214,7 +209,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -246,6 +252,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -299,6 +308,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -352,6 +364,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -387,7 +402,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_child.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -424,6 +450,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -491,7 +520,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -557,7 +589,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -621,7 +656,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -687,7 +725,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -728,7 +769,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -760,7 +804,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -792,7 +839,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua index 3dbdd66622..1e2cfe45aa 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -62,7 +51,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -79,7 +71,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -96,7 +91,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -113,7 +111,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -130,7 +131,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -147,7 +151,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua b/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua index 1fd05d4440..5116eceada 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -55,7 +44,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -80,7 +70,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -100,14 +91,30 @@ test.register_message_test( direction = "send", message = mock_zwave_device1:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_zwave_device1.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_zwave_device1:generate_test_message("main", capabilities.switchLevel.level(100)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_zwave_device1.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -126,10 +133,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_zwave_device1:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_zwave_device1.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -149,14 +164,30 @@ test.register_message_test( direction = "send", message = mock_zwave_device1:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_zwave_device1.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_zwave_device1:generate_test_message("main", capabilities.switchLevel.level(49)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_zwave_device1.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -168,6 +199,7 @@ test.register_coroutine_test( mock_zwave_device1.id, { capability = "switch", command = "off", args = {} } }) + mock_zwave_device1:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_zwave_device1, @@ -182,7 +214,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -193,6 +228,7 @@ test.register_coroutine_test( mock_zwave_device1.id, { capability = "switch", command = "on", args = {} } }) + mock_zwave_device1:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_zwave_device1, @@ -207,7 +243,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) local level = 49 @@ -219,6 +258,7 @@ test.register_coroutine_test( mock_zwave_device1.id, { capability = "switchLevel", command = "setLevel", args = { level } } }) + mock_zwave_device1:expect_native_cmd_handler_registration("switchLevel", "setLevel") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_zwave_device1, @@ -233,7 +273,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua b/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua index abbce9082b..4827c600d8 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -58,7 +47,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua index cde284936a..66cbf38817 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -64,7 +53,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number=10, configuration_value=1, size=2}) )) mock_dimmer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua index 12753b2879..65ccf723b3 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -106,6 +95,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -167,6 +159,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -207,6 +202,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -247,6 +245,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -287,6 +288,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -327,6 +331,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -396,6 +403,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -466,6 +476,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -523,6 +536,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -580,6 +596,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -641,6 +660,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -702,6 +724,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -747,6 +772,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -792,6 +820,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -829,6 +860,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -867,6 +901,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -904,6 +941,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) @@ -942,6 +982,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua index 59fa304000..96668cfc24 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua @@ -1,23 +1,14 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local zw = require "st.zwave" local zw_test_utils = require "integration_test.zwave_test_utils" -local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) local CentralScene = (require "st.zwave.CommandClass.CentralScene")({version=1}) +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) local t_utils = require "integration_test.utils" local INOVELLI_MANUFACTURER_ID = 0x031E @@ -66,8 +57,8 @@ end test.set_test_init_function(test_init) local supported_button_values = { - ["button1"] = {"pushed", "pushed_2x", "pushed_3x", "pushed_4x", "pushed_5x"}, - ["button2"] = {"pushed", "pushed_2x", "pushed_3x", "pushed_4x", "pushed_5x"}, + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, ["button3"] = {"pushed"} } @@ -100,10 +91,48 @@ test.register_coroutine_test( test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_inovelli_dimmer, - Basic:Get({}) + Association:Set({ grouping_identifier = 1, node_ids = {}, payload = "\x01" }) ) ) - end + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_dimmer, + SwitchMultilevel:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_dimmer, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_dimmer, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + ) + + local ledBarComponent = mock_inovelli_dimmer.profile.components[LED_BAR_COMPONENT_NAME] + if ledBarComponent ~= nil then + test.socket.capability:__expect_send( + mock_inovelli_dimmer:generate_test_message( + LED_BAR_COMPONENT_NAME, + capabilities.colorControl.hue(1) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_dimmer:generate_test_message( + LED_BAR_COMPONENT_NAME, + capabilities.colorControl.saturation(1) + ) + ) + end + end, + { + min_api_version = 17 + } ) @@ -126,8 +155,11 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed({state_change = true})) + message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -150,8 +182,11 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_4x({ state_change = true })) + message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_4x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -176,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_dimmer:generate_test_message("button3", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua index 250182526d..0e71010aa3 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -57,7 +46,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -82,7 +72,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -110,6 +101,7 @@ test.register_message_test( } }, { + min_api_version = 17 } ) @@ -142,7 +134,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 17 } + ) end @@ -171,7 +167,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -199,7 +198,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) do @@ -229,7 +231,10 @@ do SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua index 3141e5021f..48fde4f573 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -108,7 +97,11 @@ do Configuration:Get({ parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -140,7 +133,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message(LED_BAR_COMPONENT_NAME, capabilities.colorControl.saturation(LED_GENERIC_SATURATION)) } + }, + { + min_api_version = 17 } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua index 7605ada316..4a3ab7f64a 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -80,7 +69,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -121,7 +111,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -143,7 +134,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message("main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" })) } + }, + { + min_api_version = 17 } + ) end @@ -165,7 +160,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message("main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 17 } + ) end @@ -199,6 +198,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -239,7 +241,11 @@ do Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } + ) end @@ -268,7 +274,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -296,7 +305,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) do @@ -326,7 +338,11 @@ do SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua index 6b4e978981..701fd6513a 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -65,7 +54,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -89,7 +82,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -113,7 +110,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -137,7 +138,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -162,7 +167,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua index 2f575d8e06..81e4eff5c0 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -65,9 +54,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed({ + message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -85,9 +77,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_2x({ + message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_2x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -105,9 +100,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_3x({ + message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_3x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -125,9 +123,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_4x({ + message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_4x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -145,9 +146,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_5x({ + message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_5x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -165,9 +169,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed({ + message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -185,9 +192,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_2x({ + message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -205,9 +215,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_3x({ + message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_3x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -225,9 +238,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_4x({ + message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_4x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -245,9 +261,12 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_5x({ + message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_5x({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -268,6 +287,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button3", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua new file mode 100644 index 0000000000..a372a94eec --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua @@ -0,0 +1,287 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2}) +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local CentralScene = (require "st.zwave.CommandClass.CentralScene")({version=3}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) +local Meter = (require "st.zwave.CommandClass.Meter")({version=3}) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 +local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + {value = zw.METER}, + } + } +} + +-- Create mock device +local mock_inovelli_vzw31_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw31_sn) +end +test.set_test_init_function(test_init) + +local supported_button_values = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} + +-- Test device initialization +test.register_coroutine_test( + "Device should initialize properly on added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_inovelli_vzw31_sn.id, "added" }) + + for button_name, _ in pairs(mock_inovelli_vzw31_sn.profile.components) do + if button_name ~= "main" and button_name ~= LED_BAR_COMPONENT_NAME then + test.socket.capability:__expect_send( + mock_inovelli_vzw31_sn:generate_test_message( + button_name, + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzw31_sn:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Association:Set({ + grouping_identifier = 1, + node_ids = {}, -- Mock hub Z-Wave ID + payload = "\x01", -- Should contain grouping_identifier = 1 + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + ) + end +) + +-- Test switch on command +test.register_coroutine_test( + "Switch on command should send Basic Set with ON value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switch", command = "on", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Basic:Set({ value = SwitchBinary.value.ON_ENABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test switch off command +test.register_coroutine_test( + "Switch off command should send Basic Set with OFF value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switch", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Basic:Set({ value = SwitchBinary.value.OFF_DISABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test switch level command +test.register_coroutine_test( + "Switch level command should send SwitchMultilevel Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switchLevel", command = "setLevel", args = { 50 } } + }) + + local expected_command = SwitchMultilevel:Set({ value = 50, duration = "default" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + expected_command + ) + ) + + test.wait_for_events() + test.mock_time.advance_time(3) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test central scene notifications +test.register_message_test( + "Central scene notification should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw31_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 1, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_1_TIME + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw31_sn:generate_test_message("button1", capabilities.button.button.pushed({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test central scene notifications - button2 pressed 4 times +test.register_message_test( + "Central scene notification button2 pressed 4 times should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw31_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 2, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_4_TIMES + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw31_sn:generate_test_message("button2", capabilities.button.button.pushed_4x({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test refresh capability +test.register_message_test( + "Refresh capability should request switch level and meter data", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzw31_sn.id, + { capability = "refresh", command = "refresh", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua new file mode 100644 index 0000000000..dea43715ab --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua @@ -0,0 +1,335 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=4}) +local t_utils = require "integration_test.utils" +local st_device = require "st.device" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + } + } +} + +-- Create mock parent device +local mock_parent_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +-- Create mock child device (notification device) +local mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("rgbw-bulb.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "notification" +}) + +-- Set child device network type +mock_child_device.network_type = st_device.NETWORK_TYPE_CHILD + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +-- Test child device initialization +test.register_message_test( + "Child device should initialize with default color values", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "added" }, + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.hue(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switchLevel.level(100)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test child device switch on command (Gen3 uses parameter 99, same as vzw32) +test.register_coroutine_test( + "Child device switch on should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "on", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device switch off command +test.register_coroutine_test( + "Child device switch off should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 0, -- Switch off sends 0 + size = 4 + }) + ) + ) + end +) + +-- Test child device level command +test.register_coroutine_test( + "Child device level command should emit events and send configuration to parent", + function() + local level = math.random(1, 99) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local effect = 1 -- Default notificationType + local color = 100 -- Default color for child devices (since device starts with no hue state) + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) -- Use the actual level from command + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switchLevel", command = "setLevel", args = { level } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switchLevel.level(level)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device color command +test.register_coroutine_test( + "Child device color command should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = math.random(0, 100) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorControl", command = "setColor", args = {{ hue = color, saturation = 100 }} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(color)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device color temperature command +test.register_coroutine_test( + "Child device color temperature command should emit events and send configuration to parent", + function() + local temp = math.random(2700, 6500) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorTemperature", command = "setColorTemperature", args = { temp } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 33514751, -- Calculated: effect(1)*16777216 + hue(255)*65536 + level(100)*256 + 255 + size = 4 + }) + ) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua new file mode 100644 index 0000000000..2b4812ad7b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua @@ -0,0 +1,151 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + { value = zw.SWITCH_BINARY }, + { value = zw.SWITCH_MULTILEVEL }, + { value = zw.BASIC }, + { value = zw.CONFIGURATION }, + { value = zw.CENTRAL_SCENE }, + { value = zw.ASSOCIATION }, + } + } +} + +-- Create mock device +local mock_inovelli_vzw31_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw31_sn) +end +test.set_test_init_function(test_init) + +-- Test parameter 1 (example preference) +do + local new_param_value = 10 + test.register_coroutine_test( + "Parameter 1 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter1 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 1, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 52 (example preference) +do + local new_param_value = 25 + test.register_coroutine_test( + "Parameter 52 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter52 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 52, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 158 (example preference) +do + local new_param_value = 5 + test.register_coroutine_test( + "Parameter 158 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter158 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 158, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 19 (2-byte parameter); must be non-default (default 3600) or driver won't send Configuration:Set +do + local new_param_value = 1800 + test.register_coroutine_test( + "Parameter 19 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter19 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 19, + configuration_value = new_param_value, + size = 2, + }) + ) + ) + end + ) +end + +-- Test notificationChild preference (special case for child device creation) +do + local new_param_value = true + test.register_coroutine_test( + "notificationChild preference should create child device when enabled", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {notificationChild = new_param_value}})) + + -- Expect child device creation + mock_inovelli_vzw31_sn:expect_device_create({ + type = "EDGE_CHILD", + label = "nil Notification", -- This will be the parent label + "Notification" + profile = "rgbw-bulb", + parent_device_id = mock_inovelli_vzw31_sn.id, + parent_assigned_child_key = "notification" + }) + end + ) +end + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua new file mode 100644 index 0000000000..5a9b2ccafd --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua @@ -0,0 +1,340 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2}) +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local CentralScene = (require "st.zwave.CommandClass.CentralScene")({version=3}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) +local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({version=7}) +local Meter = (require "st.zwave.CommandClass.Meter")({version=3}) +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW32-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW32_SN_PRODUCT_TYPE = 0x0017 +local INOVELLI_VZW32_SN_PRODUCT_ID = 0x0001 +local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" + +-- Device endpoints with supported command classes +local inovelli_vzw32_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + {value = zw.SENSOR_MULTILEVEL}, + {value = zw.METER}, + {value = zw.NOTIFICATION}, + } + } +} + +-- Create mock device +local mock_inovelli_vzw32_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-mmwave-dimmer-vzw32-sn.yml"), + zwave_endpoints = inovelli_vzw32_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW32_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW32_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw32_sn) +end +test.set_test_init_function(test_init) + +local supported_button_values = { + ["button1"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button2"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"}, + ["button3"] = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} +} + +-- Test device initialization +test.register_coroutine_test( + "Device should initialize properly on added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_inovelli_vzw32_sn.id, "added" }) + + for button_name, _ in pairs(mock_inovelli_vzw32_sn.profile.components) do + if button_name ~= "main" and button_name ~= LED_BAR_COMPONENT_NAME then + test.socket.capability:__expect_send( + mock_inovelli_vzw32_sn:generate_test_message( + button_name, + capabilities.button.supportedButtonValues( + supported_button_values[button_name], + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzw32_sn:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Association:Set({ + grouping_identifier = 1, + node_ids = {}, -- Mock hub Z-Wave ID + payload = "\x01", -- Should contain grouping_identifier = 1 + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + SwitchMultilevel:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ILLUMINANCE}) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Notification:Get({notification_type = Notification.notification_type.HOME_SECURITY, event = Notification.event.home_security.MOTION_DETECTION}) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test switch on command +test.register_coroutine_test( + "Switch on command should send Basic Set with ON value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw32_sn.id, + { capability = "switch", command = "on", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Basic:Set({ value = SwitchBinary.value.ON_ENABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + SwitchMultilevel:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test switch off command +test.register_coroutine_test( + "Switch off command should send Basic Set with OFF value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw32_sn.id, + { capability = "switch", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Basic:Set({ value = SwitchBinary.value.OFF_DISABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + SwitchMultilevel:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test switch level command +test.register_coroutine_test( + "Switch level command should send SwitchMultilevel Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + test.socket.capability:__queue_receive({ + mock_inovelli_vzw32_sn.id, + { capability = "switchLevel", command = "setLevel", args = { 50 } } + }) + + local expected_command = SwitchMultilevel:Set({ value = 50, duration = "default" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + expected_command + ) + ) + + test.wait_for_events() + test.mock_time.advance_time(3) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + SwitchMultilevel:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test central scene notifications +test.register_message_test( + "Central scene notification should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw32_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 1, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_1_TIME + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw32_sn:generate_test_message("button1", capabilities.button.button.pushed({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test central scene notifications - button2 pressed 4 times +test.register_message_test( + "Central scene notification button2 pressed 4 times should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw32_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 2, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_4_TIMES + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw32_sn:generate_test_message("button2", capabilities.button.button.pushed_4x({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test refresh capability +test.register_message_test( + "Refresh capability should request switch level", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzw32_sn.id, + { capability = "refresh", command = "refresh", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + SwitchMultilevel:Get({}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ILLUMINANCE}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Notification:Get({notification_type = Notification.notification_type.HOME_SECURITY, event = Notification.event.home_security.MOTION_DETECTION}) + ) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua new file mode 100644 index 0000000000..4fcdf9e65c --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua @@ -0,0 +1,351 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=4}) +local t_utils = require "integration_test.utils" +local st_device = require "st.device" + +-- Inovelli VZW32-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW32_SN_PRODUCT_TYPE = 0x0017 +local INOVELLI_VZW32_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw32_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + } + } +} + +-- Create mock parent device +local mock_parent_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-mmwave-dimmer-vzw32-sn.yml"), + zwave_endpoints = inovelli_vzw32_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW32_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW32_SN_PRODUCT_ID +}) + +-- Create mock child device (notification device) +local mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("rgbw-bulb.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "notification" +}) + +-- Set child device network type +mock_child_device.network_type = st_device.NETWORK_TYPE_CHILD + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +-- Test child device initialization +test.register_message_test( + "Child device should initialize with default color values", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "added" }, + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.hue(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switchLevel.level(100)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 17 + } +) + +-- Test child device switch on command +test.register_coroutine_test( + "Child device switch on should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "on", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test child device switch off command +test.register_coroutine_test( + "Child device switch off should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 0, -- Switch off sends 0 + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test child device level command +test.register_coroutine_test( + "Child device level command should emit events and send configuration to parent", + function() + local level = math.random(1, 99) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local effect = 1 -- Default notificationType + local color = 100 -- Default color for child devices (since device starts with no hue state) + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) -- Use the actual level from command + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switchLevel", command = "setLevel", args = { level } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switchLevel.level(level)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test child device color command +test.register_coroutine_test( + "Child device color command should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = math.random(0, 100) -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorControl", command = "setColor", args = {{ hue = color, saturation = 100 }} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(color)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +-- Test child device color temperature command +test.register_coroutine_test( + "Child device color temperature command should emit events and send configuration to parent", + function() + local temp = math.random(2700, 6500) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorTemperature", command = "setColorTemperature", args = { temp } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 33514751, -- Calculated: effect(1)*16777216 + hue(255)*65536 + level(100)*256 + 255 + size = 4 + }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua new file mode 100644 index 0000000000..3c3e9aa7b8 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua @@ -0,0 +1,172 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW32-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW32_SN_PRODUCT_TYPE = 0x0017 +local INOVELLI_VZW32_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw32_sn_endpoints = { + { + command_classes = { + { value = zw.SWITCH_BINARY }, + { value = zw.SWITCH_MULTILEVEL }, + { value = zw.BASIC }, + { value = zw.CONFIGURATION }, + { value = zw.CENTRAL_SCENE }, + { value = zw.ASSOCIATION }, + } + } +} + +-- Create mock device +local mock_inovelli_vzw32_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-mmwave-dimmer-vzw32-sn.yml"), + zwave_endpoints = inovelli_vzw32_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW32_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW32_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw32_sn) +end +test.set_test_init_function(test_init) + +-- Test parameter 1 (example preference) +do + local new_param_value = 10 + test.register_coroutine_test( + "Parameter 1 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw32_sn:generate_info_changed({preferences = {parameter1 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Configuration:Set({ + parameter_number = 1, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end, + { + min_api_version = 17 + } + + ) +end + +-- Test parameter 52 (example preference) +do + local new_param_value = 25 + test.register_coroutine_test( + "Parameter 52 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw32_sn:generate_info_changed({preferences = {parameter52 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Configuration:Set({ + parameter_number = 52, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end, + { + min_api_version = 17 + } + + ) +end + +-- Test parameter 158 (example preference) +do + local new_param_value = 5 + test.register_coroutine_test( + "Parameter 158 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw32_sn:generate_info_changed({preferences = {parameter158 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Configuration:Set({ + parameter_number = 158, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end, + { + min_api_version = 17 + } + + ) +end + +-- Test parameter 101 (2-byte parameter) +do + local new_param_value = -400 + test.register_coroutine_test( + "Parameter 101 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw32_sn:generate_info_changed({preferences = {parameter101 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw32_sn, + Configuration:Set({ + parameter_number = 101, + configuration_value = new_param_value, + size = 2 + }) + ) + ) + end, + { + min_api_version = 17 + } + + ) +end + +-- Test notificationChild preference (special case for child device creation) +do + local new_param_value = true + test.register_coroutine_test( + "notificationChild preference should create child device when enabled", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw32_sn:generate_info_changed({preferences = {notificationChild = new_param_value}})) + + -- Expect child device creation + mock_inovelli_vzw32_sn:expect_device_create({ + type = "EDGE_CHILD", + label = "nil Notification", -- This will be the parent label + "Notification" + profile = "rgbw-bulb", + parent_device_id = mock_inovelli_vzw32_sn.id, + parent_assigned_child_key = "notification" + }) + end, + { + min_api_version = 17 + } + + ) +end + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua index 9373383093..ad95d3edbb 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -125,7 +114,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -174,7 +166,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -223,7 +218,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -248,6 +246,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -273,6 +274,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -298,6 +302,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -323,6 +330,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -369,7 +379,10 @@ test.register_coroutine_test( )) mock_parent_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -409,6 +422,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -449,6 +465,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -476,7 +495,11 @@ do message = mock_parent_device:generate_test_message( "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" })) } + }, + { + min_api_version = 17 } + ) end @@ -506,7 +529,11 @@ do "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" }) ) } + }, + { + min_api_version = 17 } + ) end @@ -534,7 +561,11 @@ do message = mock_parent_device:generate_test_message( "main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 17 } + ) end @@ -562,7 +593,11 @@ do message = mock_child_device:generate_test_message( "main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 17 } + ) end @@ -593,7 +628,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -623,7 +661,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) do @@ -714,7 +755,11 @@ do ) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -727,7 +772,10 @@ test.register_coroutine_test( current_value = 0xFF }) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua b/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua index 1275ead7f3..6c835ff893 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local zw = require "st.zwave" @@ -169,7 +158,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -195,7 +195,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -221,7 +232,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_2:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -247,7 +269,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -274,11 +307,30 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switchLevel.level(100)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -304,7 +356,26 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -330,7 +401,26 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_2:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -357,11 +447,30 @@ test.register_message_test( direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switchLevel.level(100)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -388,11 +497,30 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switchLevel.level(50)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -418,7 +546,26 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -444,7 +591,26 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_2:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -471,11 +637,30 @@ test.register_message_test( direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switchLevel.level(50)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -507,7 +692,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -539,7 +735,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -571,7 +778,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -603,7 +821,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -636,11 +865,30 @@ test.register_message_test( direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switchLevel.level(100)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -672,7 +920,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -705,11 +964,30 @@ test.register_message_test( direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_child_3:generate_test_message("main", capabilities.switchLevel.level(50)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -777,6 +1055,9 @@ test.register_message_test( SwitchMultilevel:Get({}, { dst_channels = { 5 } }) ) }, + }, + { + min_api_version = 17 } ) @@ -817,7 +1098,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -858,7 +1140,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -899,7 +1182,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -928,16 +1212,24 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) + message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.inactive()) }, { channel = "capability", direction = "send", - message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -966,16 +1258,32 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) + message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.active()) }, { channel = "capability", direction = "send", - message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.active()) - } + message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -1002,6 +1310,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 17 } ) @@ -1028,6 +1339,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -1059,6 +1373,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 17 } ) @@ -1090,6 +1407,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -1211,6 +1531,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 17 } ) @@ -1332,6 +1655,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 17 } ) @@ -1458,6 +1784,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -1484,6 +1813,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -1510,6 +1842,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -1536,6 +1871,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 17 } ) @@ -1562,6 +1900,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 17 } ) @@ -1588,6 +1929,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1614,6 +1958,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1640,6 +1987,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1666,6 +2016,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1692,6 +2045,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1718,6 +2074,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1744,6 +2103,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 17 } ) @@ -1770,6 +2132,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 17 } ) @@ -1796,7 +2161,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_5:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 20, unit = "C" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -1823,6 +2199,9 @@ test.register_message_test( direction = "send", message = mock_child_5:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 70 })) } + }, + { + min_api_version = 17 } ) @@ -1850,6 +2229,9 @@ test.register_message_test( direction = "send", message = mock_child_5:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 17 } ) @@ -1898,7 +2280,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1953,7 +2336,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -2080,7 +2464,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -2123,7 +2508,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -2143,6 +2529,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -2159,7 +2548,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 1, "metering-switch") ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -2178,6 +2570,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -2194,7 +2589,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 3, "metering-dimmer") ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -2213,6 +2611,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -2229,7 +2630,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 4, "generic-sensor") ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -2248,6 +2652,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -2264,7 +2671,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 5, "generic-multi-sensor") ) - end + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua index 20b0eed8f6..8586267722 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -58,7 +47,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua index f214bf538f..151b6b416e 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -79,7 +68,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -132,7 +124,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -154,6 +147,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -164,7 +165,7 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -186,6 +187,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -195,6 +204,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -220,6 +232,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -228,6 +248,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -253,11 +276,27 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(5)) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, { channel = "zwave", direction = "send", @@ -266,6 +305,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -286,7 +328,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -308,6 +361,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -324,7 +380,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -337,6 +396,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -372,6 +432,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -388,7 +449,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -402,6 +466,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -437,6 +502,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -453,7 +519,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -466,6 +535,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switchLevel", command = "setLevel", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -483,7 +553,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua index 21e7fbba3f..3a2ef76aa2 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -65,7 +54,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -85,7 +78,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -105,7 +102,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -125,7 +126,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -145,7 +150,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -166,7 +175,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -186,7 +199,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua index d9e6b82d63..959bb2068d 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -57,7 +46,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -77,7 +70,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -97,7 +94,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -117,7 +118,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -137,7 +142,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua index ba758717e8..89b03dae43 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -57,7 +46,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -77,7 +70,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -97,7 +94,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua index 49f1117d41..53e8744577 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -115,7 +104,10 @@ test.register_coroutine_test( { dst_channels = { 1 } } ) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -144,7 +136,10 @@ test.register_coroutine_test( { dst_channels = { 2 } } ) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -162,7 +157,10 @@ test.register_coroutine_test( { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 3 } } ) )) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -188,7 +186,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -215,7 +214,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -239,7 +239,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -263,7 +264,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -288,6 +290,9 @@ test.register_message_test( direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -312,6 +317,9 @@ test.register_message_test( direction = "send", message = mock_child_2_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -335,7 +343,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.powerMeter.power({ value = 5, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -359,7 +378,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child_2_device:generate_test_message("main", capabilities.powerMeter.power({ value = 5, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -386,6 +416,9 @@ test.register_message_test( "main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' }) ) } + }, + { + min_api_version = 17 } ) @@ -425,7 +458,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 1 } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -464,7 +500,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 1 } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -503,7 +542,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 2 } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -542,7 +584,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 2 } }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -592,7 +637,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua index 4fc60d1927..b894310832 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -57,7 +46,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -77,7 +70,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -97,7 +94,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -117,7 +118,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -137,7 +142,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua index 576ddffb89..42e01fe944 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -70,7 +59,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -123,7 +115,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -145,6 +138,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -155,7 +156,7 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -177,6 +178,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -186,6 +195,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -211,6 +223,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -219,6 +239,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -244,11 +267,27 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(5)) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, { channel = "zwave", direction = "send", @@ -257,6 +296,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -277,7 +319,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -299,6 +352,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -313,7 +369,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' }))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -326,6 +385,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -361,6 +421,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -377,7 +438,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -391,6 +455,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -426,6 +491,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -442,7 +508,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -455,6 +524,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switchLevel", command = "setLevel", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -472,7 +542,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua index 48fa00fa38..7256e3579a 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -61,7 +50,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -81,7 +74,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -101,7 +98,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -121,7 +122,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -141,7 +146,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -165,7 +174,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -189,7 +202,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua index cd07b64151..347995922e 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -60,7 +49,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -80,7 +73,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -100,7 +97,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -120,7 +121,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -140,7 +145,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -160,7 +169,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -180,7 +193,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -201,7 +218,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -221,7 +242,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua index a4f60c6a50..c1c4f31f0b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -60,7 +49,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -80,7 +73,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -100,7 +97,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -120,7 +121,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -140,7 +145,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -161,7 +170,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -181,7 +194,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -201,7 +218,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua index 7d3a96afb7..2614927458 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -68,7 +57,10 @@ test.register_coroutine_test( ) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -89,6 +81,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -99,7 +99,7 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -121,6 +121,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -130,6 +138,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -150,7 +161,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -172,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -194,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -207,6 +235,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -242,6 +271,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -258,7 +288,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -272,6 +305,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -307,6 +341,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -323,7 +358,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -364,7 +402,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua index a4664674be..c13617986b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -61,9 +50,17 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -85,6 +82,17 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -107,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -120,6 +131,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -155,7 +167,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) - end + mock_device:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -169,6 +185,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -204,7 +221,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) - end + mock_device:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -245,7 +266,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua new file mode 100644 index 0000000000..0d3759c314 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua @@ -0,0 +1,731 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) +local t_utils = require "integration_test.utils" + +local profile = t_utils.get_profile_definition("metering-switch.yml") + +local SHELLY_MANUFACTURER_ID = 0x0460 +local SHELLY_PRODUCT_TYPE = 0x0002 +local SHELLY_PRODUCT_ID = 0x008D + +local switch_multicomponent_endpoints = { + { -- ep 1 (parent) + command_classes = { + { value = zw.BASIC }, + { value = zw.SWITCH_BINARY }, + { value = zw.METER } + } + }, + { -- ep 2 (child) + command_classes = { + { value = zw.BASIC }, + { value = zw.SWITCH_BINARY }, + { value = zw.METER } + } + } +} + +local mock_parent_device = test.mock_device.build_test_zwave_device({ + profile = profile, + label = "Wave Pro 2PM 1", + zwave_endpoints = switch_multicomponent_endpoints, + zwave_manufacturer_id = SHELLY_MANUFACTURER_ID, + zwave_product_type = SHELLY_PRODUCT_TYPE, + zwave_product_id = SHELLY_PRODUCT_ID +}) + +local mock_base_device = test.mock_device.build_test_zwave_device({ + profile = profile, + label = "Wave Pro 2PM 1", + zwave_endpoints = switch_multicomponent_endpoints, + zwave_manufacturer_id = SHELLY_MANUFACTURER_ID, + zwave_product_type = SHELLY_PRODUCT_TYPE, + zwave_product_id = SHELLY_PRODUCT_ID +}) + +local mock_child_device = test.mock_device.build_test_child_device({ + profile = profile, + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_base_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Added should create the correct number of children", + function () + test.socket.device_lifecycle:__queue_receive({ mock_base_device.id, "added" }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Wave Pro 2PM 2", + profile = "metering-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "02" + }) + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_base_device, + SwitchBinary:Get({}, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_base_device, + Meter:Get( + { scale = Meter.scale.electric_meter.KILOWATT_HOURS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_base_device, + Meter:Get( + { scale = Meter.scale.electric_meter.WATTS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh on parent device sends commands to 1 endpoint", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Get({}, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.KILOWATT_HOURS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.WATTS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh on child device sends commands to 2 endpoint", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Get({}, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 2 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.KILOWATT_HOURS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 2 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.WATTS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 2 } + } + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Switch on/off capability command from parent device should be handled: on", + { + { + channel = "capability", + direction = "receive", + message = { + mock_parent_device.id, + { capability = "switch", command = "on", component = "main", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Set( + { target_value = SwitchBinary.value.ON_ENABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 1 } } + ) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Switch on/off capability command from child device should be handled: on", + { + { + channel = "capability", + direction = "receive", + message = { + mock_child_device.id, + { capability = "switch", command = "on", component = "main", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Set( + { target_value = SwitchBinary.value.ON_ENABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 2 } } + ) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Switch on/off capability command from parent device should be handled: off", + { + { + channel = "capability", + direction = "receive", + message = { + mock_parent_device.id, + { capability = "switch", command = "off", component = "main", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Set( + { target_value = SwitchBinary.value.OFF_DISABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 1 } } + ) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Switch on/off capability command from child device should be handled: off", + { + { + channel = "capability", + direction = "receive", + message = { + mock_child_device.id, + { capability = "switch", command = "off", component = "main", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Set( + { target_value = SwitchBinary.value.OFF_DISABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 2 } } + ) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Binary switch on/off report from channel 1 should be handled: on", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" }, + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_parent_device.id, + zw_test_utils.zwave_test_build_receive_command( + SwitchBinary:Report( + { target_value = SwitchBinary.value.ON_ENABLE, current_value = SwitchBinary.value.ON_ENABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 1, dst_channels = { 0 } } + ) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.WATTS }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 1 } } + ) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Binary switch on/off report from channel 2 should be handled: on", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "init" }, + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_child_device.id, + zw_test_utils.zwave_test_build_receive_command( + SwitchBinary:Report( + { target_value = SwitchBinary.value.ON_ENABLE, current_value = SwitchBinary.value.ON_ENABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 2, dst_channels = { 0 } } + ) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.WATTS }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 2 } } + ) + ) + } + }, + { + min_api_version = 17 + } +) + +do + local energy = 5 + test.register_message_test( + "Energy meter report from parent device should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_parent_device.id, + zw_test_utils.zwave_test_build_receive_command( + Meter:Report( + { scale = Meter.scale.electric_meter.KILOWATT_HOURS, meter_value = energy}, + { encap = zw.ENCAP.AUTO, src_channel = 1, dst_channels = {0} } + ) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message( + "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" })) + } + }, + { + min_api_version = 17 + } + + ) +end + + +do + local energy = 5 + test.register_message_test( + "Energy meter report from child device should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_child_device.id, + zw_test_utils.zwave_test_build_receive_command( + Meter:Report( + { scale = Meter.scale.electric_meter.KILOWATT_HOURS, meter_value = energy }, + { encap = zw.ENCAP.AUTO, src_channel = 2, dst_channels = {0} } + ) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message( + "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" }) + ) + } + }, + { + min_api_version = 17 + } + + ) +end + +do + local power = 89 + test.register_message_test( + "Power meter report from parent device should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_parent_device.id, + zw_test_utils.zwave_test_build_receive_command( + Meter:Report( + { scale = Meter.scale.electric_meter.WATTS, meter_value = power}, + { encap = zw.ENCAP.AUTO, src_channel = 1, dst_channels = {0} } + ) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message( + "main", capabilities.powerMeter.power({ value = power, unit = "W" })) + } + }, + { + min_api_version = 17 + } + + ) +end + +do + local power = 89 + test.register_message_test( + "Power meter report from child device should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_child_device.id, + zw_test_utils.zwave_test_build_receive_command( + Meter:Report( + { scale = Meter.scale.electric_meter.WATTS, meter_value = power}, + { encap = zw.ENCAP.AUTO, src_channel = 2, dst_channels = {0} } + ) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message( + "main", capabilities.powerMeter.power({ value = power, unit = "W" })) + } + }, + { + min_api_version = 17 + } + + ) +end + +test.register_coroutine_test( + "Switch capability off commands from parent device should evoke the correct Z-Wave SETs and GETs", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", command = "off", component = "main", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Set( + { target_value = SwitchBinary.value.OFF_DISABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 1 } } + ) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Get({}, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 1 } } + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Switch capability off commands from child device should evoke the correct Z-Wave SETs and GETs", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", component = "main", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Set( + { target_value = SwitchBinary.value.OFF_DISABLE }, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 2 } } + ) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Get({}, + { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 2 } } + ) + ) + ) + end, + { + min_api_version = 17 + } +) + +do + local power = 89 + test.register_coroutine_test( + "Power meter report from root node device should refresh all devices", + function () + test.socket.zwave:__queue_receive({ + mock_parent_device.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, meter_value = power + }) + }) + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Get({}, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.KILOWATT_HOURS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.WATTS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 1 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + SwitchBinary:Get({}, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 2 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.KILOWATT_HOURS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 2 } + } + ) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Meter:Get( + { scale = Meter.scale.electric_meter.WATTS }, + { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { 2 } + } + ) + ) + ) + end, + { + min_api_version = 17 + } + + ) +end + +test.register_coroutine_test( + "Switch Binary report from root node device should be ignored", + function () + test.socket.zwave:__queue_receive({ + mock_parent_device.id, + SwitchBinary:Report({ + current_value = 0xFF + }) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua index cabff8629b..c7288aafaf 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -80,7 +69,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 2, size = 1, configuration_value = 1}) )) mock_parent_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -105,6 +97,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -130,6 +125,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -155,6 +153,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -180,6 +181,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -210,6 +214,9 @@ test.register_message_test( direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -240,6 +247,9 @@ test.register_message_test( direction = "send", message = mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -251,6 +261,7 @@ test.register_coroutine_test( mock_parent_device.id, { capability = "switch", command = "off", component = "main", args = {} } }) + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_parent_device, @@ -270,7 +281,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -300,7 +314,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) local function prepare_metadata(device, endpoint, profile) @@ -331,7 +348,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -347,7 +367,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua index 0a295fbd91..fbf70da972 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -57,7 +46,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua index 9f7f7d5b86..7826e3d4e4 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -82,7 +71,10 @@ test.register_coroutine_test( }) )) mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -130,7 +122,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -177,7 +172,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -201,6 +199,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -226,6 +227,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -250,6 +254,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -275,6 +282,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -314,6 +324,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -353,6 +366,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -392,6 +408,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -431,6 +450,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 17 } ) @@ -452,6 +474,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -473,6 +498,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -493,7 +521,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.powerMeter.power({ value = 89, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -514,7 +553,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.powerMeter.power({ value = 89, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua index d63c644040..8ffb5f93f6 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -94,6 +83,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -130,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -166,6 +161,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -202,6 +200,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -238,6 +239,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -274,6 +278,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -310,6 +317,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -347,6 +357,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -384,6 +397,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -421,6 +437,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -462,6 +481,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -502,6 +524,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -542,6 +567,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -582,6 +610,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -622,6 +653,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -662,6 +696,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -702,6 +739,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -742,6 +782,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -782,6 +825,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -822,6 +868,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -881,7 +930,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("switch1", capabilities.switch.switch.off())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -958,7 +1010,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1036,7 +1089,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1071,7 +1125,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1106,7 +1161,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1141,7 +1197,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1176,7 +1233,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1211,7 +1269,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1246,7 +1305,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1281,7 +1341,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1316,7 +1377,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1351,7 +1413,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -1386,7 +1449,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua index dee32c88ed..b42d511166 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local zw = require "st.zwave" local test = require "integration_test" @@ -103,7 +92,10 @@ test.register_coroutine_test( Version:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -120,7 +112,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { dst_channels = { 1 } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -140,7 +135,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -161,7 +167,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -182,7 +199,26 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -204,11 +240,30 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switchLevel.level(100)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -234,15 +289,30 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switchLevel.level(100)) - } - + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, }, { - inner_block_ordering = "relaxed" + min_api_version = 17 } ) @@ -267,7 +337,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -288,7 +369,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -309,7 +401,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -338,7 +441,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, { dst_channels = { 0 } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -366,7 +472,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, { dst_channels = { 0 } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -394,7 +503,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { dst_channels = { 1 } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -422,7 +534,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { dst_channels = { 1 } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -433,6 +548,7 @@ test.register_coroutine_test( mock_parent.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) + mock_parent:expect_native_cmd_handler_registration("switchLevel", "setLevel") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_parent, @@ -454,7 +570,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, { dst_channels = { 0 } }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -479,11 +598,30 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_parent:generate_test_message("main", capabilities.switchLevel.level(50)) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 17 } ) @@ -508,6 +646,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -532,6 +673,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -556,6 +700,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -580,6 +727,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_2x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -604,6 +754,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_2x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -628,6 +781,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_2x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -652,6 +808,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_3x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -676,6 +835,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_3x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -700,6 +862,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_3x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -724,6 +889,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_4x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -748,6 +916,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_4x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -772,6 +943,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_4x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -796,6 +970,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_5x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -820,6 +997,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_5x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -844,6 +1024,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_5x({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -868,6 +1051,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_hold({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -892,6 +1078,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_hold({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -916,6 +1105,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -935,6 +1127,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -960,7 +1155,10 @@ test.register_coroutine_test( } ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -985,7 +1183,10 @@ test.register_coroutine_test( } ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1021,7 +1222,10 @@ test.register_coroutine_test( }) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1066,7 +1270,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1082,7 +1289,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua index 7ebf45335a..b021a25aa4 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -88,7 +77,11 @@ do }) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua index 77c8a83a4b..cd47374f82 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -65,6 +54,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -73,6 +70,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -94,6 +94,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -103,6 +111,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -128,6 +139,14 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "zwave", direction = "send", @@ -136,6 +155,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -161,11 +183,27 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(5)) }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, { channel = "zwave", direction = "send", @@ -174,6 +212,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 17 } ) @@ -194,7 +235,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -216,6 +268,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -229,6 +284,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -264,6 +320,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -280,7 +337,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -293,6 +353,7 @@ test.register_coroutine_test( mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -328,6 +389,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) + mock_device:expect_native_attr_handler_registration("switch", "switch") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( @@ -344,7 +406,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -355,6 +420,8 @@ test.register_coroutine_test( mock_device.id, { capability = "switchLevel", command = "setLevel", args = { 50 } } }) + mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_device, @@ -372,7 +439,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua index 80753fb163..16b15348ce 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -91,6 +80,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -122,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -153,6 +148,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -184,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -215,6 +216,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -246,6 +250,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -277,6 +284,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -308,6 +318,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -345,6 +358,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -382,6 +398,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -419,6 +438,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -456,6 +478,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -467,6 +492,7 @@ test.register_coroutine_test( mock_parent.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_parent, @@ -492,7 +518,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -503,6 +532,7 @@ test.register_coroutine_test( mock_child.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_parent, @@ -528,7 +558,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -539,6 +572,7 @@ test.register_coroutine_test( mock_parent.id, { capability = "switch", component = "main", command = "off", args = {} } }) + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_parent, @@ -564,7 +598,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -575,6 +612,7 @@ test.register_coroutine_test( mock_child.id, { capability = "switch", component = "main", command = "off", args = {} } }) + test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_parent, @@ -600,7 +638,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -621,7 +662,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -642,7 +686,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua index db799e9451..d11dd78b8c 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua @@ -1,16 +1,5 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -83,7 +72,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -107,7 +99,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua index 10fa833613..15fbe8b065 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -78,7 +67,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_switch_binary:generate_test_message("main", capabilities.switch.switch.on()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_switch_binary.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -94,7 +94,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_switch_binary:generate_test_message("main", capabilities.switch.switch.off()) - } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_switch_binary.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 17 } ) @@ -117,7 +128,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -142,7 +154,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -164,7 +177,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -189,7 +203,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -203,6 +218,7 @@ test.register_coroutine_test( { capability = "switch", command = "on", args = {} } } ) + mock_switch_basic:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_switch_basic, @@ -220,7 +236,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -233,6 +252,7 @@ test.register_coroutine_test( { capability = "switch", command = "off", args = {} } } ) + mock_switch_basic:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_switch_basic, @@ -250,7 +270,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -263,6 +286,7 @@ test.register_coroutine_test( { capability = "switch", command = "on", args = {} } } ) + mock_switch_binary:expect_native_cmd_handler_registration("switch", "on") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_switch_binary, @@ -280,7 +304,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -293,6 +320,7 @@ test.register_coroutine_test( { capability = "switch", command = "off", args = {} } } ) + mock_switch_binary:expect_native_cmd_handler_registration("switch", "off") test.socket.zwave:__expect_send( zw_test_utils.zwave_test_build_send_command( mock_switch_binary, @@ -310,7 +338,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua index acba87f120..608d92a052 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -54,6 +43,9 @@ test.register_message_test( direction = "send", message = mock_switch:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -83,7 +75,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -116,7 +109,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua index abfaa48ffd..5ec6b0a340 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -57,7 +46,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_switch:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_switch.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -77,6 +77,9 @@ test.register_message_test( direction = "send", message = mock_switch:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -114,7 +117,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -155,7 +159,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua index 8b526a9984..f1b382ee67 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -64,6 +53,17 @@ test.register_message_test( meter_value = 27}) )} }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_switch.id, capability_id = "powerMeter", capability_attr_id = "power" } + } + } + }, + { + min_api_version = 17 } ) @@ -83,6 +83,9 @@ test.register_message_test( direction = "send", message = mock_switch:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -112,7 +115,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -145,7 +149,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -155,7 +160,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({mock_switch.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {}}}) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( mock_switch, Meter:Reset({}))) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( mock_switch, Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}))) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua index b8e834cffe..1841b7c97e 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zw = require "st.zwave" @@ -56,7 +45,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -81,7 +71,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua index 56ffdd939d..7ea67ed56d 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -68,7 +57,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_switch:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_switch.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -83,6 +83,9 @@ test.register_message_test( meter_value = 5}) )} }, + }, + { + min_api_version = 17 } ) @@ -112,7 +115,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -145,7 +149,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) diff --git a/drivers/SmartThings/zwave-switch/src/zooz-power-strip/can_handle.lua b/drivers/SmartThings/zwave-switch/src/zooz-power-strip/can_handle.lua new file mode 100644 index 0000000000..a55c1120e6 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/zooz-power-strip/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_zooz_power_strip(opts, driver, device, ...) + local fingerprints = { + {mfr = 0x015D, prod = 0x0651, model = 0xF51C} -- Zooz ZEN 20 Power Strip + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("zooz-power-strip") + return true, subdriver + end + end + return false +end + +return can_handle_zooz_power_strip diff --git a/drivers/SmartThings/zwave-switch/src/zooz-power-strip/init.lua b/drivers/SmartThings/zwave-switch/src/zooz-power-strip/init.lua index 24b969d13e..8bf3ad9dc4 100644 --- a/drivers/SmartThings/zwave-switch/src/zooz-power-strip/init.lua +++ b/drivers/SmartThings/zwave-switch/src/zooz-power-strip/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,20 +9,6 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) --- @type st.zwave.CommandClass.SwitchBinary local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) -local ZOOZ_POWER_STRIP_FINGERPRINTS = { - {mfr = 0x015D, prod = 0x0651, model = 0xF51C} -- Zooz ZEN 20 Power Strip -} - -local function can_handle_zooz_power_strip(opts, driver, device, ...) - for _, fingerprint in ipairs(ZOOZ_POWER_STRIP_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("zooz-power-strip") - return true, subdriver - end - end - return false -end - local function binary_event_helper(driver, device, cmd) if cmd.src_channel > 0 then local value = cmd.args.value and cmd.args.value or cmd.args.target_value @@ -124,7 +99,7 @@ local zooz_power_strip = { [capabilities.switch.commands.off.NAME] = switch_off_handler } }, - can_handle = can_handle_zooz_power_strip, + can_handle = require("zooz-power-strip.can_handle"), } return zooz_power_strip diff --git a/drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/can_handle.lua b/drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/can_handle.lua new file mode 100644 index 0000000000..19d44bc623 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zooz_zen_30_dimmer_relay_double_switch(opts, driver, device, ...) + local fingerprints = { + { mfr = 0x027A, prod = 0xA000, model = 0xA008 } -- Zooz Zen 30 Dimmer Relay Double Switch + } + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("zooz-zen-30-dimmer-relay") + return true, subdriver + end + end + return false +end + +return can_handle_zooz_zen_30_dimmer_relay_double_switch diff --git a/drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/init.lua b/drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/init.lua index c92c485e43..6bb9bd4e1b 100644 --- a/drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/init.lua +++ b/drivers/SmartThings/zwave-switch/src/zooz-zen-30-dimmer-relay/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local st_device = require "st.device" local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -70,20 +59,6 @@ local map_key_attribute_to_capability = { } } -local ZOOZ_ZEN_30_DIMMER_RELAY_FINGERPRINTS = { - { mfr = 0x027A, prod = 0xA000, model = 0xA008 } -- Zooz Zen 30 Dimmer Relay Double Switch -} - -local function can_handle_zooz_zen_30_dimmer_relay_double_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(ZOOZ_ZEN_30_DIMMER_RELAY_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("zooz-zen-30-dimmer-relay") - return true, subdriver - end - end - return false -end - local function find_child(parent, src_channel) if src_channel == 0 then return parent @@ -205,7 +180,7 @@ local zooz_zen_30_dimmer_relay_double_switch = { init = device_init, added = device_added }, - can_handle = can_handle_zooz_zen_30_dimmer_relay_double_switch + can_handle = require("zooz-zen-30-dimmer-relay.can_handle") } return zooz_zen_30_dimmer_relay_double_switch diff --git a/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/can_handle.lua b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/can_handle.lua new file mode 100644 index 0000000000..416c3dfa67 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function can_handle_zwave_dual_switch(opts, driver, device, ...) + local fingerprints = require("zwave-dual-switch.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("zwave-dual-switch") + return true, subdriver + end + end + return false +end + +return can_handle_zwave_dual_switch diff --git a/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/dual_switch_configurations.lua b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/dual_switch_configurations.lua index d0b2f83373..f5c01ecf6b 100644 --- a/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/dual_switch_configurations.lua +++ b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/dual_switch_configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local devices = { FIBARO_WALLI_DOUBLE_SWITCH = { diff --git a/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/fingerprints.lua b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/fingerprints.lua new file mode 100644 index 0000000000..9640f5c4d2 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/fingerprints.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = 0x0086, prod = 0x0103, model = 0x008C }, -- Aeotec Switch 1 + { mfr = 0x0086, prod = 0x0003, model = 0x008C }, -- Aeotec Switch 1 + { mfr = 0x0258, prod = 0x0003, model = 0x008B }, -- NEO Coolcam Switch 1 + { mfr = 0x0258, prod = 0x0003, model = 0x108B }, -- NEO Coolcam Switch 1 + { mfr = 0x0312, prod = 0xC000, model = 0xC004 }, -- EVA Switch 1 + { mfr = 0x0312, prod = 0xFF00, model = 0xFF05 }, -- Minoston Switch 1 + { mfr = 0x0312, prod = 0xC000, model = 0xC007 }, -- Evalogik Switch 1 + { mfr = 0x010F, prod = 0x1B01, model = 0x1000 }, -- Fibaro Walli Double Switch + { mfr = 0x027A, prod = 0xA000, model = 0xA003 } -- Zooz Double Plug +} diff --git a/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/init.lua b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/init.lua index 3ec8868bcd..4bea42c584 100644 --- a/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/init.lua +++ b/drivers/SmartThings/zwave-switch/src/zwave-dual-switch/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local st_device = require "st.device" local capabilities = require "st.capabilities" --- @type st.zwave.defaults.switch @@ -26,28 +15,6 @@ local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) local dualSwitchConfigurationsMap = require "zwave-dual-switch/dual_switch_configurations" local utils = require "st.utils" -local ZWAVE_DUAL_SWITCH_FINGERPRINTS = { - { mfr = 0x0086, prod = 0x0103, model = 0x008C }, -- Aeotec Switch 1 - { mfr = 0x0086, prod = 0x0003, model = 0x008C }, -- Aeotec Switch 1 - { mfr = 0x0258, prod = 0x0003, model = 0x008B }, -- NEO Coolcam Switch 1 - { mfr = 0x0258, prod = 0x0003, model = 0x108B }, -- NEO Coolcam Switch 1 - { mfr = 0x0312, prod = 0xC000, model = 0xC004 }, -- EVA Switch 1 - { mfr = 0x0312, prod = 0xFF00, model = 0xFF05 }, -- Minoston Switch 1 - { mfr = 0x0312, prod = 0xC000, model = 0xC007 }, -- Evalogik Switch 1 - { mfr = 0x010F, prod = 0x1B01, model = 0x1000 }, -- Fibaro Walli Double Switch - { mfr = 0x027A, prod = 0xA000, model = 0xA003 } -- Zooz Double Plug -} - -local function can_handle_zwave_dual_switch(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_DUAL_SWITCH_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("zwave-dual-switch") - return true, subdriver - end - end - return false -end - local function find_child(parent, src_channel) if src_channel == 1 then return parent @@ -117,6 +84,20 @@ local function do_refresh(driver, device, command) end end +-- Do not use native handlers due to unique component to endpoint mapping +local function switch_on_handler(driver, device, cmd) + switch_defaults.capability_handlers[capabilities.switch.commands.on](driver, device, cmd, false) +end + +local function switch_off_handler(driver, device, cmd) + switch_defaults.capability_handlers[capabilities.switch.commands.off](driver, device, cmd, false) +end + +local function set_level_handler(driver, device, cmd) + local defaults = require "st.zwave.defaults.switchLevel" + defaults.capability_handlers[capabilities.switchLevel.commands.setLevel](driver, device, cmd, false) +end + local function switch_report(driver, device, cmd) switch_defaults.zwave_handlers[cc.SWITCH_BINARY][SwitchBinary.REPORT](driver, device, cmd) @@ -139,13 +120,20 @@ local zwave_dual_switch = { capability_handlers = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh - } + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_on_handler, + [capabilities.switch.commands.off.NAME] = switch_off_handler, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = set_level_handler, + }, }, lifecycle_handlers = { added = device_added, init = device_init }, - can_handle = can_handle_zwave_dual_switch + can_handle = require("zwave-dual-switch.can_handle") } return zwave_dual_switch diff --git a/drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/can_handle.lua new file mode 100644 index 0000000000..9feb63e1fa --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_radiator_thermostat(opts, driver, device, ...) + local AEOTEC_THERMOSTAT_FINGERPRINT = {mfr = 0x0371, prod = 0x0002, model = 0x0015} + + if device:id_match(AEOTEC_THERMOSTAT_FINGERPRINT.mfr, AEOTEC_THERMOSTAT_FINGERPRINT.prod, AEOTEC_THERMOSTAT_FINGERPRINT.model) then + return true, require "aeotec-radiator-thermostat" + else + return false + end +end + +return can_handle_aeotec_radiator_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/init.lua b/drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/init.lua index b101a43d38..cdb90e7f44 100755 --- a/drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/aeotec-radiator-thermostat/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -24,12 +14,6 @@ local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ver --- @type st.zwave.CommandClass.ThermostatSetpoint local ThermostatSetpoint = (require "st.zwave.CommandClass.ThermostatSetpoint")({version=1}) -local AEOTEC_THERMOSTAT_FINGERPRINT = {mfr = 0x0371, prod = 0x0002, model = 0x0015} - -local function can_handle_aeotec_radiator_thermostat(opts, driver, device, ...) - return device:id_match(AEOTEC_THERMOSTAT_FINGERPRINT.mfr, AEOTEC_THERMOSTAT_FINGERPRINT.prod, AEOTEC_THERMOSTAT_FINGERPRINT.model) -end - local function thermostat_mode_report_handler(self, device, cmd) local event = nil if (cmd.args.mode == ThermostatMode.mode.OFF) then @@ -114,7 +98,7 @@ local aeotec_radiator_thermostat = { } }, lifecycle_handlers = {added = device_added}, - can_handle = can_handle_aeotec_radiator_thermostat + can_handle = require("aeotec-radiator-thermostat.can_handle"), } return aeotec_radiator_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/init.lua deleted file mode 100644 index 4994710970..0000000000 --- a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,36 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - -local DANFOSS_LC13_THERMOSTAT_FPS = { - { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0003 }, -- Danfoss LC13 Thermostat - { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0004 } -- Danfoss LC13 Thermostat -} - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION and not - (device:id_match(DANFOSS_LC13_THERMOSTAT_FPS[1].manufacturerId, - DANFOSS_LC13_THERMOSTAT_FPS[1].productType, - DANFOSS_LC13_THERMOSTAT_FPS[1].productId) or - device:id_match(DANFOSS_LC13_THERMOSTAT_FPS[2].manufacturerId, - DANFOSS_LC13_THERMOSTAT_FPS[2].productType, - DANFOSS_LC13_THERMOSTAT_FPS[2].productId)) -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/can_handle.lua new file mode 100644 index 0000000000..6483fc2393 --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ct100_thermostat(opts, driver, device) + local CT100_THERMOSTAT_FINGERPRINTS = require "ct100-thermostat.fingerprints" + for _, fingerprint in ipairs(CT100_THERMOSTAT_FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require "ct100-thermostat" + end + end + + return false +end + +return can_handle_ct100_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/fingerprints.lua b/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/fingerprints.lua new file mode 100644 index 0000000000..c45eb5e7af --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CT100_THERMOSTAT_FINGERPRINTS = { + { manufacturerId = 0x0098, productType = 0x6401, productId = 0x0107 }, -- 2Gig CT100 Programmable Thermostat + { manufacturerId = 0x0098, productType = 0x6501, productId = 0x000C }, -- Iris Thermostat +} + +return CT100_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/init.lua b/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/init.lua index e44da0ca36..d5859fe777 100644 --- a/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/ct100-thermostat/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -33,11 +23,6 @@ local cooling_setpoint_defaults = require "st.zwave.defaults.thermostatCoolingSe local constants = require "st.zwave.constants" local utils = require "st.utils" -local CT100_THERMOSTAT_FINGERPRINTS = { - { manufacturerId = 0x0098, productType = 0x6401, productId = 0x0107 }, -- 2Gig CT100 Programmable Thermostat - { manufacturerId = 0x0098, productType = 0x6501, productId = 0x000C }, -- Iris Thermostat -} - -- This old device uses separate endpoints to get values of temp and humidity -- DTH actually uses the old mutliInstance encap, but multichannel should be back-compat local TEMPERATURE_ENDPOINT = 1 @@ -75,16 +60,6 @@ local function set_setpoint_factory(setpoint_type) end end -local function can_handle_ct100_thermostat(opts, driver, device) - for _, fingerprint in ipairs(CT100_THERMOSTAT_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - - return false -end - local function thermostat_mode_report_handler(self, device, cmd) local event = nil @@ -210,7 +185,7 @@ local ct100_thermostat = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - can_handle = can_handle_ct100_thermostat, + can_handle = require("ct100-thermostat.can_handle"), } return ct100_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/can_handle.lua new file mode 100644 index 0000000000..6ece30f897 --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_heat_controller(opts, driver, device, ...) + local FINGERPRINTS = require("fibaro-heat-controller.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("fibaro-heat-controller") + end + end + + return false +end + +return can_handle_fibaro_heat_controller diff --git a/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/fingerprints.lua b/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/fingerprints.lua new file mode 100644 index 0000000000..31cbd7ff0e --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_HEAT_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1301, model = 0x1000}, -- Fibaro Heat Controller + {mfr = 0x010F, prod = 0x1301, model = 0x1001} -- Fibaro Heat Controller +} + +return FIBARO_HEAT_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/init.lua b/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/init.lua index be5365edf0..ab7ef51f30 100644 --- a/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/fibaro-heat-controller/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -30,22 +20,9 @@ local ApplicationStatus = (require "st.zwave.CommandClass.ApplicationStatus")({v local utils = require "st.utils" -local FIBARO_HEAT_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1301, model = 0x1000}, -- Fibaro Heat Controller - {mfr = 0x010F, prod = 0x1301, model = 0x1001} -- Fibaro Heat Controller -} local FORCED_REFRESH_THREAD = "forcedRefreshThread" -local function can_handle_fibaro_heat_controller(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_HEAT_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - - return false -end local function thermostat_mode_report_handler(self, device, cmd) local event = nil @@ -189,7 +166,7 @@ local fibaro_heat_controller = { added = device_added, init = map_components }, - can_handle = can_handle_fibaro_heat_controller + can_handle = require("fibaro-heat-controller.can_handle"), } return fibaro_heat_controller diff --git a/drivers/SmartThings/zwave-thermostat/src/init.lua b/drivers/SmartThings/zwave-thermostat/src/init.lua index 22ad3cdf43..6a79c1f4e1 100755 --- a/drivers/SmartThings/zwave-thermostat/src/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.Driver @@ -71,6 +61,9 @@ local function set_setpoint_factory(setpoint_type) size = 2 }) else + -- There have been issues with some thermostats failing to handle non-integer values + -- correctly. This rounding is intended to be removed. + value = utils.round(value) set = ThermostatSetpoint:Set({ setpoint_type = setpoint_type, scale = scale, @@ -112,19 +105,11 @@ local driver_template = { lifecycle_handlers = { added = device_added }, - sub_drivers = { - require("aeotec-radiator-thermostat"), - require("popp-radiator-thermostat"), - require("ct100-thermostat"), - require("fibaro-heat-controller"), - require("stelpro-ki-thermostat"), - require("qubino-flush-thermostat"), - require("thermostat-heating-battery"), - require("apiv6_bugfix"), - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } -defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) +defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) --- @type st.zwave.Driver local thermostat = ZwaveDriver("zwave_thermostat", driver_template) thermostat:run() diff --git a/drivers/SmartThings/zwave-thermostat/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-thermostat/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/can_handle.lua new file mode 100644 index 0000000000..19e5f39cf3 --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_popp_radiator_thermostat(opts, driver, device, ...) + local POPP_THERMOSTAT_FINGERPRINT = {mfr = 0x0002, prod = 0x0115, model = 0xA010} + + if device:id_match(POPP_THERMOSTAT_FINGERPRINT.mfr, POPP_THERMOSTAT_FINGERPRINT.prod, POPP_THERMOSTAT_FINGERPRINT.model) then + return true, require "popp-radiator-thermostat" + else + return false + end +end + +return can_handle_popp_radiator_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/init.lua b/drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/init.lua index f6ba9955b0..46234ea2ac 100755 --- a/drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/popp-radiator-thermostat/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local utils = require "st.utils" @@ -29,8 +19,6 @@ local LATEST_WAKEUP = "latest_wakeup" local CACHED_SETPOINT = "cached_setpoint" local POPP_WAKEUP_INTERVAL = 600 --seconds -local POPP_THERMOSTAT_FINGERPRINT = {mfr = 0x0002, prod = 0x0115, model = 0xA010} - local function get_latest_wakeup_timestamp(device) return device:get_field(LATEST_WAKEUP) end @@ -48,10 +36,6 @@ local function seconds_since_latest_wakeup(device) end end -local function can_handle_popp_radiator_thermostat(opts, driver, device, ...) - return device:id_match(POPP_THERMOSTAT_FINGERPRINT.mfr, POPP_THERMOSTAT_FINGERPRINT.prod, POPP_THERMOSTAT_FINGERPRINT.model) -end - -- POPP is a sleepy device, therefore it won't accept setpoint commands rightaway. -- That's why driver waits for a device to wake up and then sends cached setpoint command. -- Driver assumes that wakeUps come in reguraly every 10 minutes. @@ -116,7 +100,7 @@ local popp_radiator_thermostat = { lifecycle_handlers = { added = added_handler }, - can_handle = can_handle_popp_radiator_thermostat + can_handle = require("popp-radiator-thermostat.can_handle"), } return popp_radiator_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/can_handle.lua new file mode 100644 index 0000000000..e5a6a8474b --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_qubino_thermostat(opts, driver, device, ...) + local FINGERPRINTS = require("qubino-flush-thermostat.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("qubino-flush-thermostat") + end + end + return false +end + +return can_handle_qubino_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/fingerprints.lua b/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/fingerprints.lua new file mode 100644 index 0000000000..d640f7e1ce --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local QUBINO_FINGERPRINTS = { + {mfr = 0x0159, prod = 0x0005, model = 0x0054}, -- Qubino Flush On/Off Thermostat 2 +} + +return QUBINO_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/init.lua b/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/init.lua index d80e58e2da..fd9ff2aacb 100644 --- a/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/qubino-flush-thermostat/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.defaults.switch local TemperatureMeasurementDefaults = require "st.zwave.defaults.temperatureMeasurement" @@ -31,9 +21,6 @@ local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ ve --- @type st.zwave.CommandClass.ThermostatOperatingState local ThermostatOperatingState = (require "st.zwave.CommandClass.ThermostatOperatingState")({version=1}) -local QUBINO_FINGERPRINTS = { - {mfr = 0x0159, prod = 0x0005, model = 0x0054}, -- Qubino Flush On/Off Thermostat 2 -} -- parameter which tells whether device is configured heat or cool thermostat mode local DEVICE_MODE_PARAMETER = 59 @@ -47,14 +34,6 @@ local CONFIGURED_MODE = "configured_mode" local COOL_MODE = "cool" local HEAT_MODE = "heat" -local function can_handle_qubino_thermostat(opts, driver, device, ...) - for _, fingerprint in ipairs(QUBINO_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function info_changed(self, device, event, args) local new_parameter_value @@ -137,7 +116,7 @@ local qubino_thermostat = { infoChanged = info_changed }, NAME = "qubino thermostat", - can_handle = can_handle_qubino_thermostat + can_handle = require("qubino-flush-thermostat.can_handle"), } return qubino_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/can_handle.lua new file mode 100644 index 0000000000..65af08df62 --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_stelpro_ki_thermostat(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("stelpro-ki-thermostat.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("stelpro-ki-thermostat") + end + end + + return false +end + +return can_handle_stelpro_ki_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/fingerprints.lua b/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/fingerprints.lua new file mode 100644 index 0000000000..00a43a739f --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local STELPRO_KI_THERMOSTAT_FINGERPRINTS = { + { manufacturerId = 0x0239, productType = 0x0001, productId = 0x0001 } -- Stelpro Ki Thermostat +} + +return STELPRO_KI_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/init.lua b/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/init.lua index 3520e35325..7b6acbe3e7 100644 --- a/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/stelpro-ki-thermostat/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local log = require "log" local capabilities = require "st.capabilities" @@ -21,19 +11,7 @@ local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ ve --- @type st.zwave.CommandClass.ThermostatMode local ThermostatMode = (require "st.zwave.CommandClass.ThermostatMode")({ version = 2 }) -local STELPRO_KI_THERMOSTAT_FINGERPRINTS = { - { manufacturerId = 0x0239, productType = 0x0001, productId = 0x0001 } -- Stelpro Ki Thermostat -} - -local function can_handle_stelpro_ki_thermostat(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(STELPRO_KI_THERMOSTAT_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function sensor_multilevel_report_handler(self, device, cmd) if (cmd.args.sensor_type == SensorMultilevel.sensor_type.TEMPERATURE) then @@ -138,7 +116,7 @@ local stelpro_ki_thermostat = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_stelpro_ki_thermostat, + can_handle = require("stelpro-ki-thermostat.can_handle"), } return stelpro_ki_thermostat diff --git a/drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua new file mode 100644 index 0000000000..38b6d5de87 --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aeotec-radiator-thermostat"), + lazy_load_if_possible("popp-radiator-thermostat"), + lazy_load_if_possible("ct100-thermostat"), + lazy_load_if_possible("fibaro-heat-controller"), + lazy_load_if_possible("stelpro-ki-thermostat"), + lazy_load_if_possible("qubino-flush-thermostat"), + lazy_load_if_possible("thermostat-heating-battery"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua old mode 100755 new mode 100644 index 6393613025..341926c301 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua @@ -94,7 +94,8 @@ test.register_message_test( -- }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -111,6 +112,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -127,6 +131,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -146,7 +153,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -164,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 17 } ) @@ -184,6 +205,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -209,7 +233,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -239,7 +266,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua index 55a35dd2a9..29645b580d 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua @@ -136,7 +136,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -162,7 +163,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 68, unit = "C"}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -187,7 +191,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({value = 68, unit = "C"}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -212,7 +219,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({value = 45}) )) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -268,6 +278,9 @@ test.register_message_test( ThermostatSetpoint:Get({setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1}) ) } + }, + { + min_api_version = 17 } ) @@ -324,6 +337,9 @@ test.register_message_test( ThermostatSetpoint:Get({setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1}) ) } + }, + { + min_api_version = 17 } ) @@ -386,7 +402,10 @@ test.register_coroutine_test( ThermostatOperatingState:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -452,7 +471,10 @@ test.register_coroutine_test( ThermostatOperatingState:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) -- these next two tests are based on actual messages from a real device @@ -475,7 +497,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 60.0, unit = 'F'}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -497,7 +522,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({value = 48}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua index 7cbc6e38e5..eee99d0445 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua @@ -115,7 +115,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -172,7 +173,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -189,6 +191,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -209,6 +214,9 @@ test.register_message_test( direction = "send", message = mock_device_extended:generate_test_message("extraTemperatureSensor", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -225,6 +233,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -244,7 +255,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -262,6 +284,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 17 } ) @@ -282,6 +307,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -325,7 +353,10 @@ test.register_coroutine_test( Configuration:Get({parameter_number = 3}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -350,7 +381,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -380,7 +414,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua old mode 100755 new mode 100644 index 7ff628f974..76b6dcc423 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua @@ -60,6 +60,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -76,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -95,8 +101,19 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -116,6 +133,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -137,7 +157,10 @@ test.register_coroutine_test( ) test.mock_time.advance_time(200) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -178,7 +201,10 @@ test.register_coroutine_test( mock_device, ThermostatSetpoint:Get({setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1})) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua index 00056c753a..d2cc7cd938 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua @@ -122,7 +122,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -144,7 +145,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -168,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -186,6 +191,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 17 } ) @@ -203,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 17 } ) @@ -227,6 +238,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -251,6 +265,9 @@ test.register_message_test( direction = "send", message = mock_device_cooling:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -305,7 +322,10 @@ test.register_coroutine_test( Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -354,7 +374,10 @@ test.register_coroutine_test( Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -379,7 +402,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -409,7 +435,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -434,6 +463,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 17 } ) @@ -453,6 +485,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 17 } ) @@ -471,7 +506,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 5, unit = "W" })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } + } } + }, + { + min_api_version = 17 } ) @@ -522,7 +568,10 @@ test.register_coroutine_test( Configuration:Get({ parameter_number = 59 }) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua index e94c9ee446..2af3f3e24c 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua @@ -62,11 +62,14 @@ test.register_coroutine_test( ThermostatSetpoint:Set({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, scale = 1, - value = 84.2 + value = 84 }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -102,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 33, unit = 'C'})) } + }, + { + min_api_version = 17 } ) @@ -138,6 +144,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 55, unit = 'F'})) } + }, + { + min_api_version = 17 } ) @@ -179,6 +188,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 30, unit = 'F'})) } + }, + { + min_api_version = 17 } ) @@ -220,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 122, unit = 'F'})) } + }, + { + min_api_version = 17 } ) @@ -256,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 17 } ) @@ -292,6 +310,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 17 } ) @@ -307,6 +328,9 @@ test.register_message_test( energy_save_heat = true })) } } + }, + { + min_api_version = 17 } ) @@ -325,6 +349,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "eco" })) } + }, + { + min_api_version = 17 } ) @@ -348,7 +375,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua index 9680419946..bf6fc0e42e 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua @@ -102,7 +102,10 @@ test.register_coroutine_test( "doConfigure() should generate WakeUp:IntervalSet", function() do_initial_setup() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -118,6 +121,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -147,7 +153,10 @@ test.register_coroutine_test( mock_device, WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -206,7 +215,10 @@ test.register_coroutine_test( zw_test_utilities.zwave_test_build_send_command(mock_device, Clock:Set({hour=now.hour, minute=now.min, weekday=WEEK[now.wday]})) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -225,6 +237,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 25, unit = "C"})) }, + }, + { + min_api_version = 17 } ) @@ -254,6 +269,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 4, unit = "C"})) }, + }, + { + min_api_version = 17 } ) @@ -283,6 +301,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 28, unit = "C"})) }, + }, + { + min_api_version = 17 } ) @@ -309,7 +330,10 @@ test.register_coroutine_test( capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 50, unit = "F"}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -399,7 +423,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -510,7 +537,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -610,7 +640,10 @@ test.register_coroutine_test( {mock_device.id, zw_test_utilities.zwave_test_build_receive_command(WakeUp:Notification({}))} ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -693,7 +726,10 @@ test.register_coroutine_test( }) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -732,7 +768,10 @@ test.register_coroutine_test( mock_device, WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -769,7 +808,10 @@ test.register_coroutine_test( WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua index f57f7cc6cf..900331c254 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua @@ -143,7 +143,8 @@ test.register_message_test( table.unpack(refresh_commands) }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -161,7 +162,8 @@ test.register_message_test( table.unpack(refresh_commands) }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -178,6 +180,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 17 } ) @@ -194,6 +199,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 17 } ) @@ -216,6 +224,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({ "off", "heat", "cool", "auto" }, {visibility={displayed=false}})) } + }, + { + min_api_version = 17 } ) @@ -237,6 +248,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({ "on", "auto", "circulate" }, {visibility={displayed=false}})) } + }, + { + min_api_version = 17 } ) @@ -256,7 +270,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } + } } + }, + { + min_api_version = 17 } ) @@ -277,6 +302,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 22 })) } + }, + { + min_api_version = 17 } ) @@ -294,6 +322,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 17 } ) @@ -311,6 +342,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.thermostatFanMode({ value = "circulate" })) } + }, + { + min_api_version = 17 } ) @@ -331,6 +365,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 17 } ) @@ -351,6 +388,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 68, unit = 'F' })) } + }, + { + min_api_version = 17 } ) @@ -376,6 +416,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 17 } ) @@ -401,7 +444,10 @@ test.register_coroutine_test( ThermostatFanMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -426,7 +472,10 @@ test.register_coroutine_test( ThermostatFanMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -451,7 +500,10 @@ test.register_coroutine_test( ThermostatFanMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -476,7 +528,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -501,7 +556,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -531,7 +589,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -582,7 +643,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -632,7 +696,65 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting the heating setpoint should generate the appropriate commands and round values not at half-degree increments", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.zwave:__queue_receive( + { + mock_device.id, + zw_test_utilities.zwave_test_build_receive_command( + ThermostatSetpoint:Report( + { + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, + scale = 1, + value = 68 + }) + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 68, unit = "F" }) + ) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, { capability = "thermostatHeatingSetpoint", command = "setHeatingSetpoint", args = { 70.2 } } }) + test.socket.zwave:__expect_send( + zw_test_utilities.zwave_test_build_send_command( + mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, + value = 70, + precision = 0, + size = 1, + scale = 1, + }) + ) + ) + test.wait_for_events() + + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utilities.zwave_test_build_send_command( + mock_device, + ThermostatSetpoint:Get({ + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 + }) + ) + ) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/can_handle.lua new file mode 100644 index 0000000000..f02798191a --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_thermostat_heating_battery(opts, driver, device, cmd, ...) + local DANFOSS_LC13_THERMOSTAT_FINGERPRINTS = require "thermostat-heating-battery.fingerprints" + for _, fingerprint in ipairs(DANFOSS_LC13_THERMOSTAT_FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require "thermostat-heating-battery" + end + end + + return false +end + +return can_handle_thermostat_heating_battery diff --git a/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/fingerprints.lua b/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/fingerprints.lua new file mode 100644 index 0000000000..bc32cac629 --- /dev/null +++ b/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local DANFOSS_LC13_THERMOSTAT_FINGERPRINTS = { + { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0003 }, -- Danfoss LC13 Thermostat + { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0004 } -- Danfoss LC13 Thermostat +} + +return DANFOSS_LC13_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/init.lua b/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/init.lua index 4cf3c22f24..60a9a2055f 100644 --- a/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/thermostat-heating-battery/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local utils = require "st.utils" @@ -42,11 +32,6 @@ local CLAMP = { CELSIUS_MAX = 28 } -local DANFOSS_LC13_THERMOSTAT_FINGERPRINTS = { - { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0003 }, -- Danfoss LC13 Thermostat - { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0004 } -- Danfoss LC13 Thermostat -} - local WEEK = {6, 0, 1, 2, 3, 4, 5} --[[ Danfoss LC13 (Living Connect) @@ -62,16 +47,6 @@ Note: https://idency.com/products/idencyhome/smarthome/sensors/danfoss-z-wave-li to the Z-Wave network, it only allows a one-way communication to change its setpoint. --]] -local function can_handle_thermostat_heating_battery(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(DANFOSS_LC13_THERMOSTAT_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - - return false -end - local function adjust_temperature_if_exceeded_min_max_limit (degree, scale) if scale == ThermostatSetpoint.scale.CELSIUS then return utils.clamp_value(degree, CLAMP.CELSIUS_MIN, CLAMP.CELSIUS_MAX) @@ -283,7 +258,7 @@ local thermostat_heating_battery = { init = device_init, added = added_handler, }, - can_handle = can_handle_thermostat_heating_battery + can_handle = require("thermostat-heating-battery.can_handle"), } return thermostat_heating_battery diff --git a/drivers/SmartThings/zwave-valve/src/init.lua b/drivers/SmartThings/zwave-valve/src/init.lua index 7392f1cbe1..f430c19a4a 100644 --- a/drivers/SmartThings/zwave-valve/src/init.lua +++ b/drivers/SmartThings/zwave-valve/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -26,10 +16,8 @@ local driver_template = { supported_capabilities = { capabilities.valve, }, - sub_drivers = { - -- Fortrezz and Zooz valves treat open as "off" and close as "on" - require("inverse_valve") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-valve/src/inverse_valve/can_handle.lua b/drivers/SmartThings/zwave-valve/src/inverse_valve/can_handle.lua new file mode 100644 index 0000000000..b36334f2c9 --- /dev/null +++ b/drivers/SmartThings/zwave-valve/src/inverse_valve/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function inverse_valve_can_handle(opts, driver, device, ...) + if device.zwave_manufacturer_id == 0x0084 or device.zwave_manufacturer_id == 0x027A then + return true, require("inverse_valve") + end + return false +end + +return inverse_valve_can_handle diff --git a/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua b/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua index 510e79019b..853bada351 100644 --- a/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua +++ b/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -55,9 +45,7 @@ local inverse_valve = { [capabilities.valve.commands.close.NAME] = close_handler } }, - can_handle = function(opts, driver, device, ...) - return device.zwave_manufacturer_id == 0x0084 or device.zwave_manufacturer_id == 0x027A - end + can_handle = require("inverse_valve.can_handle"), } return inverse_valve diff --git a/drivers/SmartThings/zwave-valve/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-valve/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-valve/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-valve/src/sub_drivers.lua b/drivers/SmartThings/zwave-valve/src/sub_drivers.lua new file mode 100644 index 0000000000..b43856e0cd --- /dev/null +++ b/drivers/SmartThings/zwave-valve/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("inverse_valve"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua b/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua index db7d6be656..801a1608eb 100644 --- a/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua +++ b/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -57,6 +47,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 17 } ) @@ -75,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 17 } ) @@ -93,6 +89,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 17 } ) @@ -111,6 +110,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 17 } ) @@ -141,7 +143,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -171,7 +176,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua b/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua index 4a21568119..3743f62fd5 100644 --- a/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua +++ b/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -79,6 +69,9 @@ test.register_message_test( direction = "send", message = mock_valve_binary:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 17 } ) @@ -95,6 +88,9 @@ test.register_message_test( direction = "send", message = mock_valve_binary:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 17 } ) @@ -124,7 +120,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -154,7 +151,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -185,7 +183,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -215,7 +216,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -245,7 +249,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -275,7 +282,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua b/drivers/SmartThings/zwave-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua index dffe6b2f38..e496b5a629 100644 --- a/drivers/SmartThings/zwave-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua +++ b/drivers/SmartThings/zwave-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua @@ -65,6 +65,9 @@ test.register_message_test( direction = "send", message = mock_momentary_switch:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 17 } ) @@ -81,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_momentary_switch:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -106,7 +112,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -128,7 +135,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -176,7 +184,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) @@ -223,7 +234,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -250,7 +264,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/fingerprints.yml b/drivers/SmartThings/zwave-window-treatment/fingerprints.yml index abd043a079..0eed587497 100644 --- a/drivers/SmartThings/zwave-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zwave-window-treatment/fingerprints.yml @@ -57,6 +57,12 @@ zwaveManufacturer: manufacturerId: 0x0371 productId: 0x008D deviceProfileName: stateless-curtain-power-button + - id: 277/529/16 + deviceLabel: RYSE SmartShades + manufacturerId: 0x0115 + productId: 0x0010 + productType: 0x0211 + deviceProfileName: window-treatment-preset-reverse zwaveGeneric: - id: window-treatment/generic/1 deviceLabel: Z-Wave Window Treatment diff --git a/drivers/SmartThings/zwave-window-treatment/profiles/window-treatment-preset-reverse.yml b/drivers/SmartThings/zwave-window-treatment/profiles/window-treatment-preset-reverse.yml index a786609ae2..b1b46c51ef 100644 --- a/drivers/SmartThings/zwave-window-treatment/profiles/window-treatment-preset-reverse.yml +++ b/drivers/SmartThings/zwave-window-treatment/profiles/window-treatment-preset-reverse.yml @@ -15,7 +15,5 @@ components: categories: - name: Blind preferences: - - preferenceId: presetPosition - explicit: true - preferenceId: reverse explicit: true diff --git a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/can_handle.lua new file mode 100644 index 0000000000..7caf966d19 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_nano_shutter(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-nano-shutter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-nano-shutter") + end + end + return false +end + +return can_handle_aeotec_nano_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/fingerprints.lua new file mode 100644 index 0000000000..dcbcf2f872 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_NANO_SHUTTER_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU + {mfr = 0x0086, prod = 0x0103, model = 0x008D}, -- Aeotec nano shutter US + {mfr = 0x0371, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU + {mfr = 0x0371, prod = 0x0103, model = 0x008D} -- Aeotec nano shutter US +} + +return AEOTEC_NANO_SHUTTER_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua index 6ca6fd7837..82977a7cfb 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -28,26 +18,12 @@ local SET_BUTTON_TO_CLOSE = "close" local SET_BUTTON_TO_PAUSE = "pause" local SHADE_STATE = "shade_state" -local AEOTEC_NANO_SHUTTER_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU - {mfr = 0x0086, prod = 0x0103, model = 0x008D}, -- Aeotec nano shutter US - {mfr = 0x0371, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU - {mfr = 0x0371, prod = 0x0103, model = 0x008D} -- Aeotec nano shutter US -} --- Determine whether the passed device is proper --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is proper, else false -local function can_handle_aeotec_nano_shutter(opts, driver, device, ...) - for _, fingerprint in ipairs(AEOTEC_NANO_SHUTTER_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end --- Default handler for basic reports for the devices --- @@ -140,7 +116,7 @@ local aeotec_nano_shutter = { added = added_handler }, NAME = "Aeotec nano shutter", - can_handle = can_handle_aeotec_nano_shutter + can_handle = require("aeotec-nano-shutter.can_handle"), } return aeotec_nano_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/can_handle.lua new file mode 100644 index 0000000000..881ebd36a3 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_iblinds_window_treatment(opts, driver, device, ...) + local FINGERPRINTS = require("iblinds-window-treatment.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("iblinds-window-treatment") + end + end + return false +end + +return can_handle_iblinds_window_treatment diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/fingerprints.lua new file mode 100644 index 0000000000..d4f652f8b9 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IBLINDS_WINDOW_TREATMENT_FINGERPRINTS = { + {mfr = 0x0287, prod = 0x0003, model = 0x000D}, -- iBlinds Window Treatment v1 / v2 + {mfr = 0x0287, prod = 0x0004, model = 0x0071}, -- iBlinds Window Treatment v3 + {mfr = 0x0287, prod = 0x0004, model = 0x0072} -- iBlinds Window Treatment v3.1 +} + +return IBLINDS_WINDOW_TREATMENT_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua index 2b981cd7ab..9c3d2d9970 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua @@ -1,40 +1,18 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=3 }) +local window_preset_defaults = require "window_preset_defaults" -local IBLINDS_WINDOW_TREATMENT_FINGERPRINTS = { - {mfr = 0x0287, prod = 0x0003, model = 0x000D}, -- iBlinds Window Treatment v1 / v2 - {mfr = 0x0287, prod = 0x0004, model = 0x0071}, -- iBlinds Window Treatment v3 - {mfr = 0x0287, prod = 0x0004, model = 0x0072} -- iBlinds Window Treatment v3.1 -} --- Determine whether the passed device is iblinds window treatment --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is iblinds window treatment, else false -local function can_handle_iblinds_window_treatment(opts, driver, device, ...) - for _, fingerprint in ipairs(IBLINDS_WINDOW_TREATMENT_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local capability_handlers = {} @@ -70,8 +48,11 @@ function capability_handlers.set_shade_level(driver, device, command) set_shade_level_helper(driver, device, command.args.shadeLevel) end -function capability_handlers.preset_position(driver, device) - set_shade_level_helper(driver, device, device.preferences.presetPosition or 50) +function capability_handlers.preset_position(driver, device, command) + local level = device:get_latest_state(command.component, "windowShadePreset", "position") or + device:get_field(window_preset_defaults.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or 50 + set_shade_level_helper(driver, device, level) end local iblinds_window_treatment = { @@ -87,11 +68,9 @@ local iblinds_window_treatment = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = capability_handlers.preset_position } }, - sub_drivers = { - require("iblinds-window-treatment.v3") - }, + sub_drivers = require("iblinds-window-treatment.sub_drivers"), NAME = "iBlinds window treatment", - can_handle = can_handle_iblinds_window_treatment + can_handle = require("iblinds-window-treatment.can_handle"), } return iblinds_window_treatment diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/sub_drivers.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/sub_drivers.lua new file mode 100644 index 0000000000..187ef0ee04 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require("lazy_load_subdriver") + +return { + lazy_load_if_possible("iblinds-window-treatment.v3") +} diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/can_handle.lua new file mode 100644 index 0000000000..ababe18ac6 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is iblinds window treatment v3 +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @return boolean true if the device is iblinds window treatment, else false +local function can_handle_iblinds_window_treatment_v3(opts, driver, device, ...) + local FINGERPRINTS = require("iblinds-window-treatment.v3.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("iblinds-window-treatment.v3") + end + end + return false +end + +return can_handle_iblinds_window_treatment_v3 diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/fingerprints.lua new file mode 100644 index 0000000000..ee256ea18a --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IBLINDS_WINDOW_TREATMENT_FINGERPRINTS_V3 = { + {mfr = 0x0287, prod = 0x0004, model = 0x0071}, -- iBlinds Window Treatment v3 + {mfr = 0x0287, prod = 0x0004, model = 0x0072} -- iBlinds Window Treatment v3 +} + +return IBLINDS_WINDOW_TREATMENT_FINGERPRINTS_V3 diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/init.lua similarity index 81% rename from drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3.lua rename to drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/init.lua index abc16b678c..c0bb8bc363 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.SwitchMultilevel @@ -35,6 +25,15 @@ local function can_handle_iblinds_window_treatment_v3(opts, driver, device, ...) return false end +local function init_handler(self, device) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.supportedCommands.NAME) == nil then + + -- setPresetPosition is not supported (device uses a separate preference) + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition"}, { visibility = { displayed = false }})) + end +end + local capability_handlers = {} function capability_handlers.close(driver, device) @@ -65,6 +64,9 @@ function capability_handlers.preset_position(driver, device) end local iblinds_window_treatment_v3 = { + lifecycle_handlers = { + init = init_handler + }, capability_handlers = { [capabilities.windowShade.ID] = { [capabilities.windowShade.commands.close.NAME] = capability_handlers.close diff --git a/drivers/SmartThings/zwave-window-treatment/src/init.lua b/drivers/SmartThings/zwave-window-treatment/src/init.lua index 1cc9c34141..8b8f32c49b 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -20,6 +10,24 @@ local ZwaveDriver = require "st.zwave.driver" --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) local preferencesMap = require "preferences" +local window_preset_defaults = require "window_preset_defaults" + +local function init_handler(self, device) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then + + -- These should only ever be nil once (and at the same time) for already-installed devices + -- It can be relocated to `added` after migration is complete + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + + local preset_position = device:get_field(window_preset_defaults.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + window_preset_defaults.PRESET_LEVEL + + device:emit_event(capabilities.windowShadePreset.position(preset_position, { visibility = {displayed = false}})) + device:set_field(window_preset_defaults.PRESET_LEVEL_KEY, preset_position, {persist = true}) + end +end local function added_handler(self, device) device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, { visibility = { displayed = false } })) @@ -56,15 +64,18 @@ local driver_template = { capabilities.battery }, lifecycle_handlers = { + init = init_handler, added = added_handler, infoChanged = info_changed }, - sub_drivers = { - require("springs-window-fashion-shade"), - require("iblinds-window-treatment"), - require("window-treatment-venetian"), - require("aeotec-nano-shutter") - } + capability_handlers = { + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = window_preset_defaults.set_preset_position_cmd, + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_preset_defaults.window_shade_preset_cmd, + } + }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-window-treatment/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-window-treatment/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-window-treatment/src/preferences.lua b/drivers/SmartThings/zwave-window-treatment/src/preferences.lua index e8854c0bc0..54bad4c0ec 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/preferences.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { QUBINO = { diff --git a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/can_handle.lua new file mode 100644 index 0000000000..12d85394cf --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_springs_window_fashion_shade(opts, driver, device, ...) + local FINGERPRINTS = require("springs-window-fashion-shade.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("springs-window-fashion-shade") + end + end + return false +end + +return can_handle_springs_window_fashion_shade diff --git a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/fingerprints.lua new file mode 100644 index 0000000000..7730b633e8 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SPRINGS_WINDOW_FINGERPRINTS = { + {mfr = 0x026E, prod = 0x4353, model = 0x5A31}, -- Springs Window Shade + {mfr = 0x026E, prod = 0x5253, model = 0x5A31}, -- Springs Roller Shade +} + +return SPRINGS_WINDOW_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua index 6e8ef08b09..2f4d3c38e6 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.constants @@ -18,23 +8,21 @@ local constants = require "st.zwave.constants" --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) -local SPRINGS_WINDOW_FINGERPRINTS = { - {mfr = 0x026E, prod = 0x4353, model = 0x5A31}, -- Springs Window Shade - {mfr = 0x026E, prod = 0x5253, model = 0x5A31}, -- Springs Roller Shade -} --- Determine whether the passed device is springs window fashion shade --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is springs window fashion shade, else false -local function can_handle_springs_window_fashion_shade(opts, driver, device, ...) - for _, fingerprint in ipairs(SPRINGS_WINDOW_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end + +local function init_handler(self, device) + -- This device has a preset position set in hardware, so we need to override the base driver + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.supportedCommands.NAME) == nil then + + -- setPresetPosition is not supported + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition"}, { visibility = { displayed = false }})) end - return false end local capability_handlers = {} @@ -58,13 +46,16 @@ function capability_handlers.preset_position(driver, device) end local springs_window_fashion_shade = { + lifecycle_handlers = { + init = init_handler + }, capability_handlers = { [capabilities.windowShadePreset.ID] = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = capability_handlers.preset_position } }, NAME = "Springs window fashion shade", - can_handle = can_handle_springs_window_fashion_shade, + can_handle = require("springs-window-fashion-shade.can_handle"), } return springs_window_fashion_shade diff --git a/drivers/SmartThings/zwave-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zwave-window-treatment/src/sub_drivers.lua new file mode 100644 index 0000000000..6db8853e1a --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("springs-window-fashion-shade"), + lazy_load_if_possible("iblinds-window-treatment"), + lazy_load_if_possible("window-treatment-venetian"), + lazy_load_if_possible("aeotec-nano-shutter"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua index 9c800e7070..ffeb175b43 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -76,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 17 } ) @@ -99,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -122,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 17 } ) @@ -152,6 +151,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 17 } ) @@ -182,6 +184,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -212,6 +217,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 17 } ) @@ -243,7 +251,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -274,7 +285,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -293,7 +307,10 @@ test.register_coroutine_test( SwitchMultilevel:StopLevelChange({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -303,7 +320,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive( { mock_fibaro_roller_shutter.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.zwave:__expect_send( @@ -324,7 +341,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -355,7 +375,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -380,6 +403,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter_venetian:generate_test_message("venetianBlind", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -403,7 +429,10 @@ test.register_coroutine_test( test.socket.zwave:__queue_receive({mock_fibaro_roller_shutter.id, Configuration:Report({ parameter_number = 150, configuration_value = 1 }) }) test.wait_for_events() assert(mock_fibaro_roller_shutter:get_field("calibration") == "done", "Calibration should be done") - end + end, + { + min_api_version = 17 + } ) do @@ -425,7 +454,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -448,7 +481,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -471,7 +508,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -494,7 +535,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -517,7 +562,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -540,7 +589,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -563,7 +616,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -586,7 +643,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -609,7 +670,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -632,7 +697,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -655,7 +724,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -678,7 +751,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -701,7 +778,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -724,7 +805,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -747,7 +832,43 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end +test.register_coroutine_test( + "Configuration:Report for OPERATING_MODE=1 should update to roller shutter profile", + function() + test.socket.zwave:__queue_receive({ + mock_fibaro_roller_shutter_venetian.id, + zw_test_utils.zwave_test_build_receive_command( + Configuration:Report({ parameter_number = 151, configuration_value = 1 }) + ) + }) + mock_fibaro_roller_shutter_venetian:expect_metadata_update({ profile = "fibaro-roller-shutter" }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Configuration:Report for OPERATING_MODE=2 should update to venetian profile", + function() + test.socket.zwave:__queue_receive({ + mock_fibaro_roller_shutter_venetian.id, + zw_test_utils.zwave_test_build_receive_command( + Configuration:Report({ parameter_number = 151, configuration_value = 2 }) + ) + }) + mock_fibaro_roller_shutter_venetian:expect_metadata_update({ profile = "fibaro-roller-shutter-venetian" }) + end, + { + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua index ce5ed9fb81..4127e8f7c4 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -84,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 17 } ) @@ -114,6 +107,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -144,6 +140,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 17 } ) @@ -174,7 +173,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -204,7 +206,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -223,7 +228,10 @@ test.register_coroutine_test( SwitchMultilevel:StopLevelChange({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -233,7 +241,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive( { mock_qubino_flush_shutter.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.zwave:__expect_send( @@ -254,7 +262,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -284,7 +295,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -309,6 +323,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter_venetian:generate_test_message("venetianBlind", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -339,7 +356,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {2}}) ) ) - end + end, + { + min_api_version = 17 + } ) do @@ -361,7 +381,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -384,7 +408,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -407,7 +435,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -430,7 +462,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -453,7 +489,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -476,7 +516,11 @@ do }) ) ) - end + end, + { + min_api_version = 17 + } + ) end @@ -517,125 +561,134 @@ test.register_coroutine_test( capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, { visibility = { displayed = false }}) ) ) - end + end, + { + min_api_version = 17 + } ) -do - test.register_coroutine_test( - "Mode should be changed to venetian blinds after receiving configuration report with value 1", - function() - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Configuration:Report({ - parameter_number = 71, - size = 1, - configuration_value = 1 - }) +test.register_coroutine_test( + "Mode should be changed to venetian blinds after receiving configuration report with value 1", + function() + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Configuration:Report({ + parameter_number = 71, + size = 1, + configuration_value = 1 }) - mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter-venetian" }) - end - ) -end + }) + mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter-venetian" }) + end, + { + min_api_version = 17 + } +) -do - test.register_coroutine_test( - "Mode should be changed to shutter after receiving configuration report with value 0", - function() - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Configuration:Report({ - parameter_number = 71, - size = 1, - configuration_value = 0 - }) +test.register_coroutine_test( + "Mode should be changed to shutter after receiving configuration report with value 0", + function() + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Configuration:Report({ + parameter_number = 71, + size = 1, + configuration_value = 0 }) - mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter" }) - end - ) -end + }) + mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter" }) + end, + { + min_api_version = 17 + } +) -do - test.register_coroutine_test( - "SwitchMultilevel:Set() should be correctly interpreted by the driver", - function() - local targetValue = 50 - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - SwitchMultilevel:Set({ - value = targetValue - }) +test.register_coroutine_test( + "SwitchMultilevel:Set() should be correctly interpreted by the driver", + function() + local targetValue = 50 + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + SwitchMultilevel:Set({ + value = targetValue }) - local expectedCachedEvent = utils.stringify_table(capabilities.windowShade.windowShade.opening()) - test.wait_for_events() - local actualCachedEvent = utils.stringify_table(mock_qubino_flush_shutter.transient_store.blinds_last_command) - assert(expectedCachedEvent == actualCachedEvent, "driver should cache 'opening' event when targetLevel > currentLevel") - assert(targetValue == mock_qubino_flush_shutter.transient_store.shade_target, "driver should chache correct level value") - end - ) -end + }) + local expectedCachedEvent = utils.stringify_table(capabilities.windowShade.windowShade.opening()) + test.wait_for_events() + local actualCachedEvent = utils.stringify_table(mock_qubino_flush_shutter.transient_store.blinds_last_command) + assert(expectedCachedEvent == actualCachedEvent, "driver should cache 'opening' event when targetLevel > currentLevel") + assert(targetValue == mock_qubino_flush_shutter.transient_store.shade_target, "driver should chache correct level value") + end, + { + min_api_version = 17 + } +) -do - test.register_coroutine_test( - "Meter:Report() with meter_value > 0 should be correctly interpreted by the driver", - function() - local cachedShadesEvent = capabilities.windowShade.windowShade.opening() - local targetValue = 50 - mock_qubino_flush_shutter:set_field("blinds_last_command", cachedShadesEvent) - mock_qubino_flush_shutter:set_field("shade_target", targetValue) - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Meter:Report({ - scale = Meter.scale.electric_meter.WATTS, - meter_value = 10 - }) +test.register_coroutine_test( + "Meter:Report() with meter_value > 0 should be correctly interpreted by the driver", + function() + local cachedShadesEvent = capabilities.windowShade.windowShade.opening() + local targetValue = 50 + mock_qubino_flush_shutter:set_field("blinds_last_command", cachedShadesEvent) + mock_qubino_flush_shutter:set_field("shade_target", targetValue) + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 10 }) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", cachedShadesEvent) - ) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(targetValue)) - ) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 10, unit = "W"})) - ) - end - ) -end + }) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", cachedShadesEvent) + ) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(targetValue)) + ) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 10, unit = "W"})) + ) + end, + { + min_api_version = 17 + } +) -do - test.register_coroutine_test( - "Meter:Report() with meter_value == 0 should be correctly interpreted by the driver", - function() - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Meter:Report({ - scale = Meter.scale.electric_meter.WATTS, - meter_value = 0 - }) + +test.register_coroutine_test( + "Meter:Report() with meter_value == 0 should be correctly interpreted by the driver", + function() + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 0 }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_qubino_flush_shutter, - SwitchMultilevel:Get({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_qubino_flush_shutter, - Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) - ) + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter, + SwitchMultilevel:Get({}) ) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 0, unit = "W"})) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) ) - end - ) -end + ) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 0, unit = "W"})) + ) + end, + { + min_api_version = 17 + } +) test.register_message_test( "Energy meter reports should be generating events", @@ -658,7 +711,71 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.energyMeter.energy({value = 50, unit = "kWh"})) } + }, + { + min_api_version = 17 } ) +test.register_coroutine_test( + "SwitchMultilevel:Set with lower target than current should cache closing command and fire Meter:Get after 4s", + function() + -- Pre-set state so currentLevel (80) > targetLevel (10) + mock_qubino_flush_shutter_venetian:update_state_cache_entry( + "main", capabilities.windowShadeLevel.ID, "shadeLevel", { value = 80 } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter_venetian.id, + SwitchMultilevel:Set({ value = 10 }) + }) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter_venetian, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + ) + end, + { + min_api_version = 17 + } +) + +do + local new_param_value = 1 + test.register_coroutine_test( + "infoChanged on venetian qubino should send Configuration:Set then Configuration:Get after 1s", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + local device_data = utils.deep_copy(mock_qubino_flush_shutter_venetian.raw_st_data) + device_data.preferences["operatingModes"] = new_param_value + local device_data_json = dkjson.encode(device_data) + test.socket.device_lifecycle:__queue_receive({ mock_qubino_flush_shutter_venetian.id, "infoChanged", device_data_json }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter_venetian, + Configuration:Set({ + parameter_number = 71, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter_venetian, + Configuration:Get({ parameter_number = 71 }) + ) + ) + end, + { + min_api_version = 17 + } + + ) +end + test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua index c11b578b07..8f6442b0ee 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -56,7 +46,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -74,7 +67,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -104,7 +100,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -140,7 +139,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +168,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -192,7 +197,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -230,7 +238,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -274,7 +285,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -285,7 +299,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_window_button, Configuration:Set({parameter_number = 80, size = 1, configuration_value = 1}))) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_window_button, Configuration:Set({parameter_number = 85, size = 1, configuration_value = 1}))) mock_window_button:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -315,6 +332,9 @@ test.register_message_test( Basic:Get({}) ) }, + }, + { + min_api_version = 17 } ) @@ -327,7 +347,10 @@ test.register_coroutine_test( capabilities.statelessCurtainPowerButton.availableCurtainPowerButtons({"open", "close", "pause"}, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -344,7 +367,10 @@ test.register_coroutine_test( mock_window_button, Configuration:Set({parameter_number = 35, size = 1, configuration_value = 100}) )) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua index bdc41956ec..cd68015466 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -49,6 +39,15 @@ local mock_blind_v3 = test.mock_device.build_test_zwave_device({ local function test_init() test.mock_device.add_test_device(mock_blind) test.mock_device.add_test_device(mock_blind_v3) + test.socket.capability:__expect_send( + mock_blind:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_blind:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_blind_v3:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition"}, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -76,7 +75,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -103,7 +105,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -140,7 +145,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -167,7 +175,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -204,7 +215,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -214,7 +228,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive( { mock_blind.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.capability:__expect_send( @@ -231,7 +245,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -240,18 +257,20 @@ test.register_coroutine_test( test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") test.socket.zwave:__set_channel_ordering("relaxed") test.socket.device_lifecycle():__queue_receive({mock_blind.id, "init"}) - test.socket.device_lifecycle():__queue_receive(mock_blind:generate_info_changed( - { - preferences = { - presetPosition = 35 - } - } - )) + test.socket.capability:__queue_receive( + { + mock_blind.id, + { capability = "windowShadePreset", component = "main", command = "setPresetPosition", args = {35} } + } + ) + test.socket.capability:__expect_send( + mock_blind:generate_test_message("main", capabilities.windowShadePreset.position(35)) + ) test.wait_for_events() test.socket.capability:__queue_receive( { mock_blind.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.capability:__expect_send( @@ -268,7 +287,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -295,7 +317,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -323,7 +348,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -364,7 +392,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -374,7 +405,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive( { mock_blind_v3.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.capability:__expect_send( @@ -391,7 +422,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -415,7 +449,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive( { mock_blind_v3.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.capability:__expect_send( @@ -432,7 +466,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -471,7 +508,64 @@ test.register_coroutine_test( mock_blind_v3, Configuration:Set({parameter_number = 6, size = 1, configuration_value = 50}) )) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting window shade level to 0 on iblinds v1 should emit windowShade.closed", + function() + test.socket.capability:__queue_receive( + { + mock_blind.id, + { capability = "windowShadeLevel", command = "setShadeLevel", args = { 0 } } + } + ) + test.socket.capability:__expect_send( + mock_blind:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_blind:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_blind, + SwitchMultilevel:Set({ value = 0 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting window shade level to 0 on iblinds v3 should emit windowShade.closed", + function() + test.socket.capability:__queue_receive( + { + mock_blind_v3.id, + { capability = "windowShadeLevel", command = "setShadeLevel", args = { 0 } } + } + ) + test.socket.capability:__expect_send( + mock_blind_v3:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_blind_v3:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_blind_v3, + SwitchMultilevel:Set({ value = 0 }) + ) + ) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua index 4335ae3b41..c6c0ca263f 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local constants = require "st.zwave.constants" @@ -18,6 +8,7 @@ local zw = require "st.zwave" local zw_test_utils = require "integration_test.zwave_test_utils" local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=4 }) local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" -- supported comand classes: SWITCH_MULTILEVEL local window_shade_switch_multilevel_endpoints = { @@ -38,6 +29,9 @@ local mock_springs_window_fashion_shade = test.mock_device.build_test_zwave_devi local function test_init() test.mock_device.add_test_device(mock_springs_window_fashion_shade) + test.socket.capability:__expect_send( + mock_springs_window_fashion_shade:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition"}, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -48,7 +42,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive( { mock_springs_window_fashion_shade.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.zwave:__expect_send( @@ -69,7 +63,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua index bf60c1797b..c5ecd837d4 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -54,6 +44,18 @@ local mock_window_shade_switch_multilevel = test.mock_device.build_test_zwave_de local function test_init() test.mock_device.add_test_device(mock_window_shade_basic) test.mock_device.add_test_device(mock_window_shade_switch_multilevel) + test.socket.capability:__expect_send( + mock_window_shade_basic:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_window_shade_basic:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) end test.set_test_init_function(test_init) @@ -77,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_basic:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 17 } ) @@ -100,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_basic:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -123,6 +131,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_basic:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 17 } ) @@ -153,6 +164,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 17 } ) @@ -183,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -213,6 +230,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 17 } ) @@ -244,7 +264,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -275,7 +298,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -294,7 +320,10 @@ test.register_coroutine_test( SwitchMultilevel:StopLevelChange({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -304,7 +333,7 @@ test.register_coroutine_test( test.socket.capability:__queue_receive( { mock_window_shade_switch_multilevel.id, - { capability = "windowShadePreset", command = "presetPosition", args = {} } + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } } ) test.socket.zwave:__expect_send( @@ -325,7 +354,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -356,7 +388,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -382,7 +417,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShade.windowShade.open())) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -408,7 +446,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShade.windowShade.closed())) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -447,7 +488,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) @@ -487,7 +531,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -526,7 +573,55 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting window shade preset on basic-only device should generate Basic:Set and Basic:Get", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.capability:__queue_receive( + { + mock_window_shade_basic.id, + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + } + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_window_shade_basic, + Basic:Set({ value = 50 }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(5) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_window_shade_basic, + Basic:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Adding a window treatment device should emit supportedWindowShadeCommands", + function() + test.socket.device_lifecycle():__queue_receive({ mock_window_shade_basic.id, "added" }) + test.socket.capability:__expect_send( + mock_window_shade_basic:generate_test_message("main", capabilities.windowShade.supportedWindowShadeCommands( + {"open", "close", "pause"}, { visibility = { displayed = false } } + )) + ) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/can_handle.lua new file mode 100644 index 0000000000..f2eb62ad27 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_window_treatment_venetian(opts, driver, device, ...) + local FINGERPRINTS = require("window-treatment-venetian.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("window-treatment-venetian") + end + end + return false +end + +return can_handle_window_treatment_venetian diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/can_handle.lua new file mode 100644 index 0000000000..cc9ca62b1c --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_roller_shutter(opts, driver, device, ...) + local FINGERPRINTS = require("window-treatment-venetian.fibaro-roller-shutter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("window-treatment-venetian.fibaro-roller-shutter") + end + end + return false +end + +return can_handle_fibaro_roller_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/fingerprints.lua new file mode 100644 index 0000000000..925685157f --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_ROLLER_SHUTTER_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter +} + +return FIBARO_ROLLER_SHUTTER_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua index dbdde5b87c..5581e3a5af 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua @@ -1,25 +1,12 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass local cc = (require "st.zwave.CommandClass") --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({version=1}) -local FIBARO_ROLLER_SHUTTER_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter -} -- configuration parameters local CALIBRATION_CONFIGURATION = 150 @@ -33,14 +20,6 @@ local CLB_NOT_STARTED = "not_started" local CLB_DONE = "done" local CLB_PENDING = "pending" -local function can_handle_fibaro_roller_shutter(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_ROLLER_SHUTTER_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function configuration_report(driver, device, cmd) local parameter_number = cmd.args.parameter_number @@ -79,7 +58,7 @@ local fibaro_roller_shutter = { } }, NAME = "fibaro roller shutter", - can_handle = can_handle_fibaro_roller_shutter, + can_handle = require("window-treatment-venetian.fibaro-roller-shutter.can_handle"), lifecycle_handlers = { add = device_added } diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fingerprints.lua new file mode 100644 index 0000000000..91dc96c5b8 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local WINDOW_TREATMENT_VENETIAN_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter + {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC + {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC +} + +return WINDOW_TREATMENT_VENETIAN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua index 2ff27250a8..c201ed32eb 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cc = (require "st.zwave.CommandClass") local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) @@ -19,20 +10,7 @@ local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ver local WindowShadeDefaults = require "st.zwave.defaults.windowShade" local WindowShadeLevelDefaults = require "st.zwave.defaults.windowShadeLevel" -local WINDOW_TREATMENT_VENETIAN_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter - {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC - {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC -} -local function can_handle_window_treatment_venetian(opts, driver, device, ...) - for _, fingerprint in ipairs(WINDOW_TREATMENT_VENETIAN_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function shade_event_handler(self, device, cmd) WindowShadeDefaults.zwave_handlers[cc.SWITCH_MULTILEVEL][SwitchMultilevel.REPORT](self, device, cmd) @@ -70,14 +48,11 @@ local window_treatment_venetian = { [SwitchMultilevel.REPORT] = shade_event_handler } }, - can_handle = can_handle_window_treatment_venetian, + can_handle = require("window-treatment-venetian.can_handle"), lifecycle_handlers = { init = map_components }, - sub_drivers = { - require("window-treatment-venetian/fibaro-roller-shutter"), - require("window-treatment-venetian/qubino-flush-shutter") - } + sub_drivers = require("window-treatment-venetian.sub_drivers"), } return window_treatment_venetian diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/can_handle.lua new file mode 100644 index 0000000000..e601800084 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_qubino_flush_shutter(opts, self, device, ...) + local FINGERPRINTS = require("window-treatment-venetian.qubino-flush-shutter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("window-treatment-venetian.qubino-flush-shutter") + end + end + return false +end + +return can_handle_qubino_flush_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/fingerprints.lua new file mode 100644 index 0000000000..098879f313 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local QUBINO_FLUSH_SHUTTER_FINGERPRINTS = { + {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC + {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC +} + +return QUBINO_FLUSH_SHUTTER_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua index fafc6d5026..56ba50f516 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -41,19 +31,7 @@ local SHADE_TARGET = "shade_target" local ENERGY_UNIT_KWH = "kWh" local POWER_UNIT_WATT = "W" -local QUBINO_FLUSH_SHUTTER_FINGERPRINTS = { - {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC - {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC -} -local function can_handle_qubino_flush_shutter(opts, self, device, ...) - for _, fingerprint in ipairs(QUBINO_FLUSH_SHUTTER_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function configuration_report(self, device, cmd) local parameter_number = cmd.args.parameter_number @@ -190,7 +168,7 @@ local qubino_flush_shutter = { [capabilities.windowShade.commands.close.NAME] = close }, }, - can_handle = can_handle_qubino_flush_shutter, + can_handle = require("window-treatment-venetian.qubino-flush-shutter.can_handle"), lifecycle_handlers = { added = device_added, infoChanged = info_changed diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/sub_drivers.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/sub_drivers.lua new file mode 100644 index 0000000000..41600cffef --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("window-treatment-venetian/fibaro-roller-shutter"), + lazy_load_if_possible("window-treatment-venetian/qubino-flush-shutter"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua b/drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua new file mode 100644 index 0000000000..3d5056abc5 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua @@ -0,0 +1,55 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + + +-- These were added to scripting engine, but this file is to make sure drivers +-- runing on older versions of scripting engine can still access these values +local capabilities = require "st.capabilities" +local constants = require "st.zwave.constants" + +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.SwitchMultilevel +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version = 4 }) +--- @type st.zwave.CommandClass.Basic +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) + +local defaults = {} + +--- WINDOW SHADE PRESET CONSTANTS +defaults.PRESET_LEVEL = 50 +defaults.PRESET_LEVEL_KEY = "_presetLevel" + +defaults.set_preset_position_cmd = function(driver, device, command) + device:emit_component_event({id = command.component}, capabilities.windowShadePreset.position(command.args.position)) + device:set_field(defaults.PRESET_LEVEL_KEY, command.args.position, {persist = true}) +end + +defaults.window_shade_preset_cmd = function(driver, device, command) + local set + local get + local preset_level = device:get_latest_state(command.component, "windowShadePreset", "position") or + device:get_field(constants.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + defaults.PRESET_LEVEL + if device:is_cc_supported(cc.SWITCH_MULTILEVEL) then + set = SwitchMultilevel:Set({ + value = preset_level, + duration = constants.DEFAULT_DIMMING_DURATION + }) + get = SwitchMultilevel:Get({}) + else + set = Basic:Set({ + value = preset_level + }) + get = Basic:Get({}) + end + device:send_to_component(set, command.component) + local query_device = function() + device:send_to_component(get, command.component) + end + device.thread:call_with_delay(constants.MIN_DIMMING_GET_STATUS_DELAY, query_device) +end + +return defaults diff --git a/drivers/Unofficial/tuya-zigbee/config.yml b/drivers/Unofficial/tuya-zigbee/config.yml new file mode 100644 index 0000000000..8b55254162 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/config.yml @@ -0,0 +1,6 @@ +name: 'Tuya Zigbee Device' +packageKey: 'tuya-zigbee-device' +permissions: + zigbee: {} +description: "Driver for uncertificed Tuya Zigbee devices" +isWWST: false \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/fingerprints.yml b/drivers/Unofficial/tuya-zigbee/fingerprints.yml new file mode 100644 index 0000000000..5c77617d1d --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/fingerprints.yml @@ -0,0 +1,51 @@ +zigbeeManufacturer: + - id: "_TZ3000_ja5osu5g/TS004F" + deviceLabel: Tuya Button + manufacturer: _TZ3000_ja5osu5g + model: TS004F + deviceProfileName: one-button-battery + - id: "_TZ3000_8rppvwda/TS0041" + deviceLabel: Tuya Button + manufacturer: _TZ3000_8rppvwda + model: TS0041 + deviceProfileName: one-button-battery + - id: "_TZ3000_pkfazisv/TS0215A" + deviceLabel: Tuya Button + manufacturer: _TZ3000_pkfazisv + model: TS0215A + deviceProfileName: one-button-battery + - id: "_TZE284_nladmfvf/TS0601" + deviceLabel: Tuya Curtain + manufacturer: _TZE284_nladmfvf + model: TS0601 + deviceProfileName: window-treatment-reverse + - id: "_TZE200_mgxy2d9f/TS0601" + deviceLabel: Tuya Motion Sensor + manufacturer: _TZE200_mgxy2d9f + model: TS0601 + deviceProfileName: motion-sensor + - id: "_TZE200_ioxkjvuz/TS0601" + deviceLabel: Tuya Gas Sensor + manufacturer: _TZE200_ioxkjvuz + model: TS0601 + deviceProfileName: smoke-detector + - id: "_TZE204_mby4kbtq/TS0601" + deviceLabel: Tuya Gas Sensor + manufacturer: _TZE204_mby4kbtq + model: TS0601 + deviceProfileName: smoke-detector + - id: "_TZE200_uebojraa/TS0601" + deviceLabel: Tuya Smoke Detector + manufacturer: _TZE200_uebojraa + model: TS0601 + deviceProfileName: smoke-battery + - id: "_TZE204_h2rctifa/TS0601" + deviceLabel: Tuya Switch 1 + manufacturer: _TZE204_h2rctifa + model: TS0601 + deviceProfileName: basic-switch + - id: _TZE284_fziifcxj/TS0601 + deviceLabel: Tuya Thermostat + manufacturer: _TZE284_fziifcxj + model: TS0601 + deviceProfileName: thermostat diff --git a/drivers/Unofficial/tuya-zigbee/profiles/basic-switch.yml b/drivers/Unofficial/tuya-zigbee/profiles/basic-switch.yml new file mode 100644 index 0000000000..dfc8581e85 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/basic-switch.yml @@ -0,0 +1,12 @@ +name: basic-switch +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/profiles/motion-sensor.yml b/drivers/Unofficial/tuya-zigbee/profiles/motion-sensor.yml new file mode 100644 index 0000000000..437f6c06e3 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/motion-sensor.yml @@ -0,0 +1,14 @@ +name: motion-sensor +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/Unofficial/tuya-zigbee/profiles/one-button-battery.yml b/drivers/Unofficial/tuya-zigbee/profiles/one-button-battery.yml new file mode 100644 index 0000000000..63ad3f2d47 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/one-button-battery.yml @@ -0,0 +1,14 @@ +name: one-button-battery +components: +- id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/Unofficial/tuya-zigbee/profiles/smoke-battery.yml b/drivers/Unofficial/tuya-zigbee/profiles/smoke-battery.yml new file mode 100644 index 0000000000..02ae6f61f2 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/smoke-battery.yml @@ -0,0 +1,14 @@ +name: smoke-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector diff --git a/drivers/Unofficial/tuya-zigbee/profiles/smoke-detector.yml b/drivers/Unofficial/tuya-zigbee/profiles/smoke-detector.yml new file mode 100644 index 0000000000..e0c04ab2f5 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/smoke-detector.yml @@ -0,0 +1,12 @@ +name: smoke-detector +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector diff --git a/drivers/Unofficial/tuya-zigbee/profiles/thermostat.yml b/drivers/Unofficial/tuya-zigbee/profiles/thermostat.yml new file mode 100644 index 0000000000..66ecb5661b --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/thermostat.yml @@ -0,0 +1,24 @@ +name: thermostat +components: + - id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + step: 0.5 + - id: thermostatMode + version: 1 + config: + values: + - key: "thermostatMode.value" + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml b/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml new file mode 100644 index 0000000000..d0f47cdafd --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml @@ -0,0 +1,24 @@ +name: window-treatment-reverse +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeLevel + version: 1 + - id: statelessWindowShadeLevelStep + version: 1 + - id: windowShadePreset + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind + +preferences: + - preferenceId: reverse + explicit: true + - preferenceId: presetPosition + explicit: true \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/button/init.lua b/drivers/Unofficial/tuya-zigbee/src/button/init.lua new file mode 100644 index 0000000000..61599e3d5a --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/button/init.lua @@ -0,0 +1,86 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local OnOff = clusters.OnOff +local device_management = require "st.zigbee.device_management" +local PRESENT_ATTRIBUTE_ID = 0x00fd +local tuya_utils = require "tuya_utils" + +local FINGERPRINTS = { + { mfr = "_TZ3000_ja5osu5g", model = "TS004F"}, + { mfr = "_TZ3000_8rppvwda", model = "TS0041"}, + { mfr = "_TZ3000_pkfazisv", model = "TS0215A"} +} + +local function is_tuya_button(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function added_handler(self, device) + device:emit_event(capabilities.button.supportedButtonValues({"pushed","held","double"}, {visibility = { displayed = false }})) + device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = { displayed = false }})) + tuya_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) +end + +local tuya_private_cluster_button_handler = function(driver, device, zb_rx) + local event + local additional_fields = { + state_change = true + } + local value = string.byte(zb_rx.body.zcl_body.body_bytes, 1) + + if value == 0x00 then + event = capabilities.button.button.pushed(additional_fields) + elseif value == 0x01 then + event = capabilities.button.button.double(additional_fields) + elseif value == 0x02 then + event = capabilities.button.button.held(additional_fields) + end + if event ~= nil then + device:emit_event(event) + end +end + +local function do_configure(driver, device) + device:configure() + device:send(device_management.build_bind_request(device, OnOff.ID, driver.environment_info.hub_zigbee_eui)) +end + +local tuya_button_driver = { + NAME = "tuya button", + lifecycle_handlers = { + added = added_handler, + doConfigure = do_configure + }, + can_handle = is_tuya_button, + zigbee_handlers = { + cluster = { + [OnOff.ID] = { + [PRESENT_ATTRIBUTE_ID] = tuya_private_cluster_button_handler + } + } + }, + sub_drivers = { + require("button.meian-button") + } +} + +return tuya_button_driver diff --git a/drivers/Unofficial/tuya-zigbee/src/button/meian-button/init.lua b/drivers/Unofficial/tuya-zigbee/src/button/meian-button/init.lua new file mode 100644 index 0000000000..887826a9f0 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/button/meian-button/init.lua @@ -0,0 +1,98 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local read_attribute = require "st.zigbee.zcl.global_commands.read_attribute" +local constants = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local data_types = require "st.zigbee.data_types" +local messages = require "st.zigbee.messages" +local defaults = require "st.zigbee.defaults" +local PowerConfiguration = clusters.PowerConfiguration +local tuya_utils = require "tuya_utils" + +local IASACE = clusters.IASACE + +local FINGERPRINTS = { + { mfr = "_TZ3000_pkfazisv", model = "TS0215A"} +} + +local function is_meian_sos_button(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function read_attribute_function(device, cluster_id, attr_id) + local read_body = read_attribute.ReadAttribute( attr_id ) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(read_attribute.ReadAttribute.ID) + }) + local addrh = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(cluster_id), + constants.HA_PROFILE_ID, + cluster_id + ) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = read_body + }) + return messages.ZigbeeMessageTx({ + address_header = addrh, + body = message_body + }) +end + +local function added_handler(driver, device, event, args) + device:emit_event(capabilities.button.supportedButtonValues({"pushed"}, {visibility = { displayed = false }})) + device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = { displayed = false }})) + tuya_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) + + local magic_spell = {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe} + device:send(read_attribute_function(device, clusters.Basic.ID, magic_spell)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) +end + +local ias_ace_emergency_handler = function(driver, device) + device:emit_event(capabilities.button.button.pushed({ state_change = true })) +end + +local meian_sos_button_handler = { + NAME = "Meian Sos Button", + supported_capabilities = { + capabilities.battery, + capabilities.button, + capabilities.refresh + }, + lifecycle_handlers = { + added = added_handler + }, + zigbee_handlers = { + cluster = { + [IASACE.ID] = { + [IASACE.server.commands.Emergency.ID] = ias_ace_emergency_handler + } + } + }, + can_handle = is_meian_sos_button +} +defaults.register_for_default_handlers(meian_sos_button_handler, meian_sos_button_handler.supported_capabilities) +return meian_sos_button_handler \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua b/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua new file mode 100644 index 0000000000..284486e88b --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua @@ -0,0 +1,229 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local utils = require "st.utils" +local device_management = require "st.zigbee.device_management" +local tuya_utils = require "tuya_utils" +local Basic = clusters.Basic +local packet_id = 0 + +local PRESET_LEVEL = 50 +local PRESET_LEVEL_KEY = "_presetLevel" + +-- When the curtain is moving, LATEST_TARGET_LEVEL is used to store the latest target position value, +-- which will be cleared after 3 seconds timeout +local LATEST_TARGET_LEVEL = "_latestTargetLevel" +-- Field name for the timer that clears the target position after timeout +local TARGET_LEVEL_TIME_OUT = "_targetLevelTimeOut" +-- Timeout for the stateless step to accumulate +local TARGET_LEVEL_TIME_OUT_SECONDS = 15 + +local FINGERPRINTS = { + { mfr = "_TZE284_nladmfvf", model = "TS0601"} +} + +local function is_tuya_curtain(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function init_handler(self, device) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then + + -- These should only ever be nil once (and at the same time) for already-installed devices + -- It can be removed after migration is complete + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + + local preset_position = device:get_field(PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + PRESET_LEVEL + + device:emit_event(capabilities.windowShadePreset.position(preset_position, { visibility = {displayed = false}})) + device:set_field(PRESET_LEVEL_KEY, preset_position, {persist = true}) + end +end + +local do_configure = function(driver, device) + -- configure ApplicationVersion to keep device online, tuya hub also uses this attribute + tuya_utils.send_magic_spell(device) + device:send(Basic.attributes.ApplicationVersion:configure_reporting(device, 30, 300, 1)) + device:send(device_management.build_bind_request(device, Basic.ID, driver.environment_info.hub_zigbee_eui)) +end + +local function device_added(driver, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + tuya_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadeLevel, capabilities.windowShadeLevel.shadeLevel.NAME, capabilities.windowShadeLevel.shadeLevel(0)) + tuya_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShade, capabilities.windowShade.windowShade.NAME, capabilities.windowShade.windowShade.closed()) + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + tuya_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadePreset, capabilities.windowShadePreset.position.NAME, PRESET_LEVEL) +end + +local function increase_packet_id(packet_id) + packet_id = (packet_id + 1) % 65536 + return packet_id +end + +local function device_info_changed(driver, device, event, args) + if device.preferences ~= nil then + local reverseCurtainDirectionPrefValue = device.preferences.reverse + -- reverse direction + if reverseCurtainDirectionPrefValue == true then + tuya_utils.send_tuya_command(device, '\x05', tuya_utils.DP_TYPE_ENUM, '\x01', packet_id) + packet_id = increase_packet_id(packet_id) + else + tuya_utils.send_tuya_command(device, '\x05', tuya_utils.DP_TYPE_ENUM, '\x00', packet_id) + packet_id = increase_packet_id(packet_id) + end + end +end + +local function window_shade_open(driver, device) + tuya_utils.send_tuya_command(device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x00', packet_id) + packet_id = increase_packet_id(packet_id) + device:emit_event(capabilities.windowShade.windowShade.opening()) +end + +local function window_shade_close(driver, device) + tuya_utils.send_tuya_command(device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x02', packet_id) + packet_id = increase_packet_id(packet_id) + device:emit_event(capabilities.windowShade.windowShade.closing()) +end + +local function window_shade_pause(driver, device) + tuya_utils.send_tuya_command(device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x01', packet_id) + packet_id = increase_packet_id(packet_id) +end + +local function window_shade_level(driver, device, command) + local level = command.args.shadeLevel + if level > 100 then + level = 100 + end + level = utils.round(level) + if device:get_manufacturer() == "_TZE284_nladmfvf" then + level = 100 - level -- specific for _TZE284_nladmfvf + end + tuya_utils.send_tuya_command(device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00'..string.pack(">I2", level), packet_id) + packet_id = increase_packet_id(packet_id) +end + +local function window_shade_preset(driver, device) + local level = device:get_latest_state("main", "windowShadePreset", "position") or + device:get_field(PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + PRESET_LEVEL + tuya_utils.send_tuya_command(device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00'..string.pack(">I2", level), packet_id) + packet_id = increase_packet_id(packet_id) +end + +local function set_preset_position_cmd(driver, device, command) + device:emit_component_event({id = command.component}, capabilities.windowShadePreset.position(command.args.position)) + device:set_field(PRESET_LEVEL_KEY, command.args.position, {persist = true}) +end + +local function tuya_cluster_handler(driver, device, zb_rx) + local window_shade_level_event, window_shade_val_event + local raw = zb_rx.body.zcl_body.body_bytes + -- dp means data point in tuya payload format + local dp = raw:byte(3) + local dp_data = raw:byte(10) + if dp == 0x03 then + window_shade_level_event = capabilities.windowShadeLevel.shadeLevel(dp_data) + if dp_data == 0 then + window_shade_val_event = capabilities.windowShade.windowShade("open") + elseif dp_data == 100 then + window_shade_val_event = capabilities.windowShade.windowShade("closed") + elseif dp_data > 0 and dp_data < 100 then + window_shade_val_event = capabilities.windowShade.windowShade("partially open") + end + end + if window_shade_level_event ~= nil and window_shade_val_event ~= nil then + device:emit_event(window_shade_level_event) + device:emit_event(window_shade_val_event) + end +end + +local function handle_step_shade_level(driver, device, command) + local step = command.args.stepSize or command.args[1] or (command.positional_args and command.positional_args.stepSize) + + -- When LATEST_TARGET_LEVEL is empty, it means the curtain motor is not moving, + -- and capabilities.windowShadeLevel.shadeLevel is used as the reference for position offset calculation; + -- when LATEST_TARGET_LEVEL is not empty, it means the curtain motor is moving, + -- and LATEST_TARGET_LEVEL is used as the reference for position offset calculation to obtain a more accurate position calculation. + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) + local current_level = latest_target_level or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + local target_level = utils.clamp_value(current_level + step, 0, 100) + target_level = utils.round(target_level) + -- Cancel any previous timer and set a 15 second timeout timer to ensure target_level is cleared + -- This is to prevent the stateless step from accumulating indefinitely, while ensuring that a single + -- "scroll" action can accumulate multiple steps within a short time frame + if device:get_field(TARGET_LEVEL_TIME_OUT) then + device.thread:cancel_timer(device:get_field(TARGET_LEVEL_TIME_OUT)) + end + local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + device:set_field(LATEST_TARGET_LEVEL, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + device:set_field(LATEST_TARGET_LEVEL, target_level) + + local new_command = { args = { shadeLevel = target_level }, component = command.component } + window_shade_level(driver, device, new_command) +end + +local tuya_curtain_driver = { + NAME = "tuya curtain", + lifecycle_handlers = { + init = init_handler, + added = device_added, + infoChanged = device_info_changed, + doConfigure = do_configure + }, + capability_handlers = { + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.open.NAME] = window_shade_open, + [capabilities.windowShade.commands.close.NAME] = window_shade_close, + [capabilities.windowShade.commands.pause.NAME] = window_shade_pause + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level + }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = handle_step_shade_level + }, + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset, + [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = set_preset_position_cmd + } + }, + zigbee_handlers = { + cluster = { + [tuya_utils.TUYA_PRIVATE_CLUSTER] = { + [tuya_utils.TUYA_PRIVATE_CMD_RESPONSE] = tuya_cluster_handler + } + } + }, + can_handle = is_tuya_curtain +} + +return tuya_curtain_driver diff --git a/drivers/Unofficial/tuya-zigbee/src/init.lua b/drivers/Unofficial/tuya-zigbee/src/init.lua new file mode 100644 index 0000000000..6313132933 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/init.lua @@ -0,0 +1,37 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local defaults = require "st.zigbee.defaults" +local ZigbeeDriver = require "st.zigbee" +local capabilities = require "st.capabilities" + +local unofficial_tuya_driver_template = { + supported_capabilities = { + capabilities.refresh, + capabilities.battery + }, + sub_drivers = { + require("button"), + require("curtain"), + require("motion-sensor"), + require("smoke-detector"), + require("switch"), + require("thermostat") + }, + health_check = false, +} + +defaults.register_for_default_handlers(unofficial_tuya_driver_template, unofficial_tuya_driver_template.supported_capabilities) +local unofficial_tuya = ZigbeeDriver("unofficial_tuya", unofficial_tuya_driver_template) +unofficial_tuya:run() \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/motion-sensor/init.lua b/drivers/Unofficial/tuya-zigbee/src/motion-sensor/init.lua new file mode 100644 index 0000000000..cef73eaac0 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/motion-sensor/init.lua @@ -0,0 +1,86 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + + +local capabilities = require "st.capabilities" +local defaults = require "st.zigbee.defaults" +local device_management = require "st.zigbee.device_management" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" +local Basic = clusters.Basic + +local FINGERPRINTS = { + { mfr = "_TZE200_mgxy2d9f", model = "TS0601"} +} + +local function is_tuya_motion(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function device_added(self, device) + device:emit_event(capabilities.motionSensor.motion.inactive()) + device:emit_event(capabilities.battery.battery(100)) +end + +local function do_configure(driver, device) + -- configure ApplicationVersion to keep device online, tuya hub also uses this attribute + tuya_utils.send_magic_spell(device) + device:send(Basic.attributes.ApplicationVersion:configure_reporting(device, 30, 300, 1)) + device:send(device_management.build_bind_request(device, Basic.ID, driver.environment_info.hub_zigbee_eui)) +end + +local function tuya_cluster_handler(driver, device, zb_rx) + local event + local raw = zb_rx.body.zcl_body.body_bytes + local dp = raw:byte(3) + local dp_data = raw:byte(7) + if dp == 0x01 and dp_data == 0x00 then + event = capabilities.motionSensor.motion.active() + elseif dp == 0x01 and dp_data == 0x01 then + event = capabilities.motionSensor.motion.inactive() + elseif dp == 0x04 then + local battery_percent = raw:byte(10) + device:emit_event(capabilities.battery.battery(battery_percent)) + end + if event ~= nil then + device:emit_event(event) + end +end + +local tuya_motion_sensor_driver = { + NAME = "tuya motion sensor", + supported_capabilities = { + capabilities.battery + }, + zigbee_handlers = { + cluster = { + [tuya_utils.TUYA_PRIVATE_CLUSTER] = { + [tuya_utils.TUYA_PRIVATE_CMD_REPORT] = tuya_cluster_handler, + } + } + }, + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + can_handle = is_tuya_motion +} + +defaults.register_for_default_handlers(tuya_motion_sensor_driver, tuya_motion_sensor_driver.supported_capabilities) +return tuya_motion_sensor_driver \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/smoke-detector/init.lua b/drivers/Unofficial/tuya-zigbee/src/smoke-detector/init.lua new file mode 100644 index 0000000000..31a976f724 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/smoke-detector/init.lua @@ -0,0 +1,102 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local device_management = require "st.zigbee.device_management" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" +local Basic = clusters.Basic + +local FINGERPRINTS = { + { mfr = "_TZE200_ioxkjvuz", model = "TS0601"}, + { mfr = "_TZE204_mby4kbtq", model = "TS0601"}, + { mfr = "_TZE200_uebojraa", model = "TS0601"} +} + +local function is_tuya_smoke(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function device_added(self, device) + device:emit_event(capabilities.smokeDetector.smoke.clear()) + device:emit_event(capabilities.batteryLevel.battery.normal()) +end + +local do_configure = function(driver, device) + -- configure ApplicationVersion to keep device online, tuya hub also uses this attribute + tuya_utils.send_magic_spell(device) + device:send(Basic.attributes.ApplicationVersion:configure_reporting(device, 30, 300, 1)) + device:send(device_management.build_bind_request(device, Basic.ID, driver.environment_info.hub_zigbee_eui)) +end + +local function tuya_cluster_handler(driver, device, zb_rx) + local event + local raw = zb_rx.body.zcl_body.body_bytes + local dp = raw:byte(3) + local dp_data = raw:byte(7) + if dp == 0x01 and dp_data == 0x00 then + event = capabilities.smokeDetector.smoke.detected() + device.thread:call_with_delay(10, function(d) + device:emit_event(capabilities.smokeDetector.smoke.clear()) + end) + elseif dp == 0x01 and dp_data == 0x01 then + event = capabilities.smokeDetector.smoke.clear() + elseif dp == 0x0e and dp_data == 0x02 then + event = capabilities.batteryLevel.battery.normal() + elseif dp == 0x0e and dp_data == 0x01 then + event = capabilities.batteryLevel.battery.warning() + elseif dp == 0x0e and dp_data == 0x00 then + event = capabilities.batteryLevel.battery.critical() + end + if event ~= nil then + device:emit_event(event) + end +end + +local function tuya_meian_cluster_handler(driver, device, zb_rx) + local rx = zb_rx.body.zcl_body.body_bytes + local dp_data = rx:byte(7) + if dp_data == 0x01 then + device:emit_event(capabilities.smokeDetector.smoke.detected()) + device.thread:call_with_delay(10, function(d) + device:emit_event(capabilities.smokeDetector.smoke.clear()) + end) + elseif dp_data == 0x00 then + device:emit_event(capabilities.smokeDetector.smoke.clear()) + end +end + +local tuya_smoke_detector_driver = { + NAME = "tuya smoke detector", + zigbee_handlers = { + cluster = { + [tuya_utils.TUYA_PRIVATE_CLUSTER] = { + [tuya_utils.TUYA_PRIVATE_CMD_RESPONSE] = tuya_meian_cluster_handler, + [tuya_utils.TUYA_PRIVATE_CMD_REPORT] = tuya_cluster_handler + } + } + }, + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + can_handle = is_tuya_smoke +} + +return tuya_smoke_detector_driver \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/switch/init.lua b/drivers/Unofficial/tuya-zigbee/src/switch/init.lua new file mode 100644 index 0000000000..16483668cd --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/switch/init.lua @@ -0,0 +1,114 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local device_lib = require "st.device" +local tuya_utils = require "tuya_utils" +local ep_array = {1,2,3,4,5,6} +local packet_id = 0 + +local FINGERPRINTS = { + { mfr = "_TZE204_h2rctifa", model = "TS0601"} +} + +local function is_tuya_switch(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function find_child(parent, endpoint) + return parent:get_child_by_parent_assigned_key(string.format("%02X", endpoint)) +end + +local function create_child_devices(driver, device) + for ep in ipairs(ep_array) do + if ep ~= device.fingerprinted_endpoint_id then + if find_child(device, ep) == nil then + local metadata = { + type = "EDGE_CHILD", + parent_assigned_child_key = string.format("%02X", ep), + label = device.label..' '..ep, + profile = "basic-switch", + parent_device_id = device.id + } + driver:try_create_device(metadata) + end + end + end +end + +local function tuya_cluster_handler(driver, device, zb_rx) + local raw = zb_rx.body.zcl_body.body_bytes + local dp = string.byte(raw:sub(3,3)) + local dp_data_len = string.unpack(">I2", raw:sub(5,6)) + local dp_data = string.unpack(">I"..dp_data_len, raw:sub(7)) + if dp == device.fingerprinted_endpoint_id or find_child(device, dp) ~= nil then + device:emit_event_for_endpoint(dp, capabilities.switch.switch(dp_data == 0 and "off" or "on")) + end +end + +local function switch_on_handler(driver, device) + local dp = (device.network_type == device_lib.NETWORK_TYPE_CHILD) and string.char(device:get_endpoint()) or "\x01" + tuya_utils.send_tuya_command(device, dp, tuya_utils.DP_TYPE_BOOL, "\x01", packet_id) + packet_id = (packet_id + 1) % 65536 +end + +local function switch_off_handler(driver, device) + local dp = (device.network_type == device_lib.NETWORK_TYPE_CHILD) and string.char(device:get_endpoint()) or "\x01" + tuya_utils.send_tuya_command(device, dp, tuya_utils.DP_TYPE_BOOL, "\x00", packet_id) + packet_id = (packet_id + 1) % 65536 +end + +local function device_added(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + create_child_devices(driver, device) + end +end + +local function device_init(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_CHILD then return end + device:set_find_child(find_child) +end + +local tuya_multi_switch_driver = { + NAME = "tuya multi switch", + supported_capabilities = { + capabilities.switch + }, + zigbee_handlers = { + cluster = { + [tuya_utils.TUYA_PRIVATE_CLUSTER] = { + [tuya_utils.TUYA_PRIVATE_CMD_RESPONSE] = tuya_cluster_handler, + [tuya_utils.TUYA_PRIVATE_CMD_REPORT] = tuya_cluster_handler, + } + }, + }, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_on_handler, + [capabilities.switch.commands.off.NAME] = switch_off_handler, + }, + }, + lifecycle_handlers = { + added = device_added, + init = device_init, + }, + can_handle = is_tuya_switch +} + +return tuya_multi_switch_driver \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_meian_button.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_meian_button.lua new file mode 100644 index 0000000000..c3610f55ea --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_meian_button.lua @@ -0,0 +1,189 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local tuya_utils = require "tuya_utils" + +local IASACE = clusters.IASACE +local PowerConfiguration = clusters.PowerConfiguration + +local ZIGBEE_ONE_BUTTON_BATTERY = "one-button-battery" + +local mock_device_meian_button = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition(ZIGBEE_ONE_BUTTON_BATTERY .. ".yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZ3000_pkfazisv", + model = "TS0215A", + server_clusters = { 0x0500, 0x0001 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device_meian_button)end + +test.set_test_init_function(test_init) + + +test.register_message_test( + "Battery percentage report should be handled (button)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_meian_button.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_meian_button, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_meian_button:generate_test_message("main", capabilities.battery.battery(28)) + } + } +) + +test.register_message_test( + "Reported button should be handled: pushed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device_meian_button.id, IASACE.server.commands.Emergency.build_test_rx(mock_device_meian_button) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_meian_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + } + } +) + +-- test.register_coroutine_test( +-- "Health check should check all relevant attributes", +-- function() +-- test.socket.device_lifecycle:__queue_receive({ mock_device_meian_button.id, "added"}) +-- test.socket.capability:__expect_send( +-- mock_device_meian_button:generate_test_message( +-- "main", +-- capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }) +-- ) +-- ) +-- test.socket.capability:__expect_send( +-- mock_device_meian_button:generate_test_message( +-- "main", +-- capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) +-- ) +-- ) +-- test.socket.capability:__expect_send({ +-- mock_device_meian_button.id, +-- { +-- capability_id = "button", component_id = "main", +-- attribute_id = "button", state = { value = "pushed" } +-- } +-- }) +-- test.socket.zigbee:__expect_send({ mock_device_meian_button.id, tuya_utils.build_tuya_magic_spell_message(mock_device_meian_button) }) +-- test.socket.zigbee:__expect_send( +-- { +-- mock_device_meian_button.id, +-- PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_meian_button) +-- } +-- ) +-- end, +-- { +-- test_init = function() +-- test.mock_device.add_test_device(mock_device_meian_button) +-- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") +-- end +-- } +-- ) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_meian_button.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send( + { + mock_device_meian_button.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_meian_button) + } + ) + + end +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + -- The initial button pushed event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device_meian_button.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device_meian_button:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device_meian_button:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send({ + mock_device_meian_button.id, + { + capability_id = "button", component_id = "main", + attribute_id = "button", state = { value = "pushed" } + } + }) + test.socket.zigbee:__expect_send({ mock_device_meian_button.id, tuya_utils.build_tuya_magic_spell_message(mock_device_meian_button) }) + test.socket.zigbee:__expect_send({ + mock_device_meian_button.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_meian_button) + }) + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device_meian_button.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device_meian_button:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device_meian_button:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.zigbee:__expect_send({ mock_device_meian_button.id, tuya_utils.build_tuya_magic_spell_message(mock_device_meian_button) }) + test.socket.zigbee:__expect_send({ + mock_device_meian_button.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_meian_button) + }) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_button.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_button.lua new file mode 100644 index 0000000000..73567c24e0 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_button.lua @@ -0,0 +1,126 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local PRESENT_CMD_ID = 0x00fd +local MFG_CODE = 0x110A +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration +local button = capabilities.button + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("one-button-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZ3000_ja5osu5g", + model = "TS004F", + server_clusters = { 0x0006, 0x0001 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + -- The initial button pushed event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button.button.pushed({ state_change = false })) + ) + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end +) + +test.register_coroutine_test( + "doConfigure lifecycle event", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "OnOff cluster private command should result with sending pushed event", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, OnOff.ID, PRESENT_CMD_ID, MFG_CODE, "\x00") }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button.button.pushed({ state_change = true })) + ) + end +) + +test.register_coroutine_test( + "OnOff cluster private command 0x01 should result with sending double event", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, OnOff.ID, PRESENT_CMD_ID, MFG_CODE, "\x01") }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button.button.double({ state_change = true })) + ) + end +) + +test.register_coroutine_test( + "OnOff cluster private command 0x02 should result with sending held event", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, OnOff.ID, PRESENT_CMD_ID, MFG_CODE, "\x02") }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", button.button.held({ state_change = true })) + ) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua new file mode 100644 index 0000000000..1cf3335d9e --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua @@ -0,0 +1,411 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" + +local Basic = clusters.Basic + +local mock_simple_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-reverse.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE284_nladmfvf", + model = "TS0601", + server_clusters = { 0xef00 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_simple_device) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "doConfigure lifecycle event", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, tuya_utils.build_tuya_magic_spell_message(mock_simple_device) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, zigbee_test_utils.build_bind_request(mock_simple_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, Basic.attributes.ApplicationVersion:configure_reporting(mock_simple_device, 30, 300, 1) }) + mock_simple_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Handle reverseCurtainDirection in infochanged", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + local updates = { + preferences = { + } + } + updates.preferences.reverse = true + test.socket.device_lifecycle:__queue_receive(mock_simple_device:generate_info_changed(updates)) + test.socket.zigbee:__expect_send( + { + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x05', tuya_utils.DP_TYPE_ENUM, '\x01', 0) + } + ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.wait_for_events() + test.mock_time.advance_time(2) + updates.preferences.reverse = false + test.socket.device_lifecycle:__queue_receive(mock_simple_device:generate_info_changed(updates)) + test.socket.zigbee:__expect_send( + { + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x05', tuya_utils.DP_TYPE_ENUM, '\x00', 1) + } + ) + end +) + +test.register_coroutine_test( + "added lifecycle event", + function() + -- The initial window shade event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added" }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.windowShadeLevel.shadeLevel(0) + ) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.windowShade.windowShade.closed() + ) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.wait_for_events() + -- Avoid sending the initial window shade event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added" }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}}) + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + end +) + +test.register_message_test( + "Handle Window shade open command", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "windowShade", component = "main", command = "open", args = {}} } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x00', 0) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + } + } +) + +test.register_message_test( + "Handle Window shade close command", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "windowShade", component = "main", command = "close", args = {}} } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x02', 0) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + } + } +) + +test.register_message_test( + "Handle Window shade pause command", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "windowShade", component = "main", command = "pause", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x01', 0) } + } + } +) + +test.register_message_test( + "Handle Window Shade level command", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 40 }} } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x3c', 0) } + } + } +) + +test.register_message_test( + "Handle Window Shade preset command", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {}} } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x32', 0) } + } + } +) + +test.register_message_test( + "Handle tuya cluster message report", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + } + } +) + +test.register_message_test( + "Handle Window Shade step level command - step up", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 } } + }, + { + channel = "zigbee", + direction = "send", + -- For _TZE284_nladmfvf, level is inverted: 100 - 10 = 90 (0x5A) + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x5a', 0) } + } + } +) + +test.register_message_test( + "Handle Window Shade step level command - step down", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -10 }, stepSize = -10 } } + }, + { + channel = "zigbee", + direction = "send", + -- For _TZE284_nladmfvf, level is inverted: 100 - 0 = 100 (0x64), but clamped to 0 so 100 - 0 = 100 + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x64', 0) } + } + } +) + +test.register_coroutine_test( + "Handle Window Shade step level command - step up from current level", + function() + -- First, simulate a current shade level of 40 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Then send step command with stepSize 20, should result in level 60 + -- For _TZE284_nladmfvf, level is inverted: 100 - 60 = 40 (0x28) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 }, stepSize = 20 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0) + }) + end +) + +test.register_coroutine_test( + "Handle Window Shade step level command - step up clamped to 100", + function() + -- First, simulate a current shade level of 90 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x5a', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(90)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Then send step command with stepSize 20, should be clamped to 100 + -- For _TZE284_nladmfvf, level is inverted: 100 - 100 = 0 (0x00) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 }, stepSize = 20 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x00', 0) + }) + end +) + +test.register_coroutine_test( + "Handle Window Shade step level command - step down clamped to 0", + function() + -- First, simulate a current shade level of 10 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x0a', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(10)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Then send step command with stepSize -20, should be clamped to 0 + -- For _TZE284_nladmfvf, level is inverted: 100 - 0 = 100 (0x64) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 }, stepSize = -20 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x64', 0) + }) + end +) + +test.register_coroutine_test( + "Handle Window Shade step level command - uses latest_target_level when available", + function() + -- First, simulate a current shade level of 40 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Send first step command to set latest_target_level to 50 + -- For _TZE284_nladmfvf, level is inverted: 100 - 50 = 50 (0x32) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x32', 0) + }) + test.wait_for_events() + + -- Send second step command before timeout - should use latest_target_level (50) as base + -- Target = 50 + 10 = 60, For _TZE284_nladmfvf: 100 - 60 = 40 (0x28) + -- Packet ID is 1 because it was incremented after the first step command + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 1) + }) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_motion_sensor.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_motion_sensor.lua new file mode 100644 index 0000000000..c00963c50e --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_motion_sensor.lua @@ -0,0 +1,125 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" + +local Basic = clusters.Basic + +local mock_simple_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("motion-sensor.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE200_mgxy2d9f", + model = "TS0601", + server_clusters = { 0xef00 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_simple_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "doConfigure lifecycle event", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, tuya_utils.build_tuya_magic_spell_message(mock_simple_device) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, zigbee_test_utils.build_bind_request(mock_simple_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, Basic.attributes.ApplicationVersion:configure_reporting(mock_simple_device, 30, 300, 1) }) + mock_simple_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added" }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.motionSensor.motion.inactive() + ) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.battery.battery(100) + ) + ) + end +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x04', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x64', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.battery.battery(100)) + } + } +) + +test.register_message_test( + "Reported motion should be handled: active", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x00', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + } +) + +test.register_message_test( + "Reported motion should be handled: inactive", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + } +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_smoke_detector.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_smoke_detector.lua new file mode 100644 index 0000000000..cc1ab8fb30 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_smoke_detector.lua @@ -0,0 +1,190 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" + +local Basic = clusters.Basic + +local mock_simple_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("smoke-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE200_uebojraa", + model = "TS0601", + server_clusters = { 0xef00 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_simple_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "doConfigure lifecycle event", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, tuya_utils.build_tuya_magic_spell_message(mock_simple_device) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, zigbee_test_utils.build_bind_request(mock_simple_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, Basic.attributes.ApplicationVersion:configure_reporting(mock_simple_device, 30, 300, 1) }) + mock_simple_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added" }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.smokeDetector.smoke.clear() + ) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.batteryLevel.battery.normal() + ) + ) + end +) + +test.register_message_test( + "Battery level report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x0e', tuya_utils.DP_TYPE_ENUM, '\x02', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.batteryLevel.battery.normal()) + } + } +) + +test.register_message_test( + "Battery level report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x0e', tuya_utils.DP_TYPE_ENUM, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.batteryLevel.battery.warning()) + } + } +) + +test.register_message_test( + "Battery level report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x0e', tuya_utils.DP_TYPE_ENUM, '\x00', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.batteryLevel.battery.critical()) + } + } +) + +test.register_message_test( + "Reported smoke should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x00', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + } + } +) + + +test.register_message_test( + "Reported smoke should be handled: detected(meian)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x01', tuya_utils.TUYA_PRIVATE_CMD_RESPONSE) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + } + } +) + +test.register_message_test( + "Reported smoke should be handled: clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + } + } +) + +test.register_message_test( + "Reported smoke should be handled: clear(meian)", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_simple_device.id, tuya_utils.build_test_attr_report(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x00', tuya_utils.TUYA_PRIVATE_CMD_RESPONSE) } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + } + } +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua new file mode 100644 index 0000000000..97537c36ad --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua @@ -0,0 +1,463 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local tuya_utils = require "tuya_utils" + +local mock_base_device = test.mock_device.build_test_zigbee_device( + { + label = "Switch", + profile = t_utils.get_profile_definition("basic-switch.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE204_h2rctifa", + model = "TS0601", + server_clusters = { 0xef00 } + } + }, + fingerprinted_endpoint_id = 0x01 + } +) + +local mock_parent_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("basic-switch.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE204_h2rctifa", + model = "TS0601", + server_clusters = { 0xef00 } + } + }, + fingerprinted_endpoint_id = 0x01 + } +) + +local first_mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("basic-switch.yml"), + device_network_id = string.format("%04X:%02X", mock_parent_device:get_short_address(), 2), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + +local second_mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("basic-switch.yml"), + device_network_id = string.format("%04X:%02X", mock_parent_device:get_short_address(), 3), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 3) +}) + +local third_mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("basic-switch.yml"), + device_network_id = string.format("%04X:%02X", mock_parent_device:get_short_address(), 4), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 4) +}) + +local fourth_mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("basic-switch.yml"), + device_network_id = string.format("%04X:%02X", mock_parent_device:get_short_address(), 5), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 5) +}) + +local fifth_mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("basic-switch.yml"), + device_network_id = string.format("%04X:%02X", mock_parent_device:get_short_address(), 6), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = string.format("%02X", 6) +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_base_device) + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(first_mock_child_device) + test.mock_device.add_test_device(second_mock_child_device) + test.mock_device.add_test_device(third_mock_child_device) + test.mock_device.add_test_device(fourth_mock_child_device) + test.mock_device.add_test_device(fifth_mock_child_device)end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Reported on off status should be handled: on dp 1", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_parent_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, tuya_utils.build_test_attr_report(mock_parent_device, '\x01', tuya_utils.DP_TYPE_BOOL, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) + } + } +) + +test.register_message_test( + "Reported on off status should be handled: on dp 2", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { first_mock_child_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, tuya_utils.build_test_attr_report(mock_parent_device, '\x02', tuya_utils.DP_TYPE_BOOL, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = first_mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) + } + } +) + +test.register_message_test( + "Reported on off status should be handled: on dp 3", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { second_mock_child_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, tuya_utils.build_test_attr_report(mock_parent_device, '\x03', tuya_utils.DP_TYPE_BOOL, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = second_mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) + } + } +) + +test.register_message_test( + "Reported on off status should be handled: on dp 4", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { third_mock_child_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, tuya_utils.build_test_attr_report(mock_parent_device, '\x04', tuya_utils.DP_TYPE_BOOL, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = third_mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) + } + } +) + +test.register_message_test( + "Reported on off status should be handled: on dp 5", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { fourth_mock_child_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, tuya_utils.build_test_attr_report(mock_parent_device, '\x05', tuya_utils.DP_TYPE_BOOL, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = fourth_mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) + } + } +) + +test.register_message_test( + "Reported on off status should be handled: on dp 6", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { fifth_mock_child_device.id, "init" } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_parent_device.id, tuya_utils.build_test_attr_report(mock_parent_device, '\x06', tuya_utils.DP_TYPE_BOOL, '\x01', tuya_utils.TUYA_PRIVATE_CMD_REPORT) } + }, + { + channel = "capability", + direction = "send", + message = fifth_mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) + } + } +) + +test.register_message_test( + "Capability on command switch on should be handled : dp 1", + { + { + channel = "capability", + direction = "receive", + message = { mock_parent_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x01', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } + } + } +) + +test.register_message_test( + "Capability on command switch on should be handled : dp 2", + { + { + channel = "capability", + direction = "receive", + message = { first_mock_child_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x02', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } + } + } +) + +test.register_message_test( + "Capability on command switch on should be handled : dp 3", + { + { + channel = "capability", + direction = "receive", + message = { second_mock_child_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x03', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } + } + } +) + +test.register_message_test( + "Capability on command switch on should be handled : dp 4", + { + { + channel = "capability", + direction = "receive", + message = { third_mock_child_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x04', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } + } + } +) + +test.register_message_test( + "Capability on command switch on should be handled : dp 5", + { + { + channel = "capability", + direction = "receive", + message = { fourth_mock_child_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x05', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } + } + } +) + +test.register_message_test( + "Capability on command switch on should be handled : dp 6", + { + { + channel = "capability", + direction = "receive", + message = { fifth_mock_child_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x06', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } + } + } +) + +test.register_message_test( + "Capability off command switch off should be handled : dp 1", + { + { + channel = "capability", + direction = "receive", + message = { mock_parent_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x01', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } + } + } +) + +test.register_message_test( + "Capability off command switch off should be handled : dp 2", + { + { + channel = "capability", + direction = "receive", + message = { first_mock_child_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x02', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } + } + } +) + +test.register_message_test( + "Capability off command switch off should be handled : dp 3", + { + { + channel = "capability", + direction = "receive", + message = { second_mock_child_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x03', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } + } + } +) + +test.register_message_test( + "Capability off command switch off should be handled : dp 4", + { + { + channel = "capability", + direction = "receive", + message = { third_mock_child_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x04', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } + } + } +) + +test.register_message_test( + "Capability off command switch off should be handled : dp 5", + { + { + channel = "capability", + direction = "receive", + message = { fourth_mock_child_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x05', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } + } + } +) + +test.register_message_test( + "Capability off command switch off should be handled : dp 6", + { + { + channel = "capability", + direction = "receive", + message = { fifth_mock_child_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x06', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } + } + } +) + +test.register_coroutine_test( + "added lifecycle event should create children in parent device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_base_device.id, "added" }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Switch 2", + profile = "basic-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "02", + }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Switch 3", + profile = "basic-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "03" + }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Switch 4", + profile = "basic-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "04" + }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Switch 5", + profile = "basic-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "05" + }) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Switch 6", + profile = "basic-switch", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "06" + }) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_thermostat.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_thermostat.lua new file mode 100644 index 0000000000..4df634bf18 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_thermostat.lua @@ -0,0 +1,545 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" + +local Basic = clusters.Basic + +local mock_simple_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("thermostat.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE284_fziifcxj", + model = "TS0601", + server_clusters = { 0xEF00 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_simple_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle doConfigure lifecycle event", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, tuya_utils.build_tuya_magic_spell_message(mock_simple_device) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, Basic.attributes.ApplicationVersion:configure_reporting(mock_simple_device, 30, 300, 1) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, zigbee_test_utils.build_bind_request(mock_simple_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) + mock_simple_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + {} +) + +test.register_coroutine_test( + "Handle added lifecycle event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.supportedThermostatModes( + { + capabilities.thermostatMode.thermostatMode.antifreezing.NAME, + capabilities.thermostatMode.thermostatMode.auto.NAME, + capabilities.thermostatMode.thermostatMode.comfort.NAME, + capabilities.thermostatMode.thermostatMode.eco.NAME, + capabilities.thermostatMode.thermostatMode.off.NAME, + capabilities.thermostatMode.thermostatMode.on.NAME, + }, + { + visibility = { displayed = false } + } + ) + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 15.0, unit = "C"}) + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.temperatureMeasurement.temperature({value = 20.0, unit = "C"}) + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.auto() + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.battery.battery(100) + ) + ) + end, + {} +) + +test.register_message_test( + "Handle thermostatHeatingSetpoint setHeatingSetpoint", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {12.5} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x04", + tuya_utils.DP_TYPE_VALUE, + "\x00\x00\x00\x7D", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (auto)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"auto"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x00", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (off)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"off"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x01", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (on)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"on"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x02", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (comfort)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"comfort"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x03", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (eco)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"eco"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x04", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (antifreezing)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"antifreezing"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x05", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (heatingSetpoint)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x04", + tuya_utils.DP_TYPE_VALUE, + "\x00\x00\x00\x7D", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 12.5, unit = "C"}) + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, auto)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x00", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.auto() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, off)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x01", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.off() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, on)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x02", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.on() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, comfort)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x03", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.comfort() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, eco)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x04", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.eco() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, antifreezing)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x05", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.antifreezing() + ) + } + }, + {} +) + +test.run_registered_tests() diff --git a/drivers/Unofficial/tuya-zigbee/src/thermostat/init.lua b/drivers/Unofficial/tuya-zigbee/src/thermostat/init.lua new file mode 100644 index 0000000000..2179fb28a4 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/thermostat/init.lua @@ -0,0 +1,187 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local defaults = require "st.zigbee.defaults" +local device_management = require "st.zigbee.device_management" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" +local Basic = clusters.Basic +local packet_id = 0 + +local FINGERPRINTS = { + { mfr = "_TZE284_fziifcxj", model = "TS0601"} +} + +local function is_tuya_thermostat(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function device_added(self, device) + device:emit_event(capabilities.thermostatMode.supportedThermostatModes({ + capabilities.thermostatMode.thermostatMode.antifreezing.NAME, + capabilities.thermostatMode.thermostatMode.auto.NAME, + capabilities.thermostatMode.thermostatMode.comfort.NAME, + capabilities.thermostatMode.thermostatMode.eco.NAME, + capabilities.thermostatMode.thermostatMode.off.NAME, + capabilities.thermostatMode.thermostatMode.on.NAME, + }, { visibility = { displayed = false } })) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 15.0, unit = "C"})) + device:emit_event(capabilities.temperatureMeasurement.temperature({value = 20.0, unit = "C"})) + device:emit_event(capabilities.thermostatMode.thermostatMode.auto()) + device:emit_event(capabilities.battery.battery(100)) +end + +local function do_configure(driver, device) + -- configure ApplicationVersion to keep device online, tuya hub also uses this attribute + tuya_utils.send_magic_spell(device) + device:send(Basic.attributes.ApplicationVersion:configure_reporting(device, 30, 300, 1)) + device:send(device_management.build_bind_request( + device, + Basic.ID, + driver.environment_info.hub_zigbee_eui + )) +end + +local function increase_packet_id(pid) + return (pid + 1) % 65536 +end + +local function do_refresh(driver, device) + device.log.info("do_refresh called") +end + +local MODE_MAP = { + [capabilities.thermostatMode.thermostatMode.auto.NAME] = "\x00", + [capabilities.thermostatMode.thermostatMode.off.NAME] = "\x01", + [capabilities.thermostatMode.thermostatMode.on.NAME] = "\x02", + [capabilities.thermostatMode.thermostatMode.comfort.NAME] = "\x03", + [capabilities.thermostatMode.thermostatMode.eco.NAME] = "\x04", + [capabilities.thermostatMode.thermostatMode.antifreezing.NAME] = "\x05", +} + +local function set_thermostat_mode(driver, device, command) + local mode_value = MODE_MAP[command.args.mode] + if mode_value ~= nil then + tuya_utils.send_tuya_command(device, "\x02", tuya_utils.DP_TYPE_ENUM, mode_value, packet_id) + packet_id = increase_packet_id(packet_id) + end +end + +local function set_heating_setpoint(driver, device, command) + local value = command.args.setpoint + local setpoint_raw = math.floor(value * 10 + 0.5) + tuya_utils.send_tuya_command( + device, + "\x04", + tuya_utils.DP_TYPE_VALUE, + string.pack(">I4", setpoint_raw), + packet_id + ) + packet_id = increase_packet_id(packet_id) +end + +local function tuya_cluster_handler(driver, device, zb_rx) + local event + local raw = zb_rx.body.zcl_body.body_bytes + local dp = raw:byte(3) + local dp_type = raw:byte(4) + local dp_data_len = string.unpack(">I2", raw:sub(5, 6)) + local dp_data = raw:sub(7, 6 + dp_data_len) + + if dp == 0x04 then -- Target temperature + if dp_type == 0x02 and dp_data_len >= 4 then -- value + local target_temp_raw = string.unpack(">I4", dp_data:sub(1, 4)) + local target_temp = target_temp_raw / 10.0 + event = capabilities.thermostatHeatingSetpoint.heatingSetpoint({ + value = target_temp, + unit = "C" + }) + end + + elseif dp == 0x05 then -- Current temperature + if dp_type == 0x02 and dp_data_len >= 4 then -- value + local temp_raw = string.unpack(">I4", dp_data:sub(1, 4)) + local temp = temp_raw / 10.0 + event = capabilities.temperatureMeasurement.temperature({ + value = temp, + unit = "C" + }) + end + + elseif dp == 0x02 then -- Thermostat mode + if dp_type == 0x04 and dp_data_len >= 1 then -- enum + local mode = dp_data:byte(1) + if mode == 0x00 then -- auto + event = capabilities.thermostatMode.thermostatMode.auto() + elseif mode == 0x01 then -- off + event = capabilities.thermostatMode.thermostatMode.off() + elseif mode == 0x02 then -- on + event = capabilities.thermostatMode.thermostatMode.on() + elseif mode == 0x03 then -- comfort + event = capabilities.thermostatMode.thermostatMode.comfort() + elseif mode == 0x04 then -- eco + event = capabilities.thermostatMode.thermostatMode.eco() + elseif mode == 0x05 then -- antifreezing + event = capabilities.thermostatMode.thermostatMode.antifreezing() + end + end + + elseif dp == 0x06 then -- Battery level + if dp_type == 0x02 and dp_data_len >= 4 then -- value + local battery_level = string.unpack(">I4", dp_data:sub(1, 4)) + event = capabilities.battery.battery(battery_level) + end + end + + if event ~= nil then + device:emit_event(event) + end +end + +local tuya_thermostat_driver = { + NAME = "tuya thermostat", + supported_capabilities = { + capabilities.temperatureMeasurement, + capabilities.thermostatHeatingSetpoint, + capabilities.thermostatMode, + capabilities.battery, + capabilities.refresh + }, + zigbee_handlers = { + cluster = { + [tuya_utils.TUYA_PRIVATE_CLUSTER] = { + [tuya_utils.TUYA_PRIVATE_CMD_REPORT] = tuya_cluster_handler, + [tuya_utils.TUYA_PRIVATE_CMD_RESPONSE] = tuya_cluster_handler, + } + } + }, + capability_handlers = { + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = set_thermostat_mode, + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + }, + }, + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + can_handle = is_tuya_thermostat +} + +defaults.register_for_default_handlers( + tuya_thermostat_driver, + tuya_thermostat_driver.supported_capabilities, + {} +) +return tuya_thermostat_driver diff --git a/drivers/Unofficial/tuya-zigbee/src/tuya_utils.lua b/drivers/Unofficial/tuya-zigbee/src/tuya_utils.lua new file mode 100644 index 0000000000..f28a6a87c1 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/tuya_utils.lua @@ -0,0 +1,173 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local device_lib = require "st.device" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local zcl_messages = require "st.zigbee.zcl" +local messages = require "st.zigbee.messages" +local data_types = require "st.zigbee.data_types" +local zb_const = require "st.zigbee.constants" +local generic_body = require "st.zigbee.generic_body" +local read_attribute = require "st.zigbee.zcl.global_commands.read_attribute" + +local TUYA_PRIVATE_CLUSTER = 0xEF00 +local TUYA_PRIVATE_CMD_RESPONSE = 0x01 +local TUYA_PRIVATE_CMD_REPORT = 0x02 +local DP_TYPE_BOOL = "\x01" +local DP_TYPE_VALUE = "\x02" +local DP_TYPE_ENUM = "\x04" + +local tuya_utils = {} + +local function read_attribute_function(device, cluster_id, attr_id) + local read_body = read_attribute.ReadAttribute( attr_id ) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(read_attribute.ReadAttribute.ID) + }) + local addrh = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(zcl_clusters.Basic.ID), + zb_const.HA_PROFILE_ID, + zcl_clusters.Basic.ID + ) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = read_body + }) + return messages.ZigbeeMessageTx({ + address_header = addrh, + body = message_body + }) +end + +tuya_utils.send_magic_spell = function(device) + local magic_spell = {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe} + device:send(read_attribute_function(device, zcl_clusters.Basic.ID, magic_spell)) +end + +tuya_utils.send_tuya_command = function(device, dp, dp_type, dp_data, packet_id) + local parent = (device.network_type == device_lib.NETWORK_TYPE_CHILD) and device:get_parent_device() or device + local zclh = zcl_messages.ZclHeader({cmd = data_types.ZCLCommandId(0x00)}) + zclh.frame_ctrl:set_cluster_specific() + zclh.frame_ctrl:set_disable_default_response() + local addrh = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + parent:get_short_address(), + parent:get_endpoint(TUYA_PRIVATE_CLUSTER), + zb_const.HA_PROFILE_ID, + TUYA_PRIVATE_CLUSTER + ) + local dp_data_len = string.len(dp_data) + local payload_body = generic_body.GenericBody(string.pack(">I2", packet_id) .. dp .. dp_type .. string.pack(">I2", dp_data_len) .. dp_data) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = payload_body + }) + local send_message = messages.ZigbeeMessageTx({ + address_header = addrh, + body = message_body + }) + parent:send(send_message) +end + +tuya_utils.build_test_attr_report = function(device, dp, dp_type, dp_data, cmd_id) + local zclh = zcl_messages.ZclHeader({cmd = data_types.ZCLCommandId(cmd_id)}) + zclh.frame_ctrl:set_cluster_specific() + zclh.frame_ctrl:set_disable_default_response() + local addrh = messages.AddressHeader( + device:get_short_address(), + device:get_endpoint(TUYA_PRIVATE_CLUSTER), + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + TUYA_PRIVATE_CLUSTER + ) + local dp_data_len = string.len(dp_data) + local payload_body = generic_body.GenericBody(string.pack(">I2", 1) .. dp .. dp_type .. string.pack(">I2", dp_data_len) .. dp_data) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = payload_body + }) + return messages.ZigbeeMessageRx({ + address_header = addrh, + body = message_body + }) +end + +tuya_utils.build_send_tuya_command = function(device, dp, dp_type, dp_data, packet_id) + local parent = (device.network_type == device_lib.NETWORK_TYPE_CHILD) and device:get_parent_device() or device + local zclh = zcl_messages.ZclHeader({cmd = data_types.ZCLCommandId(0x00)}) + zclh.frame_ctrl:set_cluster_specific() + zclh.frame_ctrl:set_disable_default_response() + local addrh = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + parent:get_short_address(), + parent:get_endpoint(TUYA_PRIVATE_CLUSTER), + zb_const.HA_PROFILE_ID, + TUYA_PRIVATE_CLUSTER + ) + local dp_data_len = string.len(dp_data) + local payload_body = generic_body.GenericBody(string.pack(">I2", packet_id) .. dp .. dp_type .. string.pack(">I2", dp_data_len) .. dp_data) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = payload_body + }) + return messages.ZigbeeMessageTx({ + address_header = addrh, + body = message_body + }) +end + +tuya_utils.build_tuya_magic_spell_message = function(device) + local magic_spell = {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe} + local read_body = read_attribute.ReadAttribute( magic_spell ) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(read_attribute.ReadAttribute.ID) + }) + local addrh = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(zcl_clusters.Basic.ID), + zb_const.HA_PROFILE_ID, + zcl_clusters.Basic.ID + ) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = read_body + }) + return messages.ZigbeeMessageTx({ + address_header = addrh, + body = message_body + }) +end + +tuya_utils.emit_event_if_latest_state_missing = function(device, component, capability, attribute_name, value) + if device:get_latest_state(component, capability.ID, attribute_name) == nil then + device:emit_event(value) + end +end + +tuya_utils.TUYA_PRIVATE_CLUSTER = TUYA_PRIVATE_CLUSTER +tuya_utils.DP_TYPE_BOOL = DP_TYPE_BOOL +tuya_utils.DP_TYPE_ENUM = DP_TYPE_ENUM +tuya_utils.DP_TYPE_VALUE = DP_TYPE_VALUE +tuya_utils.TUYA_PRIVATE_CMD_RESPONSE = TUYA_PRIVATE_CMD_RESPONSE +tuya_utils.TUYA_PRIVATE_CMD_REPORT = TUYA_PRIVATE_CMD_REPORT + +return tuya_utils \ No newline at end of file diff --git a/tools/config.luacov b/tools/config.luacov index cca0c1dc2e..26034bfb27 100644 --- a/tools/config.luacov +++ b/tools/config.luacov @@ -3,7 +3,50 @@ configuration = { "scripting-engine", "lua_libs", "test", - "unofficial" + "embedded_clusters", + "embedded_cluster_utils", + "unofficial", + "DishwasherAlarm", + "DishwasherMode", + "LaundryWasherControls", + "LaundryWasherMode", + "OperationalState", + "RefrigeratorAlarm", + "RefrigeratorAndTemperatureControlledCabinetMode", + "TemperatureControl", + "PumpConfigurationAndControl", + "RvcCleanMode", + "RvcOperationalState", + "RvcRunMode", + "AirQuality", + "CarbonDioxideConcentrationMeasurement", + "CarbonMonoxideConcentrationMeasurement", + "ConcentrationMeasurement", + "FormaldehydeConcentrationMeasurement", + "NitrogenDioxideConcentrationMeasurement", + "OzoneConcentrationMeasurement", + "Pm1ConcentrationMeasurement", + "Pm10ConcentrationMeasurement", + "Pm25ConcentrationMeasurement", + "PressureMeasurement", + "RadonConcentrationMeasurement", + "SmokeCoAlarm", + "TotalVolatileOrganicCompoundsConcentrationMeasurement", + "ActivatedCarbonFilterMonitoring", + "HepaFilterMonitoring", + "MicrowaveOvenControl", + "MicrowaveOvenMode", + "OvenMode", + "DeviceEnergyManagementMode", + "ElectricalEnergyMeasurement", + "ElectricalPowerMeasurement", + "EnergyEvse", + "EnergyEvseMode", + "BooleanStateConfiguration", + "ValveConfigurationAndControl", + "ThreadBorderRouterManagement", + "WaterHeaterMode", + "WiFiNetworkManagement", }, cobertura = { filenameparser = function(filename) @@ -13,4 +56,4 @@ configuration = { } } -return configuration \ No newline at end of file +return configuration diff --git a/tools/deploy.py b/tools/deploy.py index 6e980a23de..2c550149c7 100644 --- a/tools/deploy.py +++ b/tools/deploy.py @@ -5,29 +5,38 @@ CHANGED_DRIVERS = os.environ.get('CHANGED_DRIVERS') # configurable from Jenkins to override and manually set the drivers to be uploaded DRIVERS_OVERRIDE = os.environ.get('DRIVERS_OVERRIDE') or "[]" +DRY_RUN = os.environ.get("DRY_RUN") == True or os.environ.get("DRY_RUN") == "True" print(BRANCH) print(ENVIRONMENT) print(CHANGED_DRIVERS) -branch_environment = "{}_{}_".format(BRANCH, ENVIRONMENT) -ENVIRONMENT_URL = os.environ.get(ENVIRONMENT+'_ENVIRONMENT_URL') +ENVIRONMENT_URL = os.environ.get('ENVIRONMENT_URL') if not ENVIRONMENT_URL: print("No environment url specified, aborting.") exit(0) UPLOAD_URL = ENVIRONMENT_URL+"/drivers/package" -CHANNEL_ID = os.environ.get(branch_environment+'CHANNEL_ID') +CHANNEL_ID = os.environ.get(BRANCH+'_CHANNEL_ID') if not CHANNEL_ID: - print("No channel id specified, aborting.") + print("No channel id specified for "+BRANCH+", aborting.") exit(0) UPDATE_URL = ENVIRONMENT_URL+"/channels/"+CHANNEL_ID+"/drivers/bulk" -TOKEN = os.environ.get(ENVIRONMENT+'_TOKEN') +TOKEN = os.environ.get('TOKEN') DRIVERID = "driverId" VERSION = "version" PACKAGEKEY = "packageKey" +FAILURE_FILE = "failures.log" + BOSE_APPKEY = os.environ.get("BOSE_AUDIONOTIFICATION_APPKEY") -SONOS_API_KEY = os.environ.get("SONOS_API_KEY") + +SONOS_API_KEY = os.environ.get("SONOS_API_KEY") or "N/A" +SONOS_OAUTH_API_KEY = os.environ.get("SONOS_OAUTH_API_KEY") or "N/A" +SONOS_API_KEY_LUA_TEMPLATE = f"""return {{ + s1_key = "{SONOS_API_KEY}", + oauth_key = "{SONOS_OAUTH_API_KEY}", +}} +""" print(ENVIRONMENT_URL) @@ -49,12 +58,13 @@ with open(localization_file) as csvfile: reader = csv.reader(csvfile) for row in reader: - print("en: "+row[0]+" "+LOCALE+": "+row[1]) - subprocess.run( - "find . -name 'fingerprints.yml' | xargs sed -i -E 's/deviceLabel ?: \"?"+row[0].translate(slash_escape)+"\"?/deviceLabel: "+row[1].translate(slash_escape)+"/g'", - shell=True, - cwd=os.path.dirname(current_path) - ) + if len(row) > 1: + print("en: "+row[0]+" "+LOCALE+": "+row[1]) + subprocess.run( + "find . -name 'fingerprints.yml' | xargs sed -i -E 's/deviceLabel ?: \"?"+row[0].translate(slash_escape)+"\"?/deviceLabel: "+row[1].translate(slash_escape)+"/g'", + shell=True, + cwd=os.path.dirname(current_path) + ) subprocess.run("git status", shell=True) @@ -64,13 +74,16 @@ headers={ "Accept": "application/vnd.smartthings+json;v=20200810", "Authorization": "Bearer "+TOKEN, - "X-ST-LOG-LEVEL": "TRACE" + "X-ST-LOG-LEVEL": "DEBUG" } ) if response.status_code != 200: print("Failed to retrieve channel's current drivers") print("Error code: "+str(response.status_code)) print("Error response: "+response.text) + # if we cant get the existing drivers in the channel the bulk upload will + # unassign drivers from the channel. Exit to fail the deploy with a fail status + exit(1) else: response_json = json.loads(response.text)["items"] for driver in response_json: @@ -80,13 +93,20 @@ headers = { "Accept": "application/vnd.smartthings+json;v=20200810", "Authorization": "Bearer "+TOKEN, - "X-ST-LOG-LEVEL": "TRACE" + "X-ST-LOG-LEVEL": "DEBUG", + "X-ST-CORRELATION": "driver-deployment-"+BRANCH+"-"+ENVIRONMENT+"-"+str(time.time()) }, json = { DRIVERID: driver[DRIVERID], "driverVersion": driver[VERSION] } ) + if driver_info_response.status_code != 200: + print("Failed to retrieve detailed driver info for {driver}") + print("Error code: " + str(driver_info_response.status_code)) + print("Error response: "+ driver_info_response.text) + exit(1) + driver_info_response_json = json.loads(driver_info_response.text)["items"][0] if PACKAGEKEY in driver_info_response_json: packageKey = driver_info_response_json[PACKAGEKEY] @@ -115,8 +135,13 @@ if package_key == "bose" and BOSE_APPKEY: # write the app key into a app_key.lua (overwrite if exists already) subprocess.run(["touch -a ./src/app_key.lua && echo \'return \"" + BOSE_APPKEY + "\"\n\' > ./src/app_key.lua"], cwd=driver, shell=True, capture_output=True) - if package_key == "sonos" and SONOS_API_KEY: - subprocess.run(["echo \'return \"" + SONOS_API_KEY + "\"\n\' > ./src/app_key.lua"], cwd=driver, shell=True, capture_output=True) + if package_key == "sonos": + subprocess.run( + [f"echo -n '{SONOS_API_KEY_LUA_TEMPLATE}' > ./src/app_key.lua"], + cwd=driver, + shell=True, + capture_output=True, + ) retries = 0 while not os.path.exists(driver+".zip") and retries < 5: try: @@ -125,7 +150,7 @@ print(error.stderr) retries += 1 if retries >= 5: - print("5 zip failires, skipping "+package_key+" and continuing.") + print("5 zip failures, skipping "+package_key+" and continuing.") continue with open(driver+".zip", 'rb') as driver_package: data = driver_package.read() @@ -138,7 +163,7 @@ "Content-Type": "application/zip", "Accept": "application/vnd.smartthings+json;v=20200810", "Authorization": "Bearer "+TOKEN, - "X-ST-LOG-LEVEL": "TRACE"}, + "X-ST-LOG-LEVEL": "DEBUG"}, data=data) if response.status_code != 200: print("Failed to upload driver "+driver) @@ -147,6 +172,11 @@ if response.status_code == 500 or response.status_code == 429: retries = retries + 1 if retries > 3: + with open("../../"+FAILURE_FILE, 'a') as f: # go up to the root directory to output, since we've changed dirs to the partner directory + f.write("Failed to upload driver to "+ENVIRONMENT+": "+driver) + f.write("Error code: "+str(response.status_code)) + f.write("Error response: "+response.text) + f.write('\n') break # give up if response.status_code == 429: time.sleep(10) @@ -165,23 +195,27 @@ print("Uploading package: {} driver id: {} version: {}".format(package_key, driver_info[DRIVERID], driver_info[VERSION])) driver_updates.append({DRIVERID: driver_info[DRIVERID], VERSION: driver_info[VERSION]}) -response = requests.put( - UPDATE_URL, - headers={ - "Accept": "application/vnd.smartthings+json;v=20200810", - "Authorization": "Bearer "+TOKEN, - "Content-Type": "application/json", - "X-ST-LOG-LEVEL": "TRACE" - }, - data=json.dumps(driver_updates) -) -if response.status_code != 204: - print("Failed to bulk update drivers") - print("Error code: "+str(response.status_code)) - print("Error response: "+response.text) - exit(1) +if DRY_RUN: + print("Dry Run, skipping bulk upload to " + UPDATE_URL) +else: + response = requests.put( + UPDATE_URL, + headers={ + "Accept": "application/vnd.smartthings+json;v=20200810", + "Authorization": "Bearer "+TOKEN, + "Content-Type": "application/json", + "X-ST-LOG-LEVEL": "DEBUG", + "X-ST-CORRELATION": "driver-deployment-"+BRANCH+"-"+ENVIRONMENT+"-"+str(time.time()) + }, + data=json.dumps(driver_updates) + ) + if response.status_code != 204: + print("Failed to bulk update drivers") + print("Error code: "+str(response.status_code)) + print("Error response: "+response.text) + exit(1) print("Update drivers: ") print(drivers_updated) -print("\nDrivers currently deplpyed: ") +print("\nDrivers currently deployed: ") print(uploaded_drivers.keys()) diff --git a/tools/fetch_capability_definitions.py b/tools/fetch_capability_definitions.py new file mode 100644 index 0000000000..45ef47b9cd --- /dev/null +++ b/tools/fetch_capability_definitions.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +# Copyright 2026 SmartThings +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""fetch_capability_definitions.py + +Scans driver profile YAML files to discover all capability IDs and versions in +use, then downloads their fully-inlined JSON definitions from the SmartThings +cloud API and writes each to a separate file in the output directory. + +The downloaded JSON files are pre-inlined by the cloud (no $ref strings remain) +and can be used directly by the integration test framework's capability_json_loader +module via the ST_CAPABILITY_JSON_DIR environment variable. + +Capability definitions are fetched individually via GET /capabilities// +by default, which ensures that a failure for one capability does not affect others. +A known allowlist of capabilities (QUERY_ENDPOINT_IDS) that are confirmed to work +with the bulk POST /capabilities/query endpoint are fetched in a single batch +request instead, for efficiency. + +Usage +----- + python3 tools/fetch_capability_definitions.py \\ + --drivers-dir drivers/ \\ + --drivers-dir ~/projects/SmartThingsEdgeDrivers/drivers \\ + --output-dir ~/cap_cache \\ + [--overwrite] \\ + [--failed-output-file failures_comment.md] + +Arguments +--------- + --drivers-dir PATH Root of a driver tree to scan for profiles. + May be specified multiple times. + --output-dir PATH Directory to write _.json files. + Created if it does not exist. + --overwrite When set, re-download and overwrite all capability + files even if they already exist in --output-dir. + When absent (default), only missing files are fetched; + capabilities already on disk are skipped entirely, + and no API request is made for them. + --failed-output-file PATH When set, write a markdown-formatted report of any + capabilities that could not be downloaded to this file. + The file is only created when there are failures, making + it suitable for use as a PR comment via hashFiles() checks + in CI workflows. + +Environment +----------- + CAPABILITY_PAT Required. SmartThings personal access token used for + the Authorization: Bearer header on all API requests. +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Dict, List, Set, Tuple + +import requests +import yaml + +API_BASE = "https://api.smartthings.com/v1" +QUERY_ENDPOINT = f"{API_BASE}/capabilities/query" + +# These two IDs appear in the /capabilities list but are rejected by both the +# /capabilities/query endpoint and the individual GET endpoint. They are +# lowercase/deprecated duplicates of alarmSensor and samsungTV respectively. +EXCLUDED_IDS = frozenset(["alarmsensor", "samsungTv"]) + +# These capability IDs are confirmed to work with the bulk POST /capabilities/query +# endpoint and will be fetched in a single batch request for efficiency. All other +# capability IDs (e.g. namespaced capabilities such as stse.*, sec.*, samsungim.*) +# are fetched individually via GET /capabilities// by default. This +# ensures that a new capability added to a profile that is not supported by the +# query endpoint will only cause an isolated failure rather than affecting all +# capabilities in the batch. +QUERY_ENDPOINT_IDS = frozenset([ + "accelerationSensor", + "activitySensor", + "airConditionerFanMode", + "airConditionerMode", + "airPurifierFanMode", + "airQualityHealthConcern", + "airQualitySensor", + "alarm", + "atmosphericPressureMeasurement", + "audioMute", + "audioNotification", + "audioRecording", + "audioTrackData", + "audioVolume", + "battery", + "batteryLevel", + "bodyWeightMeasurement", + "bridge", + "button", + "cameraPrivacyMode", + "cameraViewportSettings", + "carbonDioxideHealthConcern", + "carbonDioxideMeasurement", + "carbonMonoxideDetector", + "carbonMonoxideHealthConcern", + "carbonMonoxideMeasurement", + "chargingState", + "chime", + "colorControl", + "colorTemperature", + "configuration", + "contactSensor", + "cookTime", + "currentMeasurement", + "dewPoint", + "doorControl", + "dustHealthConcern", + "dustSensor", + "elevatorCall", + "energyMeter", + "evseChargingSession", + "evseState", + "fanMode", + "fanOscillationMode", + "fanSpeed", + "fanSpeedPercent", + "feederOperatingState", + "feederPortion", + "filterState", + "filterStatus", + "fineDustHealthConcern", + "fineDustSensor", + "firmwareUpdate", + "flowMeasurement", + "formaldehydeHealthConcern", + "formaldehydeMeasurement", + "gasDetector", + "gasMeter", + "hardwareFault", + "hdr", + "illuminanceMeasurement", + "imageCapture", + "imageControl", + "keypadInput", + "knob", + "laundryWasherRinseMode", + "laundryWasherSpinSpeed", + "level", + "localMediaStorage", + "lock", + "lockAlarm", + "lockAliro", + "lockCodes", + "lockCredentials", + "lockSchedules", + "lockUsers", + "mechanicalPanTiltZoom", + "mediaGroup", + "mediaPlayback", + "mediaPresets", + "mediaTrackControl", + "mode", + "moldHealthConcern", + "momentary", + "motionSensor", + "movementSensor", + "multipleZonePresence", + "nightVision", + "nitrogenDioxideHealthConcern", + "nitrogenDioxideMeasurement", + "operationalState", + "ozoneHealthConcern", + "ozoneMeasurement", + "panicAlarm", + "pestControl", + "powerConsumptionReport", + "powerMeter", + "powerSource", + "presenceSensor", + "pumpControlMode", + "pumpOperationMode", + "radonHealthConcern", + "radonMeasurement", + "rainSensor", + "refresh", + "relativeHumidityMeasurement", + "remoteControlStatus", + "robotCleanerOperatingState", + "safetyValve", + "serviceArea", + "signalStrength", + "smokeDetector", + "soundSensor", + "sounds", + "statelessColorTemperatureStep", + "statelessCurtainPowerButton", + "statelessSwitchLevelStep", + "switch", + "switchLevel", + "tamperAlert", + "temperatureAlarm", + "temperatureLevel", + "temperatureMeasurement", + "temperatureSetpoint", + "thermostatCoolingSetpoint", + "thermostatFanMode", + "thermostatHeatingSetpoint", + "thermostatMode", + "thermostatOperatingState", + "threadBorderRouter", + "threadNetwork", + "threeAxis", + "tone", + "tvocHealthConcern", + "tvocMeasurement", + "ultravioletIndex", + "valve", + "veryFineDustHealthConcern", + "veryFineDustSensor", + "videoCapture2", + "videoStreamSettings", + "voltageMeasurement", + "waterFlowAlarm", + "waterMeter", + "waterSensor", + "webrtc", + "wifiInformation", + "windMode", + "windowShade", + "windowShadeLevel", + "windowShadePreset", + "windowShadeTiltLevel", + "zoneManagement", + "zwMultichannel", +]) + + +def build_headers(pat: str) -> Dict: + return { + "Authorization": f"Bearer {pat}", + "Accept": "application/vnd.smartthings+json;v=1, application/json", + "Content-Type": "application/json", + } + + +def scan_profiles(drivers_dirs: List[Path]) -> Set[Tuple[str, int]]: + """ + Walk every drivers_dir, find all *.yml files under any profiles/ subdirectory, + parse them with PyYAML, and extract (capabilityId, version) pairs. + + Returns a deduplicated set of (id, version) tuples. + """ + found: Set[Tuple[str, int]] = set() + profile_count = 0 + error_count = 0 + + for drivers_dir in drivers_dirs: + for profile_path in drivers_dir.rglob("profiles/*.yml"): + profile_count += 1 + try: + with open(profile_path, encoding="utf-8") as f: + profile = yaml.safe_load(f) + if not profile or not isinstance(profile.get("components"), list): + continue + for component in profile["components"]: + for cap in component.get("capabilities") or []: + cap_id = cap.get("id") + cap_ver = cap.get("version", 1) + if cap_id: + found.add((cap_id, int(cap_ver))) + except Exception as exc: + print(f" WARNING: failed to parse {profile_path}: {exc}", file=sys.stderr) + error_count += 1 + + print( + f"Scanned {profile_count} profile(s) across {len(drivers_dirs)} driver tree(s)" + + (f", {error_count} parse error(s)" if error_count else "") + + "." + ) + return found + + +def output_path(output_dir: Path, cap_id: str, cap_ver: int) -> Path: + return output_dir / f"{cap_id}_{cap_ver}.json" + + +def fetch_definitions( + pairs: List[Tuple[str, int]], + headers: Dict, + max_retries: int = 3, + retry_delay: float = 2.0, +) -> Tuple[Dict[Tuple[str, int], Dict], List[Tuple[str, int, str]]]: + """ + Fetch capability definitions for the given (id, version) pairs. + + Pairs whose ID is in QUERY_ENDPOINT_IDS are fetched via a single bulk POST + to /capabilities/query. All other pairs are fetched individually via + GET /capabilities//, so that an unsupported capability causes + only an isolated failure rather than affecting the entire batch. + + Any capability that is missing from the bulk query response (e.g. due to an + intermittent API hiccup) is retried individually via GET up to max_retries + times before being recorded as a failure. + + Returns a tuple of: + - dict mapping (id, version) -> definition object for successful fetches + - list of (id, version, reason) tuples for failed fetches + """ + results: Dict[Tuple[str, int], Dict] = {} + failures: List[Tuple[str, int, str]] = [] + if not pairs: + return results, failures + + # Split into bulk-query pairs and individual-GET pairs + query_pairs = [(cid, ver) for cid, ver in pairs if cid in QUERY_ENDPOINT_IDS] + get_pairs = [(cid, ver) for cid, ver in pairs if cid not in QUERY_ENDPOINT_IDS] + + # --- Bulk query for known-good capabilities --- + if query_pairs: + query = [{"capabilityId": cid, "version": ver} for cid, ver in query_pairs] + response = requests.post(QUERY_ENDPOINT, headers=headers, json={"query": query}, timeout=30) + + if response.status_code == 200: + for item in response.json().get("items", []): + results[(item["id"], item["version"])] = item + # Any pair missing from the response gets retried individually - the + # API occasionally omits items from the bulk response transiently. + returned = {(item["id"], item["version"]) for item in response.json().get("items", [])} + missing = [(cid, ver) for cid, ver in query_pairs if (cid, ver) not in returned] + if missing: + print( + f" WARNING: {len(missing)} capability/version pair(s) missing from bulk query " + "response — retrying individually..." + ) + for cap_id, cap_ver in missing: + _fetch_individual(cap_id, cap_ver, headers, results, failures, + max_retries, retry_delay) + else: + print( + f" WARNING: bulk query returned HTTP {response.status_code} — " + "marking all query-endpoint capabilities as failed." + ) + for cap_id, cap_ver in query_pairs: + failures.append((cap_id, cap_ver, f"HTTP {response.status_code}")) + + # --- Individual GET for all other capabilities --- + if get_pairs: + print(f" Fetching {len(get_pairs)} capability/version pair(s) via individual GET...") + for cap_id, cap_ver in get_pairs: + _fetch_individual(cap_id, cap_ver, headers, results, failures, + max_retries, retry_delay) + + return results, failures + + +def _fetch_individual( + cap_id: str, + cap_ver: int, + headers: Dict, + results: Dict, + failures: List, + max_retries: int = 3, + retry_delay: float = 2.0, +) -> None: + """ + Fetch a single capability definition via GET /capabilities//, + retrying up to max_retries times on failure with exponential backoff. + Appends to results on success or failures on final failure. + """ + last_status = None + for attempt in range(1, max_retries + 1): + r = requests.get( + f"{API_BASE}/capabilities/{cap_id}/{cap_ver}", + headers=headers, + timeout=30, + ) + if r.status_code == 200: + results[(cap_id, cap_ver)] = r.json() + return + last_status = r.status_code + if attempt < max_retries: + wait = retry_delay * attempt + print( + f" WARNING: {cap_id} v{cap_ver} — HTTP {last_status} " + f"(attempt {attempt}/{max_retries}, retrying in {wait:.0f}s...)" + ) + time.sleep(wait) + + print(f" WARNING: skipping {cap_id} v{cap_ver} — HTTP {last_status} after {max_retries} attempts.") + failures.append((cap_id, cap_ver, f"HTTP {last_status}")) + + +def write_failure_report( + failures: List[Tuple[str, int, str]], + output_file: Path, + truncate_at: int = 10, +) -> None: + """ + Write a markdown-formatted failure report suitable for use as a PR comment. + + The file is only written when there are failures. If failures is empty, + this function does nothing (leaving any previously existing file untouched, + but no file will be created for a clean run). + """ + if not failures: + return + + lines = [ + "## ⚠️ Capability Definition Download Failures", + "", + "The following capability definitions could not be downloaded from the SmartThings API:", + "", + "| Capability ID | Version | Reason |", + "|--------------|---------|--------|", + ] + + shown = failures[:truncate_at] + for cap_id, cap_ver, reason in shown: + lines.append(f"| `{cap_id}` | {cap_ver} | {reason} |") + + if len(failures) > truncate_at: + lines.append("") + lines.append(f"_...and {len(failures) - truncate_at} more._") + + lines += [ + "", + "These capabilities may not be available in the test environment. " + "Tests using these capabilities may fail or fall back to built-in definitions.", + ] + + output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--drivers-dir", + action="append", + dest="drivers_dirs", + metavar="PATH", + required=True, + help="Root of a driver tree to scan (may be specified multiple times).", + ) + parser.add_argument( + "--output-dir", + metavar="PATH", + required=True, + help="Directory to write _.json files.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + default=False, + help=( + "Re-download and overwrite all capability files even if they already " + "exist. Without this flag only missing files are fetched." + ), + ) + parser.add_argument( + "--failed-output-file", + metavar="PATH", + default=None, + help=( + "When set, write a markdown-formatted report of any capabilities that " + "could not be downloaded to this file. The file is only created when " + "there are failures, making it suitable for use as a PR comment via " + "hashFiles() checks in CI workflows." + ), + ) + args = parser.parse_args(argv) + + pat = os.environ.get("CAPABILITY_PAT", "") + if not pat: + print("ERROR: CAPABILITY_PAT environment variable is not set.", file=sys.stderr) + sys.exit(1) + + headers = build_headers(pat) + output_dir = Path(args.output_dir).expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + drivers_dirs = [Path(d).expanduser().resolve() for d in args.drivers_dirs] + for d in drivers_dirs: + if not d.is_dir(): + print(f"ERROR: --drivers-dir does not exist: {d}", file=sys.stderr) + sys.exit(1) + + # Step 1: scan profiles + all_pairs = scan_profiles(drivers_dirs) + print(f"Found {len(all_pairs)} unique capability/version pair(s).") + + # Step 2: filter known-bad IDs + excluded = {p for p in all_pairs if p[0] in EXCLUDED_IDS} + for cap_id, cap_ver in sorted(excluded): + print( + f" WARNING: excluding {cap_id} v{cap_ver} " + "(rejected by the query endpoint — likely a duplicate/malformed entry)." + ) + all_pairs -= excluded + + # Step 3: resolve what to fetch + if args.overwrite: + to_fetch = sorted(all_pairs) + already_present = 0 + else: + to_fetch = sorted(p for p in all_pairs if not output_path(output_dir, p[0], p[1]).exists()) + already_present = len(all_pairs) - len(to_fetch) + + if already_present: + print(f"{already_present} capability/version pair(s) already on disk — skipping.") + + if not to_fetch: + print("Nothing to fetch. Output directory is up to date.") + _print_summary(len(drivers_dirs), len(all_pairs) + len(excluded), len(excluded), + already_present, 0, 0, 0, output_dir) + return + + print(f"Fetching {len(to_fetch)} capability definition(s)...") + + # Step 4: fetch + definitions, failures = fetch_definitions(to_fetch, headers) + + # Step 5: write + written = 0 + skipped_api = 0 + for cap_id, cap_ver in to_fetch: + definition = definitions.get((cap_id, cap_ver)) + if definition is None: + skipped_api += 1 + continue + dest = output_path(output_dir, cap_id, cap_ver) + dest.write_text(json.dumps(definition, indent=2), encoding="utf-8") + written += 1 + + _print_summary(len(drivers_dirs), len(all_pairs) + len(excluded), len(excluded), + already_present, len(to_fetch), written, skipped_api, output_dir) + + # Step 6: write failure report if requested + if args.failed_output_file: + failed_output_file = Path(args.failed_output_file).expanduser().resolve() + write_failure_report(failures, failed_output_file) + if failures: + print(f" Failure report written to: {failed_output_file}") + + +def _print_summary( + trees: int, + total_found: int, + excluded: int, + already_present: int, + requested: int, + written: int, + skipped_api: int, + output_dir: Path, +) -> None: + print() + print("=== Summary ===") + print(f" Driver trees scanned : {trees}") + print(f" Unique capabilities : {total_found}") + if excluded: + print(f" Excluded (bad IDs) : {excluded}") + print(f" Already on disk : {already_present}") + print(f" Requested from API : {requested}") + print(f" Written : {written}") + if skipped_api: + print(f" Skipped (API error) : {skipped_api}") + print(f" Output directory : {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/tools/localizations/cn.csv b/tools/localizations/cn.csv index 9c45f636ef..6c6aac0a81 100644 --- a/tools/localizations/cn.csv +++ b/tools/localizations/cn.csv @@ -44,16 +44,118 @@ Aqara Water Leak Sensor,Aqara 水浸传感器 T1 Aqara Illuminance Sensor T1,Aqara 光照传感器T1 Aqara Presence Sensor FP1,Aqara 人体存在传感器FP1 Aqara Smart Dimmer Controller T1 Pro,Aqara 双色温驱动器 T1 Pro -Aqara Single Switch Module T1 (With Neutral),Aqara 单路控制器T1 (零火版) +"Aqara Single Switch Module T1 (With Neutral)",Aqara 单路控制器T1 (零火线版) Aqara Curtain Controller,Aqara智能窗帘电机 (Zigbee开合帘版) Aqara Roller Shade Controller,Aqara智能管状电机 Aqara Smart Pet Feeder C1,Aqara智能宠物喂食器 C1 -"Aqara Smart Wall Switch H1 (With Neutral,Single Rocker)",Aqara智能墙壁开关H1 (零火线单键版) -"Aqara Smart Wall Switch H1 (With Neutral,Double Rocker) 1",Aqara智能墙壁开关H1 (零火线双键版) 1 -"Aqara Smart Wall Switch H1 (With Neutral,Triple Rocker) 1",Aqara智能墙壁开关H1 (零火线三键版) 1 +"Aqara Smart Wall Switch H1 (With Neutral, Single Rocker)",Aqara智能墙壁开关H1 (零火线单键版) +"Aqara Smart Wall Switch H1 (With Neutral, Double Rocker)",Aqara智能墙壁开关H1 (零火线双键版) +"Aqara Smart Wall Switch H1 (With Neutral, Triple Rocker)",Aqara智能墙壁开关H1 (零火线三键版) Aqara Smart Natural Gas Detector,Aqara天然气报警器 Aqara Smart Smoke Detector,Aqara烟雾报警器 Aqara Smart Plug T1,Aqara 智能插座 T1 (国标) Aqara Wireless Mini Switch T1,Aqara 无线开关 T1 "Aqara Wireless Remote Switch E1 (Single Rocker)",Aqara 无线开关 E1 (贴墙式单键版) -"Aqara LED Light Bulb T1 (Tunable White)",Aqara LED灯泡 T1 (可调色温) \ No newline at end of file +"Aqara LED Light Bulb T1 (Tunable White)",Aqara LED灯泡 T1 (可调色温) +"Aqara Smart Wall Switch H1 EU (With Neutral, Single Rocker)",Aqara智能墙壁开关H1 EU (零火线单键版) +"Aqara Smart Wall Switch H1 EU (With Neutral, Double Rocker)",Aqara智能墙壁开关H1 EU (零火线双键版) +"Aqara Smart Wall Switch H1 EU (No Neutral, Single Rocker)",Aqara智能墙壁开关H1 EU (单火线单键版) +"Aqara Smart Wall Switch H1 EU (No Neutral, Double Rocker)",Aqara智能墙壁开关H1 EU (单火线双键版) +"Aqara Single Switch Module T1 (No Neutral)",Aqara 单路控制器T1 (单火线版) +"TOTEM Door Lock",TOTEM智能门锁 +"LeTianPai Presence Sensor Box",乐天派存在传感器Box +"Aqara Cube T1 Pro",Aqara 魔方控制器 T1 Pro +"Aqara Smart Wall Switch T1 (With Neutral, Single Rocker)",Aqara智能墙壁开关T1 (零火线单键版) +"Aqara Smart Wall Switch T1 (With Neutral, Double Rocker)",Aqara智能墙壁开关T1 (零火线双键版) +"Aqara Smart Wall Switch T1 (With Neutral, Triple Rocker)",Aqara智能墙壁开关T1 (零火线三键版) +"LeTianPai Smart Switch 1 Way",乐天派智能开关(零火版)单开 +"LeTianPai Smart Light Bulb",乐天派智能灯泡 +"Heiman Door and Window D1-M",智能门磁传感器 +"WallHero Switch (1 Way)",一位智能开关面板 +"WallHero Switch (2 Way)",二位智能开关面板 +"WallHero Switch (3 Way)",三位智能开关面板 +"WallHero Switch (4 Way)",四位智能开关面板 +"WallHero Switch (8 Way)",八位智能开关/场景面板 +"WallHero Outlet",智能墙面五孔插座 +"WallHero Remote Control (4-Inch)",语音场景(4寸)面板 +"Siterwell Radiator Thermostat",赛特威尔智能温控阀 +"Zemismart ZM02 Smart Curtain",Zemismart ZM02 智能窗帘 +"Zemismart ZM25C Smart Curtain",Zemismart ZM25C 智能窗帘 +"Zemismart ZM01 Smart Curtain", Zemismart ZM01 智能窗帘 +"WISTAR WSERD30-B Smart Tubular Motor",威仕达智能管状电机 WSERD30-B +"WISTAR WSER40 Smart Tubular Motor",威仕达智能管状电机 WSER40 +"WISTAR WSER50 Smart Tubular Motor",威仕达智能管状电机 WSER50 +"Heiman Smoke Sensor",海曼智能烟雾传感器 +"Heiman CO Sensor",海曼智能一氧化碳传感器 +"Smart co sensor",海曼智能一氧化碳传感器 +"ThirdReality Smart Soil Moisture Sensor",树实智能土壤湿度计 +"ThirdReality Temperature and Humidity Sensor",树实智能温湿度计 +"Laisiao Bathroom Heater",Laisiao智能浴霸 +"Siterwell Door Window Sensor",赛特威尔门窗检测传感器 +"Siterwell Smart Carbon Monoxide Alarm",赛特威尔智能CO报警器 +"Zemismart M6 Hub",Zemismart M6 网关 +"Siterwell Smart Smoke Alarm",赛特威尔智能烟雾报警器 +"Siterwell Motion Sensor",赛特威尔人体检测传感器 +"ThirdReality Smart Watering Kit",树实智能浇灌套装 +"Zemismart ZM24A Smart Curtain", Zemismart ZM24A 智能窗帘 +"Greentown Lock(SG20)",绿城门锁(SG20) +"MultiIR Air Quality Detector",MultiIR空气质量检测仪 +"Essentials Indoor Lights",基础款室内照明 +"Sleepone Ai SX-1",智能床垫 +"Smart Mechanical Keyboard MK1",树实智能机械键盘MK1 +"Smart Bridge MZ1",树实智能网桥MZ1 +"A3 Home Gateway",萤石A3智能网关 +"xCREAS Smart Air Purifier Cyclone400",xCREAS智能空气净化器Cyclone400 +"Zemismart M1 Hub",Zemismart M1 网关 +"WISTAR WSERD16-B Smart Tubular Motor",威仕达智能管状电机 WSERD16-B +"WISTAR WSERD24 Smart Tubular Motor",威仕达智能管状电机 WSERD24 +"WISTAR WSERD40-B Smart Tubular Motor",威仕达智能管状电机 WSERD40-B +"WISTAR WSERD40-L Smart Tubular Motor",威仕达智能管状电机 WSERD40-L +"WISTAR WSERD40-T Smart Tubular Motor",威仕达智能管状电机 WSERD40-T +"WISTAR WSERD50-B Smart Tubular Motor",威仕达智能管状电机 WSERD50-B +"WISTAR WSERD50-L Smart Tubular Motor",威仕达智能管状电机 WSERD50-L +"WISTAR WSERD50-T Smart Tubular Motor",威仕达智能管状电机 WSERD50-T +"WISTAR WSER60 Smart Tubular Motor",威仕达智能管状电机 WSER60 +"WISTAR WSCMXH Smart Vertical Blind Motor",威仕达智能梦幻帘电机 WSCMXH +"WISTAR WSCMXF Smart Vertical Blind Motor",威仕达智能梦幻帘电机 WSCMXF +"WISTAR WSCMXF-LED Smart Vertical Blind Motor",威仕达智能梦幻帘电机 WSCMXF-LED +"VIVIDSTORM Smart Screen VWSDSTUST120H",VIVIDSTORM智能幕布 VWSDSTUST120H +"Wi-Fi Relay",无线继电器 +"HOPOsmart Window Opener A2230011",HOPOsmart链式开窗器 A2230011 +"Yanmi Switch (3 Way)",岩米三位智能开关面板 +"Onvis Smart Plug S4EU",Onvis 智能插座S4EU +"Yanmi Switch (2 Way)",岩米二位智能开关面板 +"Yanmi Switch (1 Way)",岩米一位智能开关面板 +"WISTAR WSCMQ Smart Curtain Motor",威仕达智能开合帘电机 WSCMQ +"WISTAR WSCMXI Smart Curtain Motor",威仕达智能开合帘电机 WSCMXI +"WISTAR WSCMT Smart Curtain Motor",威仕达智能开合帘电机 WSCMT +"WISTAR WSCMXB Smart Curtain Motor",威仕达智能开合帘电机 WSCMXB +"WISTAR WSCMXC Smart Curtain Motor",威仕达智能开合帘电机 WSCMXC +"WISTAR WSCMXJ Smart Curtain Motor",威仕达智能开合帘电机 WSCMXJ +"HAOJAI Smart Switch 3-key",好家智能三键开关 +"HAOJAI Smart Switch 6-key",好家智能六键开关 +"MultiIR Motion Detector MIR-IR100",麦乐克人体移动传感器MIR-IR100 +"MultiIR Water Leak Sensor MIR-WA100",麦乐克水浸传感器MIR-WA100 +"MultiIR Contact Sensor MIR-MC100",麦乐克门窗开关传感器MIR-MC100 +"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 +"Mirror Series 3x3 1",镜系列3x3 1 +"Punk Series 3x3 1",朋克系列3x3 1 +"Punk Series 2x2 1",朋克系列2x2 1 +"Punk Series 1x1 1",朋克系列1x1 1 +"Matrix X Series 4x2 1",矩阵系列4x2 1 +"Matrix X Series 3x2 1",矩阵系列3x2 1 +"Matrix X Series 2x2 1",矩阵系列2x2 1 +"Matrix X Series 1x2 1",矩阵系列1x2 1 +"Guanzhi Series 3x3 1",观致系列3x3 1 +"Guanzhi Series 2x2 1",观致系列2x2 1 +"Guanzhi Series 1x1 1",观致系列1x1 1 +"Dynamic Lite Series 4x4 1",灵动L系列4x4 1 +"Dynamic Lite Series 3x3 1",灵动L系列3x3 1 +"Dynamic Lite Series 2x2 1",灵动L系列2x2 1 +"Dynamic Lite Series 1x1 1",灵动L系列1x1 1 +"Mirror Series 1x1 1",镜系列1x1 1 +"Mirror Series 2x2 1",镜系列2x2 1 +"WALLHERO Thermostat ACL-403STC1",墙壁侠温控ACL-403STC1 diff --git a/tools/pre-commit b/tools/pre-commit new file mode 100755 index 0000000000..41a9406bf5 --- /dev/null +++ b/tools/pre-commit @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Copyright 2025 SmartThings, Inc. +# Licensed under the Apache License, Version 2.0 +# +# pre-commit +# +# - Soft link into .git/hooks/pre-commit +# - $ ln -s ../../tools/pre-commit .git/hooks/pre-commit +# - Ensure executable + +set -e + +# Get changed files that are Added or Modified +if [[ "$1" ]]; then + files=$(find $1 -type f -follow -print) +else + files=$(git diff --staged --name-only --diff-filter=AM) +fi + +fail=0 +for file in $files; do + # Only process .lua files + case "$file" in + *.lua) + # Skip if not a regular file + [ -f "$file" ] || continue + + # Check if file is not empty, is a SmartThings driver and has the copyright header + if [ -s "$file" ] && [[ "$file" =~ "drivers/SmartThings" ]] \ + && [[ ! $(grep $file --text -P -e "-{2,} Copyright (?:© )?\d{4}(?:-\d{4})? SmartThings") ]]; then + echo "$file: SmartThings Copyright missing from file" + fail=1 + fi + + # Check if file is non-empty and does NOT end with a newline + if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then + echo "$file: Missing newline at end of file" + fail=1 + fi + ;; + esac +done + +exit $fail diff --git a/tools/run_driver_tests.py b/tools/run_driver_tests.py index c797ef5320..9020bba05b 100755 --- a/tools/run_driver_tests.py +++ b/tools/run_driver_tests.py @@ -7,6 +7,7 @@ import argparse from pathlib import Path import junit_xml +import shutil VERBOSITY_TOTALS_ONLY = 0 VERBOSITY_TEST_STATUS_ONLY = 1 @@ -14,7 +15,7 @@ VERBOSITY_ALL_TEST_LOGS = 3 DRIVER_DIR = Path(os.path.abspath(__file__)).parents[1].joinpath("drivers") -LUACOV_CONFIG = DRIVER_DIR.parent.joinpath(".circleci", "config.luacov") +LUACOV_CONFIG = DRIVER_DIR.parent.joinpath("tools", "config.luacov") def find_affected_tests(working_dir, changed_files): affected_tests = [] @@ -29,13 +30,18 @@ def find_affected_tests(working_dir, changed_files): affected_tests = set(affected_tests) return affected_tests -def run_tests(verbosity_level, filter, junit, coverage_files): +def run_tests(verbosity_level, filter, junit, coverage_files, html): owd = os.getcwd() coverage_files = find_affected_tests(owd, coverage_files) failure_files = defaultdict(list) ts = [] total_tests = 0 total_passes = 0 + drivers_needing_html = {} + # Propagate ST_CAPABILITY_JSON_DIR so the mock capability channel can load + # capability definitions from pre-fetched JSON files produced by + # tools/fetch_capability_definitions.py. + env = os.environ.copy() for test_file in DRIVER_DIR.glob("*" + os.path.sep + "*" + os.path.sep + "src" + os.path.sep + "test" + os.path.sep + "test_*.lua"): if filter != None and re.search(filter, str(test_file)) is None: continue @@ -44,9 +50,9 @@ def run_tests(verbosity_level, filter, junit, coverage_files): print("#" * len(test_line)) print(test_line) if test_file in coverage_files: - a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) else: - a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) lines = a.stdout.decode().split("\n") test_count = 0 passes = 0 @@ -115,10 +121,10 @@ def run_tests(verbosity_level, filter, junit, coverage_files): test_status = "" test_logs = "" test_done = False - if re.match("^\s*$", line) is None: + if re.match(r"^\s*$", line) is None: last_line = line - m = re.match("Passed (\d+) of (\d+) tests", last_line) + m = re.match(r"Passed (\d+) of (\d+) tests", last_line) if m is None: failure_files[test_file].append("\n ".join(a.stderr.decode().split("\n"))) test_case = junit_xml.TestCase(test_suite.name) @@ -137,7 +143,29 @@ def run_tests(verbosity_level, filter, junit, coverage_files): test_suite.test_cases = test_cases ts.append(test_suite) if test_file in coverage_files: - subprocess.run("luacov -c={}".format(LUACOV_CONFIG), shell=True) + if html: + driver_name = test_file.parts[-4] + src_path = test_file.parents[1] + drivers_needing_html[driver_name] = src_path + else: + subprocess.run("luacov -c={}".format(LUACOV_CONFIG), shell=True) + + if drivers_needing_html: + coverage_html_dir = DRIVER_DIR.parent.joinpath("tools/coverage_output_html") + try: + os.mkdir(coverage_html_dir) + except FileExistsError: + pass + + for driver_name, src_path in drivers_needing_html.items(): + os.chdir(src_path) + subprocess.run("luacov -c {} --reporter html".format(LUACOV_CONFIG), shell=True) + html_source = Path("luacov.report.out") + html_dest = coverage_html_dir.joinpath(driver_name+"_luacov.report.html") + if html_source.exists(): + shutil.copy(html_source, html_dest) + else: + print(f"Warning: HTML coverage file for {driver_name} not found") total_test_info = "Total unit tests passes: {}/{}".format(total_passes, total_tests) print("#" * len(total_test_info)) @@ -165,6 +193,7 @@ def run_tests(verbosity_level, filter, junit, coverage_files): parser.add_argument("--filter", "-f", type=str, nargs="?", help="only run tests containing the filter value in the path") parser.add_argument("--junit", "-j", type=str, nargs="?", help="output test results in JUnit XML to the specified file") parser.add_argument("--coverage", "-c", nargs="*", help="run code tests with coverage (luacov must be installed) OPTIONAL: restrict files to run coverage tests for") + parser.add_argument("--html", action="store_true", help="Generate HTML coverage reports for the files specified by the coverage argument") args = parser.parse_args() verbosity_level = 0 if args.verbose: @@ -173,5 +202,4 @@ def run_tests(verbosity_level, filter, junit, coverage_files): verbosity_level = 2 elif args.superextraverbose: verbosity_level = 3 - run_tests(verbosity_level, args.filter, args.junit, args.coverage) - + run_tests(verbosity_level, args.filter, args.junit, args.coverage, args.html) diff --git a/tools/run_driver_tests_p.py b/tools/run_driver_tests_p.py index 16180f7682..d19bf46716 100644 --- a/tools/run_driver_tests_p.py +++ b/tools/run_driver_tests_p.py @@ -29,15 +29,19 @@ def per_driver_task(driver_dir): else: failure_output = None if driver_dir.name in CHANGED_DRIVERS: - with driver_dir.parent.parent.parent.joinpath("tools/coverage_output").joinpath(driver_dir.name+"_coverage.xml") as outfile: - subprocess.run("luacov-cobertura -o {} -c {}".format(outfile, LUACOV_CONFIG), shell=True) + outfile = driver_dir.parent.parent.parent.joinpath("tools/coverage_output").joinpath(driver_dir.name+"_coverage.xml") + subprocess.run("luacov-cobertura -o {} -c {}".format(outfile, LUACOV_CONFIG), shell=True) return failure_output def run_test(test_file): + # Propagate ST_CAPABILITY_JSON_DIR so the mock capability channel can load + # capability definitions from pre-fetched JSON files produced by + # tools/fetch_capability_definitions.py. + env = os.environ.copy() if test_file.parent.parent.parent.name in CHANGED_DRIVERS: - a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) else: - a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) error = a.stderr.decode() if error and error != "": print(error) diff --git a/tools/update_min_tags.py b/tools/update_min_tags.py new file mode 100644 index 0000000000..1af892b1cc --- /dev/null +++ b/tools/update_min_tags.py @@ -0,0 +1,100 @@ +import argparse +import re +import os +import subprocess + +if os.environ.get("LUA_PATH") == None: + print("LUA_PATH environment variable must be set") + exit(1) +script = 'local v=require(\"version\"); print(v.api)' +LIBS_VERSION = int(os.popen(f"lua -e '{script}'").read().strip()) +print(f"Found lua-libs version: {LIBS_VERSION}") + +TEST_CODE_REGEX = r"(test\.register_(?:coroutine|message)_test\(\s*\"([^\"]+)\"[\s\S]*?min_api_version\s*=\s*)(\d+)([\s\S]*?\))" +TEST_RESULT_REGEX = r"Running test \"(.+?(?=\"))\" \(\d+ of \d+\)\n(PASSED|FAILED)" +TEST_FILE_REGEX = r"Running tests from (\S+.lua)" + +def capture_test_output(test_filter = None) -> str: + command = f"python3 tools/run_driver_tests.py -v".split(" ") + if test_filter != None: + command = f"python3 tools/run_driver_tests.py --filter {test_filter} -v".split(" ") + + print(f"Running command: {command}") + proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + print(f"Done") + return proc.stdout.decode('utf-8') + +def get_results_from_output(output:str) -> dict: + passing = {} + failing = {} + while (m := re.search(TEST_FILE_REGEX,output)): + # print(m.group(1)) + output = output[m.end():] + + passed = [] + failed = [] + while True: + eot_index = len(output) + if eot := (re.search(r"#{10,}", output) or re.search(TEST_FILE_REGEX, output)): + eot_index = eot.start() + + if t := re.search(TEST_RESULT_REGEX,output[:eot_index]): + if t.group(2) == 'PASSED': + passed.append(t.group(1)) + else: + failed.append(t.group(1)) + output = output[t.end():] + else: + output = output[eot_index:] + break + if len(passed) > 0: + passing[m.group(1)] = passed + if len(failed) > 0: + failing[m.group(1)] = failed + return (passing,failing) + +def show_failing_tests(failing): + for filename in failing: + tests = failing[filename] + print(f"Failing tests for: {filename}") + for tn in tests: + print(f" {tn}") + +def update_passing_tests(passing): + for filename in passing: + # print(f"Parsing file: {filename}") + fn = filename + passing_tests = list(passing[filename]) + + def replace_function(match): + if match.group(2) in passing_tests and int(match.group(3)) > LIBS_VERSION: + return f"{match.group(1)}{LIBS_VERSION}{match.group(4)}" + else: + return match.group(0) + + + with open(filename,'r') as fd: + file_contents = fd.read() + + updated_test_file = re.sub(TEST_CODE_REGEX,replace_function,file_contents) + + with open(filename + ".new", 'w') as fd: + fd.write(updated_test_file) + + os.replace(filename + ".new", filename) + print(f"Updated: {filename}") + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="""Runs driver tests against lua libs, parses the output of the tests results, + and updates the min_api_version of the tests that successfully passed if the test version is + less than the test's min_api_version.NOTE: this CAN NOT be done if test filtering is active. + If enabled, disable this by temporarily removing the code or commenting it out.""") + parser.add_argument("--filter", "-f", help="Filter of tests to run and update min_api_version for. Argument is passed into the tools/run_driver_tests.py --filter ",default=None) + args = parser.parse_args() + + test_output = capture_test_output(args.filter) + passing, failing = get_results_from_output(test_output) + update_passing_tests(passing) + show_failing_tests(failing) +